| 34 | 6. If `save_points_between_planes`, call `divide_las_file_by_planes` for each LAS via a `ThreadPoolExecutor(max_workers=max_parallel_las_files)` (capped at `os.cpu_count()`). 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. | 34 | 6. If `save_points_between_planes`, call `divide_las_file_by_planes` for each LAS via a `ThreadPoolExecutor(max_workers=max_parallel_las_files)` (capped at `os.cpu_count()`). 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 | | 35 | |
| 36 | - **`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**. | 36 | - **`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 | | 37 | |
| 38 | The per-point ancillary arrays travel as a single `dict[str, np.ndarray]` keyed by the module-level `ANCILLARY_FIELD_NAMES` tuple (`points` stays separate as the `(N, 3)` geometry array; `SEGMENT_NPZ_FIELD_NAMES = ("points", *ANCILLARY_FIELD_NAMES)` fixes the NPZ member order). Extraction, the angle-limit mask, dtype registration, `_SegmentSplitWriter.write`, the memmap allocation and the archive member list all iterate that tuple, so **adding an NPZ field = one entry in `ANCILLARY_FIELD_NAMES` + one extraction line in `SegmentMapper._chunk_field_arrays`** (plus a default in `DEFAULT_FIELD_DTYPES` if the field can be absent from the source LAS). | 38 | The per-point ancillary arrays travel as a single `dict[str, np.ndarray]` keyed by the module-level `ANCILLARY_FIELD_NAMES` tuple (`points` stays separate as the `(N, 3)` geometry array; `SEGMENT_NPZ_FIELD_NAMES = ("points", *ANCILLARY_FIELD_NAMES)` fixes the NPZ member order). Extraction, the angle-limit mask, dtype registration, `_SegmentSplitWriter.write`, the memmap allocation and the archive member list all iterate that tuple. Adding an NPZ field therefore costs, in full: |
| | 39 | |
| | 40 | 1. one entry in `ANCILLARY_FIELD_NAMES`; |
| | 41 | 2. one extraction line in `SegmentMapper._chunk_field_arrays`; |
| | 42 | 3. an entry in `DEFAULT_FIELD_DTYPES` **if the LAS may lack the field** — the mapping is a `types.MappingProxyType`, so extend the literal rather than mutating it at runtime. Optional fields are read via `_record_field_or_zeros(chunk, key=..., dtype=...)`, which is generic: a new fallback field needs no new helper; |
| | 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 | |
| | 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. |
| 39 | | 46 | |
| 40 | - **`_config.py`** — strict whitelist validation. `ALLOWED_SEGMENT_MAPPER_CONFIG_KEYS` and `ALLOWED_SEGMENT_MAPPER_FILE_NAMING_KEYS` are enforced; unknown keys raise `SegmentMapperConfigError`. `normalize_segment_mapper_config` fills defaults; `build_segment_mapper_config(overrides=..., config_path=...)` does deep-merge over `segment_mapper.default.json`. **When adding a new config key you must update both the whitelist set and `normalize_segment_mapper_config`'s `setdefault` block, and add a default in `segment_mapper.default.json`.** | 47 | - **`_config.py`** — strict whitelist validation. `ALLOWED_SEGMENT_MAPPER_CONFIG_KEYS` and `ALLOWED_SEGMENT_MAPPER_FILE_NAMING_KEYS` are enforced; unknown keys raise `SegmentMapperConfigError`. `normalize_segment_mapper_config` fills defaults; `build_segment_mapper_config(overrides=..., config_path=...)` does deep-merge over `segment_mapper.default.json`. **When adding a new config key you must update both the whitelist set and `normalize_segment_mapper_config`'s `setdefault` block, and add a default in `segment_mapper.default.json`.** |
| 41 | | 48 | |
| 42 | - **`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. |