Miroslav Simko <ms@iolabs.ch> 2026-09-01T14:53:04+02:00
Commit #5 ยท 6 snippets
src/iolabs_point_cloud_mask_clustering/input_io.py | 196 ++++++++++++--------- 1 file changed, 117 insertions(+), 79 deletions(-)
Consumer switches from hand-parsing NPZ members to segment_points_io.load_points_npz.
| 1 | """Read Step 3 point NPZ chunks and stream them into separation buffers.""" | 1 | """Read Step 3 point NPZ chunks and stream them into separation buffers. |
| 2 | 2 | ||
| 3 | Record loading goes through :mod:`iolabs.common.segment_points_io`, the SSOT for | ||
| 4 | the ``*_run3_points.npz`` contract, and channels are carried by record key rather | ||
| 5 | than field by field, so a key added to both that contract and | ||
| 6 | ``ColorIntensityData`` flows through this module with no code change here. | ||
| 7 | """ | ||
| 8 | |||
| 9 | import dataclasses | ||
| 3 | from collections.abc import Iterable, Mapping | 10 | from collections.abc import Iterable, Mapping |
| 4 | from dataclasses import dataclass | 11 | from dataclasses import dataclass |
| 5 | from pathlib import Path | 12 | from pathlib import Path |
| 6 | 13 | ||
| 7 | import numpy as np | 14 | import numpy as np |
| 15 | from iolabs.common import segment_points_io | ||
| 8 | from iolabs.common.color_intensity_data import ColorIntensityData | 16 | from iolabs.common.color_intensity_data import ColorIntensityData |
| 9 | from iolabs.logstash import get_props_logger | 17 | from iolabs.logstash import get_props_logger |
| 10 | 18 | ||
| 11 | from ._log_props import LOG_PROPS | 19 | from ._log_props import LOG_PROPS |
| 12 | from .types import PointChannels, RasterFrame | 20 | from .types import PointChannels, RasterFrame |
| 13 | 21 | ||
| 14 | logger = get_props_logger(__name__, LOG_PROPS) | 22 | logger = get_props_logger(__name__, LOG_PROPS) |
| 15 | 23 | ||
| 16 | REQUIRED_ARRAYS = ("points", "scan_angle", "intensity", "red", "green", "blue") | 24 | #: Deprecated alias for the shared run3 point-record schema; use |
| 25 | #: :data:`iolabs.common.segment_points_io.POINT_RECORD_KEYS` instead. | ||
| 26 | REQUIRED_ARRAYS: tuple[str, ...] = segment_points_io.POINT_RECORD_KEYS | ||
| 17 | 27 | ||
| 28 | #: Record keys whose name differs from the matching ``ColorIntensityData`` field. | ||
| 29 | _RECORD_KEY_TO_CHANNEL_FIELD: Mapping[str, str] = {"scan_angle": "scan_angle_rank"} | ||
| 18 | 30 | ||
| 19 | @dataclass(frozen=True) | 31 | #: Keys the lean separation path reads directly from the npz. |
| 20 | class SeparationArrays: | 32 | _LEAN_KEYS: tuple[str, ...] = ("points", "intensity") |
| 21 | """Preallocated full-segment arrays for intensity separation.""" | ||
| 22 | 33 | ||
| 23 | xyz: np.ndarray | 34 | |
| 24 | rows: np.ndarray | 35 | def _channel_field(record_key: str) -> str: |
| 25 | cols: np.ndarray | 36 | """Map a run3 record key to its ``ColorIntensityData`` field name.""" |
| 26 | intensity: np.ndarray | 37 | return _RECORD_KEY_TO_CHANNEL_FIELD.get(record_key, record_key) |
| 27 | channels: ColorIntensityData | None | 38 | |
| 39 | |||
| 40 | def channel_record_keys() -> tuple[str, ...]: | ||
| 41 | """Run3 record keys that ``ColorIntensityData`` can carry, in record order. | ||
| 42 | |||
| 43 | Derived from the installed ``iolabs.common`` contract: a key added to both | ||
| 44 | the point-record schema and ``ColorIntensityData`` appears here | ||
| 45 | automatically, and keys the installed dataclass cannot hold are skipped. | ||
| 46 | """ | ||
| 47 | fields = {field.name for field in dataclasses.fields(ColorIntensityData)} | ||
| 48 | return tuple( | ||
| 49 | key | ||
| 50 | for key in segment_points_io.POINT_RECORD_KEYS | ||
| 51 | if key != "points" and _channel_field(key) in fields | ||
| 52 | ) | ||
| 53 | |||
| 54 | |||
| 55 | def channel_dtypes_of(data: ColorIntensityData) -> dict[str, np.dtype]: | ||
| 56 | """Return *data*'s per-channel storage dtypes, keyed by run3 record key.""" | ||
| 57 | return { | ||
| 58 | key: getattr(data, _channel_field(key)).dtype for key in channel_record_keys() | ||
| 59 | } | ||
| 28 | 60 | ||
| 29 | 61 | ||
| 30 | def _validate_step3_schema(path: Path, payload: object) -> None: | 62 | def _channels_from_record(record: Mapping[str, np.ndarray]) -> ColorIntensityData: |
| 31 | files = payload.files | 63 | """Build channels from a run3 record without naming each field.""" |
| 32 | missing = [name for name in REQUIRED_ARRAYS if name not in files] | 64 | return ColorIntensityData( |
| 33 | if missing: | 65 | **{ |
| 34 | raise ValueError(f"{path} is missing required arrays: {missing}") | 66 | _channel_field(key): record[key] |
| 67 | for key in channel_record_keys() | ||
| 68 | if key in record | ||
| 69 | } | ||
| 70 | ) | ||
| 35 | 71 | ||
| 36 | 72 | ||
| 37 | def _as_points(path: Path, raw: np.ndarray) -> np.ndarray: | 73 | def _as_points(path: Path, raw: np.ndarray) -> np.ndarray: |
| 38 | points = np.asarray(raw, dtype=np.float64) | 74 | points = np.asarray(raw, dtype=np.float64) |
| 61 | 112 | ||
| 62 | Raises: | 113 | Raises: |
| 63 | ValueError: A required array is missing or has the wrong shape. | 114 | ValueError: A required array is missing or has the wrong shape. |
| 64 | """ | 115 | """ |
| 65 | with np.load(path) as payload: | 116 | record = segment_points_io.load_points_npz(path) |
| 66 | _validate_step3_schema(path, payload) | ||
| 67 | arrays = {name: np.asarray(payload[name]) for name in REQUIRED_ARRAYS} | ||
| 68 | |||
| 69 | points = _as_points(path, arrays["points"]) | ||
| 70 | count = len(points) | ||
| 71 | channels = { | ||
| 72 | name: _as_channel(path, name, arrays[name], count) | ||
| 73 | for name in REQUIRED_ARRAYS[1:] | ||
| 74 | } | ||
| 75 | return PointChannels( | 117 | return PointChannels( |
| 76 | points=points, | 118 | points=_as_points(path, record["points"]), |
| 77 | data=ColorIntensityData( | 119 | data=_channels_from_record(record), |
| 78 | red=channels["red"], | ||
| 79 | green=channels["green"], | ||
| 80 | blue=channels["blue"], | ||
| 81 | intensity=channels["intensity"], | ||
| 82 | scan_angle_rank=channels["scan_angle"], | ||
| 83 | ), | ||
| 84 | ) | 120 | ) |
| 85 | 121 | ||
| 86 | 122 | ||
| 87 | def _load_separation_chunk( | 123 | def _load_separation_chunk( |
| 88 | path: Path, *, with_channels: bool | 124 | path: Path, *, with_channels: bool |
| 89 | ) -> tuple[np.ndarray, np.ndarray, ColorIntensityData | None]: | 125 | ) -> tuple[np.ndarray, np.ndarray, ColorIntensityData | None]: |
| 90 | """Load one Step 3 chunk for separation. | 126 | """Load one Step 3 chunk for separation. |
| 91 | 127 | ||
| 92 | When ``with_channels`` is false, only ``points`` and ``intensity`` are read from | 128 | When ``with_channels`` is false, only ``points`` and ``intensity`` are read |
| 93 | the npz (RGB/scan_angle keys are schema-checked but never materialized). | 129 | from the npz; the other record keys are neither materialized nor validated |
| 130 | here, because every chunk is already validated up front through | ||
| 131 | :func:`load_step3_file`. | ||
| 94 | """ | 132 | """ |
| 133 | if with_channels: | ||
| 134 | record = segment_points_io.load_points_npz(path) | ||
| 135 | return ( | ||
| 136 | _as_points(path, record["points"]), | ||
| 137 | record["intensity"], | ||
| 138 | _channels_from_record(record), | ||
| 139 | ) | ||
| 140 | |||
| 95 | with np.load(path) as payload: | 141 | with np.load(path) as payload: |
| 96 | _validate_step3_schema(path, payload) | 142 | missing = [name for name in _LEAN_KEYS if name not in payload.files] |
| 143 | if missing: | ||
| 144 | raise ValueError(f"{path} is missing required arrays: {missing}") | ||
| 97 | points = _as_points(path, payload["points"]) | 145 | points = _as_points(path, payload["points"]) |
| 98 | count = len(points) | 146 | intensity = _as_channel(path, "intensity", payload["intensity"], len(points)) |
| 99 | intensity = _as_channel(path, "intensity", payload["intensity"], count) | 147 | return points, intensity, None |
| 100 | if not with_channels: | ||
| 101 | return points, intensity, None | ||
| 102 | red = _as_channel(path, "red", payload["red"], count) | ||
| 103 | green = _as_channel(path, "green", payload["green"], count) | ||
| 104 | blue = _as_channel(path, "blue", payload["blue"], count) | ||
| 105 | scan_angle = _as_channel(path, "scan_angle", payload["scan_angle"], count) | ||
| 106 | return ( | ||
| 107 | points, | ||
| 108 | intensity, | ||
| 109 | ColorIntensityData( | ||
| 110 | red=red, | ||
| 111 | green=green, | ||
| 112 | blue=blue, | ||
| 113 | intensity=intensity, | ||
| 114 | scan_angle_rank=scan_angle, | ||
| 115 | ), | ||
| 116 | ) | ||
| 117 | 148 | ||
| 118 | 149 | ||
| 119 | def load_step3_points(paths: Iterable[Path]) -> PointChannels: | 150 | def load_step3_points(paths: Iterable[Path]) -> PointChannels: |
| 120 | """Load and concatenate several Step 3 chunks. | 151 | """Load and concatenate several Step 3 chunks. |
| 148 | ) -> SeparationArrays: | 179 | ) -> SeparationArrays: |
| 149 | """Stream Step 3 chunks into preallocated separation arrays. | 180 | """Stream Step 3 chunks into preallocated separation arrays. |
| 150 | 181 | ||
| 151 | Loads one chunk at a time so per-chunk arrays are never held alongside the | 182 | Loads one chunk at a time so per-chunk arrays are never held alongside the |
| 152 | full-segment buffers. When ``with_channels`` is false (normal separation path), | 183 | full-segment buffers. When ``with_channels`` is false (normal separation |
| 153 | RGB and scan_angle are neither read from disk nor allocated. They are loaded | 184 | path), the ancillary channels are neither read from disk nor allocated. They |
| 154 | only when ``with_channels`` is true (save_padded_clusters debug path). | 185 | are loaded only when ``with_channels`` is true (save_padded_clusters debug |
| 186 | path). | ||
| 187 | |||
| 188 | Args: | ||
| 189 | paths: Step 3 ``.npz`` chunks; streamed in sorted order. | ||
| 190 | frame: Raster frame used to project each chunk's XY. | ||
| 191 | total_count: Exact number of points held by the chunks together. | ||
| 192 | channel_dtypes: Buffer dtype per run3 record key, as produced by | ||
| 193 | :func:`channel_dtypes_of`. Keys other than ``intensity`` are | ||
| 194 | allocated only when *with_channels* is true. | ||
| 195 | with_channels: Whether to materialize the ancillary channels. | ||
| 196 | |||
| 197 | Returns: | ||
| 198 | The filled buffers; ``channels`` is set only when *with_channels*. | ||
| 199 | |||
| 200 | Raises: | ||
| 201 | ValueError: The chunks hold more or fewer points than *total_count*, or | ||
| 202 | a chunk is malformed. | ||
| 155 | """ | 203 | """ |
| 156 | xyz = np.empty((total_count, 3), dtype=np.float64) | 204 | xyz = np.empty((total_count, 3), dtype=np.float64) |
| 157 | rows = np.empty(total_count, dtype=np.int32) | 205 | rows = np.empty(total_count, dtype=np.int32) |
| 158 | cols = np.empty(total_count, dtype=np.int32) | 206 | cols = np.empty(total_count, dtype=np.int32) |
| 159 | intensity = np.empty(total_count, dtype=channel_dtypes["intensity"]) | 207 | intensity = np.empty(total_count, dtype=channel_dtypes["intensity"]) |
| 160 | red: np.ndarray | None = None | 208 | extra_keys = ( |
| 161 | green: np.ndarray | None = None | 209 | tuple(key for key in channel_dtypes if key != "intensity") |
| 162 | blue: np.ndarray | None = None | 210 | if with_channels |
| 163 | scan_angle: np.ndarray | None = None | 211 | else () |
| 164 | if with_channels: | 212 | ) |
| 165 | red = np.empty(total_count, dtype=channel_dtypes["red"]) | 213 | buffers = { |
| 166 | green = np.empty(total_count, dtype=channel_dtypes["green"]) | 214 | key: np.empty(total_count, dtype=channel_dtypes[key]) for key in extra_keys |
| 167 | blue = np.empty(total_count, dtype=channel_dtypes["blue"]) | 215 | } |
| 168 | scan_angle = np.empty(total_count, dtype=channel_dtypes["scan_angle"]) | ||
| 169 | 216 | ||
| 170 | offset = 0 | 217 | offset = 0 |
| 171 | for path in sorted(paths): | 218 | for path in sorted(paths): |
| 172 | chunk_points, chunk_intensity, chunk_channels = _load_separation_chunk( | 219 | chunk_points, chunk_intensity, chunk_channels = _load_separation_chunk( |
| 49 | ) | 85 | ) |
| 50 | return array | 86 | return array |
| 51 | 87 | ||
| 52 | 88 | ||
| 89 | @dataclass(frozen=True) | ||
| 90 | class SeparationArrays: | ||
| 91 | """Preallocated full-segment arrays for intensity separation.""" | ||
| 92 | |||
| 93 | xyz: np.ndarray | ||
| 94 | rows: np.ndarray | ||
| 95 | cols: np.ndarray | ||
| 96 | intensity: np.ndarray | ||
| 97 | channels: ColorIntensityData | None | ||
| 98 | |||
| 99 | |||
| 53 | def load_step3_file(path: Path) -> PointChannels: | 100 | def load_step3_file(path: Path) -> PointChannels: |
| 54 | """Load one Step 3 chunk with all its channels. | 101 | """Load one Step 3 chunk with all its channels. |
| 55 | 102 | ||
| 103 | The record is read and schema-validated by | ||
| 104 | :func:`iolabs.common.segment_points_io.load_points_npz`; XYZ is then cast to | ||
| 105 | float64, as the rest of the pipeline expects. | ||
| 106 | |||
| 56 | Args: | 107 | Args: |
| 57 | path: Step 3 ``.npz`` chunk. | 108 | path: Step 3 ``.npz`` chunk. |
| 58 | 109 | ||
| 59 | Returns: | 110 | Returns: |
| 190 | cols[offset:end] = chunk_cols | 237 | cols[offset:end] = chunk_cols |
| 191 | del chunk_rows, chunk_cols | 238 | del chunk_rows, chunk_cols |
| 192 | intensity[offset:end] = chunk_intensity | 239 | intensity[offset:end] = chunk_intensity |
| 193 | if with_channels: | 240 | if with_channels: |
| 194 | assert red is not None and green is not None | ||
| 195 | assert blue is not None and scan_angle is not None | ||
| 196 | assert chunk_channels is not None | 241 | assert chunk_channels is not None |
| 197 | red[offset:end] = chunk_channels.red | 242 | for key in extra_keys: |
| 198 | green[offset:end] = chunk_channels.green | 243 | buffers[key][offset:end] = getattr(chunk_channels, _channel_field(key)) |
| 199 | blue[offset:end] = chunk_channels.blue | ||
| 200 | scan_angle[offset:end] = chunk_channels.scan_angle_rank | ||
| 201 | del chunk_points, chunk_intensity, chunk_channels | 244 | del chunk_points, chunk_intensity, chunk_channels |
| 202 | offset = end | 245 | offset = end |
| 203 | 246 | ||
| 204 | if offset != total_count: | 247 | if offset != total_count: |
| 207 | ) | 250 | ) |
| 208 | 251 | ||
| 209 | channels = None | 252 | channels = None |
| 210 | if with_channels: | 253 | if with_channels: |
| 211 | assert red is not None and green is not None | ||
| 212 | assert blue is not None and scan_angle is not None | ||
| 213 | channels = ColorIntensityData( | 254 | channels = ColorIntensityData( |
| 214 | red=red, | ||
| 215 | green=green, | ||
| 216 | blue=blue, | ||
| 217 | intensity=intensity, | 255 | intensity=intensity, |
| 218 | scan_angle_rank=scan_angle, | 256 | **{_channel_field(key): buffers[key] for key in extra_keys}, |
| 219 | ) | 257 | ) |
| 220 | return SeparationArrays( | 258 | return SeparationArrays( |
| 221 | xyz=xyz, | 259 | xyz=xyz, |
| 222 | rows=rows, | 260 | rows=rows, |
Step 5 stops parsing run3 NPZs by hand and loads them through common's
segment_points_io, carrying ancillary channels by key. This is what makes the consumer schema-agnostic;REQUIRED_ARRAYSbecomes a deprecated alias ofPOINT_RECORD_KEYS(member order changed, gains optional keys under new common).