Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:07:34+02:00
Commit #18 · 15 snippets
AGENTS.md | 2 +- CLAUDE.md | 2 +- docs/configuration.md | 5 ++++- knowledge.md | 2 +- .../_config.py | 10 ++++++++- tests/test_config.py | 24 ++++++++++++++++++++++ 6 files changed, 40 insertions(+), 5 deletions(-)
| 53 | device: str = "CPU:0" | 53 | device: str = "CPU:0" |
| 54 | visualize: bool = False | 54 | visualize: bool = False |
| 55 | visualize_las_segment_coloring: bool = False | 55 | visualize_las_segment_coloring: bool = False |
| 56 | save_points_between_planes: bool = True | 56 | save_points_between_planes: bool = True |
| 57 | las_points_per_chunk: int = 500000 | 57 | las_points_per_chunk: int = pydantic.Field(default=500000, gt=0) |
| 58 | max_parallel_las_files: int = 1 | 58 | max_parallel_las_files: int = 1 |
| 59 | angle_limit: int | None = 80 | 59 | angle_limit: int | None = 80 |
| 60 | n_extra_planes: int = 4 | 60 | n_extra_planes: int = 4 |
| 61 | segments_base_dir_name: str = "lane_points" | 61 | segments_base_dir_name: str = "lane_points" |
| 78 | if value is None: | 78 | if value is None: |
| 79 | return {} | 79 | return {} |
| 80 | return value | 80 | return value |
| 81 | 81 | ||
| 82 | @pydantic.field_validator("write_only_segments", mode="before") | ||
| 83 | @classmethod | ||
| 84 | def _none_write_only_segments_is_empty(cls, value: Any) -> Any: | ||
| 85 | """Treat a JSON ``null`` as 'no restriction', as the pre-pydantic code did.""" | ||
| 86 | if value is None: | ||
| 87 | return [] | ||
| 88 | return value | ||
| 89 | |||
| 82 | 90 | ||
| 83 | def _load_segment_mapper_model( | 91 | def _load_segment_mapper_model( |
| 84 | *, | 92 | *, |
| 85 | overrides: dict[str, Any] | None = None, | 93 | overrides: dict[str, Any] | None = None, |
| 164 | config = normalize_segment_mapper_config({"file_naming": None}) | 164 | config = normalize_segment_mapper_config({"file_naming": None}) |
| 165 | assert config["file_naming"] == EXPECTED_FILE_NAMING_DEFAULTS | 165 | assert config["file_naming"] == EXPECTED_FILE_NAMING_DEFAULTS |
| 166 | 166 | ||
| 167 | 167 | ||
| 168 | def test_visualization_colors_none_yields_defaults() -> None: | ||
| 169 | config = normalize_segment_mapper_config({"visualization_colors": None}) | ||
| 170 | assert config["visualization_colors"] == ( | ||
| 171 | EXPECTED_TOP_LEVEL_DEFAULTS["visualization_colors"] | ||
| 172 | ) | ||
| 173 | |||
| 174 | |||
| 175 | def test_write_only_segments_none_means_no_restriction() -> None: | ||
| 176 | """A JSON ``null`` keeps the pre-pydantic 'write every segment' behaviour.""" | ||
| 177 | config = normalize_segment_mapper_config({"write_only_segments": None}) | ||
| 178 | assert config["write_only_segments"] == [] | ||
| 179 | |||
| 180 | |||
| 181 | def test_write_only_segments_accepts_int_list() -> None: | ||
| 182 | config = normalize_segment_mapper_config({"write_only_segments": [3, 7]}) | ||
| 183 | assert config["write_only_segments"] == [3, 7] | ||
| 184 | |||
| 185 | |||
| 186 | def test_las_points_per_chunk_must_be_positive() -> None: | ||
| 187 | for bad in (0, -1): | ||
| 188 | with pytest.raises(SegmentMapperConfigError): | ||
| 189 | normalize_segment_mapper_config({"las_points_per_chunk": bad}) | ||
| 190 | |||
| 191 | |||
| 168 | def test_longitudinal_limit_distance_must_be_positive() -> None: | 192 | def test_longitudinal_limit_distance_must_be_positive() -> None: |
| 169 | with pytest.raises(SegmentMapperConfigError): | 193 | with pytest.raises(SegmentMapperConfigError): |
| 170 | normalize_segment_mapper_config({"longitudinal_limit_distance_m": 0}) | 194 | normalize_segment_mapper_config({"longitudinal_limit_distance_m": 0}) |
| 171 | 195 |
| 35 | 6. If `save_points_between_planes`, call `divide_las_file_by_planes` for each LAS — single-threaded on CUDA, otherwise a `ThreadPoolExecutor(max_workers=max_parallel_las_files)`. Per-segment `.npz` files land under `<segments_base_dir>/segment_NNN/` (3-digit zero-padded index, matching the `point{i:03d}`/`normal{i:03d}` keys in `run3_planes.npz`) and `save_version_json` drops a `run3_versions.json` next to them. | 35 | 6. If `save_points_between_planes`, call `divide_las_file_by_planes` for each LAS — single-threaded on CUDA, otherwise a `ThreadPoolExecutor(max_workers=max_parallel_las_files)`. Per-segment `.npz` files land under `<segments_base_dir>/segment_NNN/` (3-digit zero-padded index, matching the `point{i:03d}`/`normal{i:03d}` keys in `run3_planes.npz`) and `save_version_json` drops a `run3_versions.json` next to them. |
| 36 | 36 | ||
| 37 | - **`divide_las_file_by_planes`** streams the LAS via `laspy.open(...).chunk_iterator(las_points_per_chunk)`, never loading the whole file. Per chunk: optional `|scan_angle| < angle_limit` filter, then a sign-count mask against all selected planes assigns each point to a segment bucket. Scan-angle field is auto-detected (`scan_angle_rank` legacy vs `scan_angle` newer); RGB and intensity are required and the code raises if missing. Each per-segment `.npz` also carries `number_of_returns` (uint8, AI3D-382); LAS files without that field degrade to zeros, which the run3 NPZ contract reads as "unknown" (0 is not a legal LAS return count). Output points are written **geoshift-relative**. | 37 | - **`divide_las_file_by_planes`** streams the LAS via `laspy.open(...).chunk_iterator(las_points_per_chunk)`, never loading the whole file. Per chunk: optional `|scan_angle| < angle_limit` filter, then a sign-count mask against all selected planes assigns each point to a segment bucket. Scan-angle field is auto-detected (`scan_angle_rank` legacy vs `scan_angle` newer); RGB and intensity are required and the code raises if missing. Each per-segment `.npz` also carries `number_of_returns` (uint8, AI3D-382); LAS files without that field degrade to zeros, which the run3 NPZ contract reads as "unknown" (0 is not a legal LAS return count). Output points are written **geoshift-relative**. |
| 38 | 38 | ||
| 39 | - **`_config.py`** — pydantic `config_loader.ConfigModel` tree (`SegmentMapperConfig` plus nested section models) validated by `iolabs.common.config_loader.load_config`. Unknown keys raise `SegmentMapperConfigError` (a `config_loader.ConfigError`). `normalize_segment_mapper_config` / `load_segment_mapper_config` / `build_segment_mapper_config` return plain dicts (`model.model_dump()`); overrides deep-merge onto the packaged JSON. **When adding a new config key, add a field to the model and a matching default in `segment_mapper.default.json`. Nothing else.** | 39 | - **`_config.py`** — pydantic `config_loader.ConfigModel` tree (`SegmentMapperConfig` plus nested section models) validated by `iolabs.common.config_loader.load_config`. Unknown keys raise `SegmentMapperConfigError` (a `config_loader.ConfigError`). `normalize_segment_mapper_config` / `load_segment_mapper_config` / `build_segment_mapper_config` return plain dicts (`model.model_dump()`); overrides deep-merge onto the packaged JSON, while an explicit `config_path` replaces it (omitted keys fall back to the model defaults, which the packaged JSON mirrors). **When adding a new config key, add a field to the model and a matching default in `segment_mapper.default.json`. Nothing else.** |
| 40 | 40 | ||
| 41 | - **`segment_mapper.default.json`** — bundled defaults. It is force-included into the wheel via `[tool.hatch.build.targets.wheel.force-include]` in `pyproject.toml`; if you rename or move it, update that mapping or the installed package will be missing the file at runtime. | 41 | - **`segment_mapper.default.json`** — bundled defaults. It is force-included into the wheel via `[tool.hatch.build.targets.wheel.force-include]` in `pyproject.toml`; if you rename or move it, update that mapping or the installed package will be missing the file at runtime. |
| 42 | 42 | ||
| 43 | - **`_log_props.py`** — `LOG_PROPS = {"pipeline_step": "s3", ...}` is attached to every logger via `iolabs.logstash.get_props_logger`. Per-LAS-file work is wrapped in `las_file_scope(las_file)` so log records carry the LAS filename context — keep new per-file code paths inside that scope. | 43 | - **`_log_props.py`** — `LOG_PROPS = {"pipeline_step": "s3", ...}` is attached to every logger via `iolabs.logstash.get_props_logger`. Per-LAS-file work is wrapped in `las_file_scope(las_file)` so log records carry the LAS filename context — keep new per-file code paths inside that scope. |
| 43 | 4. an entry in `required_chunk_fields` inside `divide_las_file_by_planes` **if the field is mandatory** — that tuple is what turns a missing field into an upfront `ValueError` instead of a later `AttributeError`. | 43 | 4. an entry in `required_chunk_fields` inside `divide_las_file_by_planes` **if the field is mandatory** — that tuple is what turns a missing field into an upfront `ValueError` instead of a later `AttributeError`. |
| 44 | 44 | ||
| 45 | `SEGMENT_NPZ_FIELD_NAMES` / `NUMBER_OF_RETURNS_KEY` / `NUMBER_OF_RETURNS_DTYPE` duplicate the schema owned by `iolabs.common.segment_points_io` (mirrored, not imported, so this module stays importable against older `iolabs-common`). `tests/test_number_of_returns.py::test_npz_schema_matches_common_segment_points_io` guards the duplication; it `importorskip`s the consumer module, so it is inert until the `iolabs-common` floor is raised to a release that ships it, and then activates on its own. For the same cross-version reason, `SegmentMapper.load_color_intensity_data` builds its `ColorIntensityData` kwargs filtered by `dataclasses.fields(...)`: passing a kwarg the installed dataclass does not declare is a `TypeError`, so a field the installed `iolabs-common` predates is dropped rather than forced. | 45 | `SEGMENT_NPZ_FIELD_NAMES` / `NUMBER_OF_RETURNS_KEY` / `NUMBER_OF_RETURNS_DTYPE` duplicate the schema owned by `iolabs.common.segment_points_io` (mirrored, not imported, so this module stays importable against older `iolabs-common`). `tests/test_number_of_returns.py::test_npz_schema_matches_common_segment_points_io` guards the duplication; it `importorskip`s the consumer module, so it is inert until the `iolabs-common` floor is raised to a release that ships it, and then activates on its own. For the same cross-version reason, `SegmentMapper.load_color_intensity_data` builds its `ColorIntensityData` kwargs filtered by `dataclasses.fields(...)`: passing a kwarg the installed dataclass does not declare is a `TypeError`, so a field the installed `iolabs-common` predates is dropped rather than forced. |
| 46 | 46 | ||
| 47 | - **`_config.py`** — pydantic `config_loader.ConfigModel` tree (`SegmentMapperConfig` plus nested section models) validated by `iolabs.common.config_loader.load_config`. Unknown keys raise `SegmentMapperConfigError` (a `config_loader.ConfigError`). `normalize_segment_mapper_config` / `load_segment_mapper_config` / `build_segment_mapper_config` return plain dicts (`model.model_dump()`); overrides deep-merge onto the packaged JSON. **When adding a new config key, add a field to the model and a matching default in `segment_mapper.default.json`. Nothing else.** | 47 | - **`_config.py`** — pydantic `config_loader.ConfigModel` tree (`SegmentMapperConfig` plus nested section models) validated by `iolabs.common.config_loader.load_config`. Unknown keys raise `SegmentMapperConfigError` (a `config_loader.ConfigError`). `normalize_segment_mapper_config` / `load_segment_mapper_config` / `build_segment_mapper_config` return plain dicts (`model.model_dump()`); overrides deep-merge onto the packaged JSON, while an explicit `config_path` replaces it (omitted keys fall back to the model defaults, which the packaged JSON mirrors). **When adding a new config key, add a field to the model and a matching default in `segment_mapper.default.json`. Nothing else.** |
| 48 | 48 | ||
| 49 | - **`segment_mapper.default.json`** — bundled defaults. It is force-included into the wheel via `[tool.hatch.build.targets.wheel.force-include]` in `pyproject.toml`; if you rename or move it, update that mapping or the installed package will be missing the file at runtime. | 49 | - **`segment_mapper.default.json`** — bundled defaults. It is force-included into the wheel via `[tool.hatch.build.targets.wheel.force-include]` in `pyproject.toml`; if you rename or move it, update that mapping or the installed package will be missing the file at runtime. |
| 50 | 50 | ||
| 51 | - **`_log_props.py`** — `LOG_PROPS = {"pipeline_step": "s3", ...}` is attached to every logger via `iolabs.logstash.get_props_logger`. Per-LAS-file work is wrapped in `las_file_scope(las_file)` so log records carry the LAS filename context — keep new per-file code paths inside that scope. | 51 | - **`_log_props.py`** — `LOG_PROPS = {"pipeline_step": "s3", ...}` is attached to every logger via `iolabs.logstash.get_props_logger`. Per-LAS-file work is wrapped in `las_file_scope(las_file)` so log records carry the LAS filename context — keep new per-file code paths inside that scope. |
| 5 | keys raise `SegmentMapperConfigError`. | 5 | keys raise `SegmentMapperConfigError`. |
| 6 | 6 | ||
| 7 | Defaults are loaded from | 7 | Defaults are loaded from |
| 8 | `src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.default.json`. | 8 | `src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.default.json`. |
| 9 | Partial nested overrides are deep-merged with the bundled defaults. | 9 | Partial nested overrides are deep-merged with the bundled defaults. A config |
| 10 | file passed explicitly by path replaces the bundled JSON rather than extending | ||
| 11 | it; keys it omits fall back to the same values the bundled JSON carries. | ||
| 10 | 12 | ||
| 11 | ## Example | 13 | ## Example |
| 12 | 14 | ||
| 13 | ```json | 15 | ```json |
| 51 | | `reuse_existing_planes` | `false` | Loads planes from `<output_dir>/lane_points/<planes_filename>` instead of regenerating them. Requires a compatible saved `.npz` archive. | | 53 | | `reuse_existing_planes` | `false` | Loads planes from `<output_dir>/lane_points/<planes_filename>` instead of regenerating them. Requires a compatible saved `.npz` archive. | |
| 52 | | `reuse_existing_geoshift` | `false` | Loads geoshift from `<output_dir>/lane_points/<geoshift_filename>` instead of recomputing it from connected spline points. | | 54 | | `reuse_existing_geoshift` | `false` | Loads geoshift from `<output_dir>/lane_points/<geoshift_filename>` instead of recomputing it from connected spline points. | |
| 53 | | `enable_longitudinal_limit_planes` | `true` | Builds side limit planes for each segment and drops saved points outside the left/right corridor. This affects LAS splitting and the LAS coloring visualization. | | 55 | | `enable_longitudinal_limit_planes` | `true` | Builds side limit planes for each segment and drops saved points outside the left/right corridor. This affects LAS splitting and the LAS coloring visualization. | |
| 54 | | `longitudinal_limit_distance_m` | `100.0` | Left/right offset distance, in metres, used to build longitudinal limit planes. Must be greater than zero. | | 56 | | `longitudinal_limit_distance_m` | `100.0` | Left/right offset distance, in metres, used to build longitudinal limit planes. Must be greater than zero. | |
| 57 | | `write_only_segments` | `[]` | Restricts LAS splitting to the listed segment indices; an empty list (or `null`) writes every segment. Useful for re-running a few failed segments without redoing the whole split. | | ||
| 55 | | `save_longitudinal_limit_planes` | `true` | Writes longitudinal limit planes to `<output_dir>/lane_points/<longitudinal_limit_planes_filename>` when longitudinal limits are enabled. | | 58 | | `save_longitudinal_limit_planes` | `true` | Writes longitudinal limit planes to `<output_dir>/lane_points/<longitudinal_limit_planes_filename>` when longitudinal limits are enabled. | |
| 56 | | `visualization_colors` | See below | RGB colors used by visualization-only code. Values are lists of three numbers. | | 59 | | `visualization_colors` | See below | RGB colors used by visualization-only code. Values are lists of three numbers. | |
| 57 | | `file_naming` | See below | Output filename overrides. Partial overrides keep unspecified defaults. | | 60 | | `file_naming` | See below | Output filename overrides. Partial overrides keep unspecified defaults. | |
| 58 | 61 |
| 36 | 7. If `save_points_between_planes`: call `divide_las_file_by_planes` per LAS. **Single-threaded on CUDA**; otherwise `max_workers = max(1, min(max_parallel_las_files, os.cpu_count()))`, and a serial path is taken when `max_workers == 1` or only one LAS file is queued — else a `ThreadPoolExecutor` runs the splits in parallel. Per-segment `.npz` files land under `<segments_base_dir>/segment_NNN/` (3-digit zero-padded, matching the `point{i:03d}`/`normal{i:03d}` keys in `run3_planes.npz`). `save_version_json` then drops `run3_versions.json` in each segment dir. | 36 | 7. If `save_points_between_planes`: call `divide_las_file_by_planes` per LAS. **Single-threaded on CUDA**; otherwise `max_workers = max(1, min(max_parallel_las_files, os.cpu_count()))`, and a serial path is taken when `max_workers == 1` or only one LAS file is queued — else a `ThreadPoolExecutor` runs the splits in parallel. Per-segment `.npz` files land under `<segments_base_dir>/segment_NNN/` (3-digit zero-padded, matching the `point{i:03d}`/`normal{i:03d}` keys in `run3_planes.npz`). `save_version_json` then drops `run3_versions.json` in each segment dir. |
| 37 | 37 | ||
| 38 | - **`divide_las_file_by_planes`** streams the LAS via `laspy.open(...).chunk_iterator(las_points_per_chunk)` — never loads the whole file. Per chunk: optional `|scan_angle| < angle_limit` filter, then a sign-count mask against all selected planes assigns each point to a segment bucket. Scan-angle field is auto-detected (`scan_angle_rank` legacy vs `scan_angle` newer). RGB and intensity are **required** — missing fields raise. Each per-segment `.npz` also carries `number_of_returns` (uint8, AI3D-382); LAS files without that field degrade to zeros, which the run3 NPZ contract reads as "unknown" (0 is not a legal LAS return count). Output points are written **geoshift-relative**. | 38 | - **`divide_las_file_by_planes`** streams the LAS via `laspy.open(...).chunk_iterator(las_points_per_chunk)` — never loads the whole file. Per chunk: optional `|scan_angle| < angle_limit` filter, then a sign-count mask against all selected planes assigns each point to a segment bucket. Scan-angle field is auto-detected (`scan_angle_rank` legacy vs `scan_angle` newer). RGB and intensity are **required** — missing fields raise. Each per-segment `.npz` also carries `number_of_returns` (uint8, AI3D-382); LAS files without that field degrade to zeros, which the run3 NPZ contract reads as "unknown" (0 is not a legal LAS return count). Output points are written **geoshift-relative**. |
| 39 | 39 | ||
| 40 | - **`_config.py`** — pydantic `config_loader.ConfigModel` tree (`SegmentMapperConfig` plus nested section models) validated by `iolabs.common.config_loader.load_config`. Unknown keys raise `SegmentMapperConfigError` (a `config_loader.ConfigError`). `normalize_segment_mapper_config` / `load_segment_mapper_config` / `build_segment_mapper_config` return plain dicts (`model.model_dump()`); overrides deep-merge onto the packaged JSON. | 40 | - **`_config.py`** — pydantic `config_loader.ConfigModel` tree (`SegmentMapperConfig` plus nested section models) validated by `iolabs.common.config_loader.load_config`. Unknown keys raise `SegmentMapperConfigError` (a `config_loader.ConfigError`). `normalize_segment_mapper_config` / `load_segment_mapper_config` / `build_segment_mapper_config` return plain dicts (`model.model_dump()`); overrides deep-merge onto the packaged JSON, while an explicit `config_path` replaces it (omitted keys fall back to the model defaults, which the packaged JSON mirrors). |
| 41 | 41 | ||
| 42 | - **`segment_mapper.default.json`** — bundled defaults. Force-included into the wheel via `[tool.hatch.build.targets.wheel.force-include]` in `pyproject.toml`. If you rename/move it, update that mapping or the installed package will be missing it at runtime. | 42 | - **`segment_mapper.default.json`** — bundled defaults. Force-included into the wheel via `[tool.hatch.build.targets.wheel.force-include]` in `pyproject.toml`. If you rename/move it, update that mapping or the installed package will be missing it at runtime. |
| 43 | 43 | ||
| 44 | - **`_log_props.py`** — `LOG_PROPS = {"pipeline_step": "s3", ...}` is attached to every logger via `iolabs.logstash.get_props_logger`. Per-LAS work is wrapped in `las_file_scope(las_file)` so log records carry LAS filename context. | 44 | - **`_log_props.py`** — `LOG_PROPS = {"pipeline_step": "s3", ...}` is attached to every logger via `iolabs.logstash.get_props_logger`. Per-LAS work is wrapped in `las_file_scope(las_file)` so log records carry LAS filename context. |
| 43 | 4. an entry in `required_chunk_fields` inside `divide_las_file_by_planes` **if the field is mandatory** — that tuple is what turns a missing field into an upfront `ValueError` instead of a later `AttributeError`. | 43 | 4. an entry in `required_chunk_fields` inside `divide_las_file_by_planes` **if the field is mandatory** — that tuple is what turns a missing field into an upfront `ValueError` instead of a later `AttributeError`. |
| 44 | 44 | ||
| 45 | `SEGMENT_NPZ_FIELD_NAMES` / `NUMBER_OF_RETURNS_KEY` / `NUMBER_OF_RETURNS_DTYPE` duplicate the schema owned by `iolabs.common.segment_points_io` (mirrored, not imported, so this module stays importable against older `iolabs-common`). `tests/test_number_of_returns.py::test_npz_schema_matches_common_segment_points_io` guards the duplication; it `importorskip`s the consumer module, so it is inert until the `iolabs-common` floor is raised to a release that ships it, and then activates on its own. For the same cross-version reason, `SegmentMapper.load_color_intensity_data` builds its `ColorIntensityData` kwargs filtered by `dataclasses.fields(...)`: passing a kwarg the installed dataclass does not declare is a `TypeError`, so a field the installed `iolabs-common` predates is dropped rather than forced. | 45 | `SEGMENT_NPZ_FIELD_NAMES` / `NUMBER_OF_RETURNS_KEY` / `NUMBER_OF_RETURNS_DTYPE` duplicate the schema owned by `iolabs.common.segment_points_io` (mirrored, not imported, so this module stays importable against older `iolabs-common`). `tests/test_number_of_returns.py::test_npz_schema_matches_common_segment_points_io` guards the duplication; it `importorskip`s the consumer module, so it is inert until the `iolabs-common` floor is raised to a release that ships it, and then activates on its own. For the same cross-version reason, `SegmentMapper.load_color_intensity_data` builds its `ColorIntensityData` kwargs filtered by `dataclasses.fields(...)`: passing a kwarg the installed dataclass does not declare is a `TypeError`, so a field the installed `iolabs-common` predates is dropped rather than forced. |
| 46 | 46 | ||
| 47 | - **`_config.py`** — pydantic `config_loader.ConfigModel` tree (`SegmentMapperConfig` plus nested section models) validated by `iolabs.common.config_loader.load_config`. Unknown keys raise `SegmentMapperConfigError` (a `config_loader.ConfigError`). `normalize_segment_mapper_config` / `load_segment_mapper_config` / `build_segment_mapper_config` return plain dicts (`model.model_dump()`); overrides deep-merge onto the packaged JSON. **When adding a new config key, add a field to the model and a matching default in `segment_mapper.default.json`. Nothing else.** | 47 | - **`_config.py`** — pydantic `config_loader.ConfigModel` tree (`SegmentMapperConfig` plus nested section models) validated by `iolabs.common.config_loader.load_config`. Unknown keys raise `SegmentMapperConfigError` (a `config_loader.ConfigError`). `normalize_segment_mapper_config` / `load_segment_mapper_config` / `build_segment_mapper_config` return plain dicts (`model.model_dump()`); overrides deep-merge onto the packaged JSON, while an explicit `config_path` replaces it (omitted keys fall back to the model defaults, which the packaged JSON mirrors). **When adding a new config key, add a field to the model and a matching default in `segment_mapper.default.json`. Nothing else.** |
| 48 | 48 | ||
| 49 | - **`segment_mapper.default.json`** — bundled defaults. It is force-included into the wheel via `[tool.hatch.build.targets.wheel.force-include]` in `pyproject.toml`; if you rename or move it, update that mapping or the installed package will be missing the file at runtime. | 49 | - **`segment_mapper.default.json`** — bundled defaults. It is force-included into the wheel via `[tool.hatch.build.targets.wheel.force-include]` in `pyproject.toml`; if you rename or move it, update that mapping or the installed package will be missing the file at runtime. |
| 50 | 50 | ||
| 51 | - **`_log_props.py`** — `LOG_PROPS = {"pipeline_step": "s3", ...}` is attached to every logger via `iolabs.logstash.get_props_logger`. Per-LAS-file work is wrapped in `las_file_scope(las_file)` so log records carry the LAS filename context — keep new per-file code paths inside that scope. | 51 | - **`_log_props.py`** — `LOG_PROPS = {"pipeline_step": "s3", ...}` is attached to every logger via `iolabs.logstash.get_props_logger`. Per-LAS-file work is wrapped in `las_file_scope(las_file)` so log records carry the LAS filename context — keep new per-file code paths inside that scope. |
| 5 | keys raise `SegmentMapperConfigError`. | 5 | keys raise `SegmentMapperConfigError`. |
| 6 | 6 | ||
| 7 | Defaults are loaded from | 7 | Defaults are loaded from |
| 8 | `src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.default.json`. | 8 | `src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.default.json`. |
| 9 | Partial nested overrides are deep-merged with the bundled defaults. | 9 | Partial nested overrides are deep-merged with the bundled defaults. A config |
| 10 | file passed explicitly by path replaces the bundled JSON rather than extending | ||
| 11 | it; keys it omits fall back to the same values the bundled JSON carries. | ||
| 10 | 12 | ||
| 11 | ## Example | 13 | ## Example |
| 12 | 14 | ||
| 13 | ```json | 15 | ```json |
| 51 | | `reuse_existing_planes` | `false` | Loads planes from `<output_dir>/lane_points/<planes_filename>` instead of regenerating them. Requires a compatible saved `.npz` archive. | | 53 | | `reuse_existing_planes` | `false` | Loads planes from `<output_dir>/lane_points/<planes_filename>` instead of regenerating them. Requires a compatible saved `.npz` archive. | |
| 52 | | `reuse_existing_geoshift` | `false` | Loads geoshift from `<output_dir>/lane_points/<geoshift_filename>` instead of recomputing it from connected spline points. | | 54 | | `reuse_existing_geoshift` | `false` | Loads geoshift from `<output_dir>/lane_points/<geoshift_filename>` instead of recomputing it from connected spline points. | |
| 53 | | `enable_longitudinal_limit_planes` | `true` | Builds side limit planes for each segment and drops saved points outside the left/right corridor. This affects LAS splitting and the LAS coloring visualization. | | 55 | | `enable_longitudinal_limit_planes` | `true` | Builds side limit planes for each segment and drops saved points outside the left/right corridor. This affects LAS splitting and the LAS coloring visualization. | |
| 54 | | `longitudinal_limit_distance_m` | `100.0` | Left/right offset distance, in metres, used to build longitudinal limit planes. Must be greater than zero. | | 56 | | `longitudinal_limit_distance_m` | `100.0` | Left/right offset distance, in metres, used to build longitudinal limit planes. Must be greater than zero. | |
| 57 | | `write_only_segments` | `[]` | Restricts LAS splitting to the listed segment indices; an empty list (or `null`) writes every segment. Useful for re-running a few failed segments without redoing the whole split. | | ||
| 55 | | `save_longitudinal_limit_planes` | `true` | Writes longitudinal limit planes to `<output_dir>/lane_points/<longitudinal_limit_planes_filename>` when longitudinal limits are enabled. | | 58 | | `save_longitudinal_limit_planes` | `true` | Writes longitudinal limit planes to `<output_dir>/lane_points/<longitudinal_limit_planes_filename>` when longitudinal limits are enabled. | |
| 56 | | `visualization_colors` | See below | RGB colors used by visualization-only code. Values are lists of three numbers. | | 59 | | `visualization_colors` | See below | RGB colors used by visualization-only code. Values are lists of three numbers. | |
| 57 | | `file_naming` | See below | Output filename overrides. Partial overrides keep unspecified defaults. | | 60 | | `file_naming` | See below | Output filename overrides. Partial overrides keep unspecified defaults. | |
| 58 | 61 |
| 36 | 7. If `save_points_between_planes`: call `divide_las_file_by_planes` per LAS. **Single-threaded on CUDA**; otherwise `max_workers = max(1, min(max_parallel_las_files, os.cpu_count()))`, and a serial path is taken when `max_workers == 1` or only one LAS file is queued — else a `ThreadPoolExecutor` runs the splits in parallel. Per-segment `.npz` files land under `<segments_base_dir>/segment_NNN/` (3-digit zero-padded, matching the `point{i:03d}`/`normal{i:03d}` keys in `run3_planes.npz`). `save_version_json` then drops `run3_versions.json` in each segment dir. | 36 | 7. If `save_points_between_planes`: call `divide_las_file_by_planes` per LAS. **Single-threaded on CUDA**; otherwise `max_workers = max(1, min(max_parallel_las_files, os.cpu_count()))`, and a serial path is taken when `max_workers == 1` or only one LAS file is queued — else a `ThreadPoolExecutor` runs the splits in parallel. Per-segment `.npz` files land under `<segments_base_dir>/segment_NNN/` (3-digit zero-padded, matching the `point{i:03d}`/`normal{i:03d}` keys in `run3_planes.npz`). `save_version_json` then drops `run3_versions.json` in each segment dir. |
| 37 | 37 | ||
| 38 | - **`divide_las_file_by_planes`** streams the LAS via `laspy.open(...).chunk_iterator(las_points_per_chunk)` — never loads the whole file. Per chunk: optional `|scan_angle| < angle_limit` filter, then a sign-count mask against all selected planes assigns each point to a segment bucket. Scan-angle field is auto-detected (`scan_angle_rank` legacy vs `scan_angle` newer). RGB and intensity are **required** — missing fields raise. Each per-segment `.npz` also carries `number_of_returns` (uint8, AI3D-382); LAS files without that field degrade to zeros, which the run3 NPZ contract reads as "unknown" (0 is not a legal LAS return count). Output points are written **geoshift-relative**. | 38 | - **`divide_las_file_by_planes`** streams the LAS via `laspy.open(...).chunk_iterator(las_points_per_chunk)` — never loads the whole file. Per chunk: optional `|scan_angle| < angle_limit` filter, then a sign-count mask against all selected planes assigns each point to a segment bucket. Scan-angle field is auto-detected (`scan_angle_rank` legacy vs `scan_angle` newer). RGB and intensity are **required** — missing fields raise. Each per-segment `.npz` also carries `number_of_returns` (uint8, AI3D-382); LAS files without that field degrade to zeros, which the run3 NPZ contract reads as "unknown" (0 is not a legal LAS return count). Output points are written **geoshift-relative**. |
| 39 | 39 | ||
| 40 | - **`_config.py`** — pydantic `config_loader.ConfigModel` tree (`SegmentMapperConfig` plus nested section models) validated by `iolabs.common.config_loader.load_config`. Unknown keys raise `SegmentMapperConfigError` (a `config_loader.ConfigError`). `normalize_segment_mapper_config` / `load_segment_mapper_config` / `build_segment_mapper_config` return plain dicts (`model.model_dump()`); overrides deep-merge onto the packaged JSON. | 40 | - **`_config.py`** — pydantic `config_loader.ConfigModel` tree (`SegmentMapperConfig` plus nested section models) validated by `iolabs.common.config_loader.load_config`. Unknown keys raise `SegmentMapperConfigError` (a `config_loader.ConfigError`). `normalize_segment_mapper_config` / `load_segment_mapper_config` / `build_segment_mapper_config` return plain dicts (`model.model_dump()`); overrides deep-merge onto the packaged JSON, while an explicit `config_path` replaces it (omitted keys fall back to the model defaults, which the packaged JSON mirrors). |
| 41 | 41 | ||
| 42 | - **`segment_mapper.default.json`** — bundled defaults. Force-included into the wheel via `[tool.hatch.build.targets.wheel.force-include]` in `pyproject.toml`. If you rename/move it, update that mapping or the installed package will be missing it at runtime. | 42 | - **`segment_mapper.default.json`** — bundled defaults. Force-included into the wheel via `[tool.hatch.build.targets.wheel.force-include]` in `pyproject.toml`. If you rename/move it, update that mapping or the installed package will be missing it at runtime. |
| 43 | 43 | ||
| 44 | - **`_log_props.py`** — `LOG_PROPS = {"pipeline_step": "s3", ...}` is attached to every logger via `iolabs.logstash.get_props_logger`. Per-LAS work is wrapped in `las_file_scope(las_file)` so log records carry LAS filename context. | 44 | - **`_log_props.py`** — `LOG_PROPS = {"pipeline_step": "s3", ...}` is attached to every logger via `iolabs.logstash.get_props_logger`. Per-LAS work is wrapped in `las_file_scope(las_file)` so log records carry LAS filename context. |
| 53 | device: str = "CPU:0" | 53 | device: str = "CPU:0" |
| 54 | visualize: bool = False | 54 | visualize: bool = False |
| 55 | visualize_las_segment_coloring: bool = False | 55 | visualize_las_segment_coloring: bool = False |
| 56 | save_points_between_planes: bool = True | 56 | save_points_between_planes: bool = True |
| 57 | las_points_per_chunk: int = 500000 | 57 | las_points_per_chunk: int = pydantic.Field(default=500000, gt=0) |
| 58 | max_parallel_las_files: int = 1 | 58 | max_parallel_las_files: int = 1 |
| 59 | angle_limit: int | None = 80 | 59 | angle_limit: int | None = 80 |
| 60 | n_extra_planes: int = 4 | 60 | n_extra_planes: int = 4 |
| 61 | segments_base_dir_name: str = "lane_points" | 61 | segments_base_dir_name: str = "lane_points" |
| 78 | if value is None: | 78 | if value is None: |
| 79 | return {} | 79 | return {} |
| 80 | return value | 80 | return value |
| 81 | 81 | ||
| 82 | @pydantic.field_validator("write_only_segments", mode="before") | ||
| 83 | @classmethod | ||
| 84 | def _none_write_only_segments_is_empty(cls, value: Any) -> Any: | ||
| 85 | """Treat a JSON ``null`` as 'no restriction', as the pre-pydantic code did.""" | ||
| 86 | if value is None: | ||
| 87 | return [] | ||
| 88 | return value | ||
| 89 | |||
| 82 | 90 | ||
| 83 | def _load_segment_mapper_model( | 91 | def _load_segment_mapper_model( |
| 84 | *, | 92 | *, |
| 85 | overrides: dict[str, Any] | None = None, | 93 | overrides: dict[str, Any] | None = None, |
| 164 | config = normalize_segment_mapper_config({"file_naming": None}) | 164 | config = normalize_segment_mapper_config({"file_naming": None}) |
| 165 | assert config["file_naming"] == EXPECTED_FILE_NAMING_DEFAULTS | 165 | assert config["file_naming"] == EXPECTED_FILE_NAMING_DEFAULTS |
| 166 | 166 | ||
| 167 | 167 | ||
| 168 | def test_visualization_colors_none_yields_defaults() -> None: | ||
| 169 | config = normalize_segment_mapper_config({"visualization_colors": None}) | ||
| 170 | assert config["visualization_colors"] == ( | ||
| 171 | EXPECTED_TOP_LEVEL_DEFAULTS["visualization_colors"] | ||
| 172 | ) | ||
| 173 | |||
| 174 | |||
| 175 | def test_write_only_segments_none_means_no_restriction() -> None: | ||
| 176 | """A JSON ``null`` keeps the pre-pydantic 'write every segment' behaviour.""" | ||
| 177 | config = normalize_segment_mapper_config({"write_only_segments": None}) | ||
| 178 | assert config["write_only_segments"] == [] | ||
| 179 | |||
| 180 | |||
| 181 | def test_write_only_segments_accepts_int_list() -> None: | ||
| 182 | config = normalize_segment_mapper_config({"write_only_segments": [3, 7]}) | ||
| 183 | assert config["write_only_segments"] == [3, 7] | ||
| 184 | |||
| 185 | |||
| 186 | def test_las_points_per_chunk_must_be_positive() -> None: | ||
| 187 | for bad in (0, -1): | ||
| 188 | with pytest.raises(SegmentMapperConfigError): | ||
| 189 | normalize_segment_mapper_config({"las_points_per_chunk": bad}) | ||
| 190 | |||
| 191 | |||
| 168 | def test_longitudinal_limit_distance_must_be_positive() -> None: | 192 | def test_longitudinal_limit_distance_must_be_positive() -> None: |
| 169 | with pytest.raises(SegmentMapperConfigError): | 193 | with pytest.raises(SegmentMapperConfigError): |
| 170 | normalize_segment_mapper_config({"longitudinal_limit_distance_m": 0}) | 194 | normalize_segment_mapper_config({"longitudinal_limit_distance_m": 0}) |
| 171 | 195 |
write_only_segments: nullaccepted,las_points_per_chunk > 0enforced.