Back to report index

Step 6 maskclustering cffd511: AI3D-382 Use module imports (Google style) in touched files

Miroslav Simko <ms@iolabs.ch> 2026-09-02T07:50:17+02:00

Commit #115 ยท 66 snippets

 .../cluster_io.py                                  |  22 ++--
 src/iolabs_point_cloud_mask_clustering/input_io.py |  55 ++++----
 src/iolabs_point_cloud_mask_clustering/pipeline.py | 140 ++++++++++-----------
 tests/test_cluster_io.py                           |  59 +++++----
 4 files changed, 133 insertions(+), 143 deletions(-)
Importance #1: src/iolabs_point_cloud_mask_clustering/cluster_io.py @@ -43,9 +41,9 @@
43 np.rint(np.asarray(values, dtype=np.float64)), info.min, info.max41 np.rint(np.asarray(values, dtype=np.float64)), info.min, info.max
44 ).astype(dtype)42 ).astype(dtype)
4543
4644
47def _extra_channel_arrays(data: ColorIntensityData) -> dict[str, np.ndarray]:45def _extra_channel_arrays(data: color_intensity_data.ColorIntensityData) -> dict[str, np.ndarray]:
48 """Return the channels to forward verbatim, keyed by NPZ member name.46 """Return the channels to forward verbatim, keyed by NPZ member name.
4947
50 The installed dataclass is inspected with :func:`dataclasses.fields` rather48 The installed dataclass is inspected with :func:`dataclasses.fields` rather
51 than matched against a version, so this works on any ``iolabs-common``:49 than matched against a version, so this works on any ``iolabs-common``:
Importance #2: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -1,44 +1,38 @@
1"""Run the mask-clustering step end to end for one segment."""1"""Run the mask-clustering step end to end for one segment."""
22
3import datetime
3import os4import os
5import pathlib
4import tempfile6import tempfile
5from datetime import UTC, datetime7from importlib import metadata
6from importlib.metadata import PackageNotFoundError, version
7from pathlib import Path
8from typing import Any8from typing import Any
99
10import numpy as np10import numpy as np
11from iolabs.common.color_intensity_data import ColorIntensityData11from iolabs import logstash
12from iolabs.common.run_stats import read_stats, write_stats12from iolabs.common import color_intensity_data, run_stats, segments, version_info
13from iolabs.common.segments import segment_record_files
14from iolabs.common.version_info import save_version_json
15from iolabs.logstash import get_props_logger
1613
17from ._config import MaskClusteringConfig14from . import (
18from ._log_props import LOG_PROPS15 _config,
19from .cluster_io import write_cluster_npz16 _log_props,
20from .geometry import component_geojson_geometry, mask_polygons_xy, write_prism_ply17 cluster_io,
21from .input_io import channel_dtypes_of, load_separation_arrays, load_step3_file18 geometry,
22from .intensity_separation import (19 input_io,
23 ClusterSeparation,20 intensity_separation,
24 dilate_mask,21 mask_components,
25 separate_cluster,22 point_projection,
26 write_separation_pdf,23 raster_frame,
24 types,
27)25)
28from .mask_components import label_components, load_classified_mask
29from .point_projection import assign_point_chunks
30from .raster_frame import reconstruct_raster_frame
31from .types import ComponentResult, PointChannels, SegmentResult, SegmentType
3226
33logger = get_props_logger(__name__, LOG_PROPS)27logger = logstash.get_props_logger(__name__, _log_props.LOG_PROPS)
3428
3529
36def apply_sparse_policy(30def apply_sparse_policy(
37 *,31 *,
38 segment_name: str,32 segment_name: str,
39 component_id: int,33 component_id: int,
40 segment_type: SegmentType,34 segment_type: types.SegmentType,
41 point_count: int,35 point_count: int,
42 warn_below_points: int,36 warn_below_points: int,
43 min_points_per_cluster: int,37 min_points_per_cluster: int,
44 logger: Any,38 logger: Any,
Importance #3: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -387,16 +381,16 @@
387381
388 separation_pdf_relative: str | None = None382 separation_pdf_relative: str | None = None
389 if separation_enabled:383 if separation_enabled:
390 separation_pdf_path = output_dir / str(separation_cfg["pdf_filename"])384 separation_pdf_path = output_dir / str(separation_cfg["pdf_filename"])
391 write_separation_pdf(separations, separation_pdf_path, separation_cfg)385 intensity_separation.write_separation_pdf(separations, separation_pdf_path, separation_cfg)
392 separation_pdf_relative = separation_pdf_path.relative_to(output_dir).as_posix()386 separation_pdf_relative = separation_pdf_path.relative_to(output_dir).as_posix()
393387
394 versions_path = output_dir / "run6c_versions.json"388 versions_path = output_dir / "run6c_versions.json"
395 save_version_json(versions_path, "step6c_mask_clustering")389 version_info.save_version_json(versions_path, "step6c_mask_clustering")
396 dropped_count = len(components) - retained_index390 dropped_count = len(components) - retained_index
397 package_version = _package_version()391 package_version = _package_version()
398 completed_at = datetime.now(UTC)392 completed_at = datetime.datetime.now(datetime.UTC)
399 manifest = {393 manifest = {
400 "schema": "iolabs.mask_clustering/1",394 "schema": "iolabs.mask_clustering/1",
401 "package_version": package_version,395 "package_version": package_version,
402 "timestamps": {396 "timestamps": {
Importance #4: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -497,7 +491,7 @@
497491
498492
499def _package_version() -> str:493def _package_version() -> str:
500 try:494 try:
501 return version("iolabs-point-cloud-mask-clustering")495 return metadata.version("iolabs-point-cloud-mask-clustering")
502 except PackageNotFoundError:496 except metadata.PackageNotFoundError:
503 return "0.1.0"497 return "0.1.0"
Importance #5: src/iolabs_point_cloud_mask_clustering/cluster_io.py @@ -10,19 +10,17 @@
10archive is byte-identical to the pre-AI3D-382 output.10archive is byte-identical to the pre-AI3D-382 output.
11"""11"""
1212
13import dataclasses13import dataclasses
14from pathlib import Path14import pathlib
1515
16import numpy as np16import numpy as np
17from iolabs.common.atomic_io import atomic_savez17from iolabs import logstash
18from iolabs.common.color_intensity_data import ColorIntensityData18from iolabs.common import atomic_io, color_intensity_data
19from iolabs.logstash import get_props_logger
2019
21from ._log_props import LOG_PROPS20from . import _log_props, types
22from .types import PointChannels, SegmentType
2321
24logger = get_props_logger(__name__, LOG_PROPS)22logger = logstash.get_props_logger(__name__, _log_props.LOG_PROPS)
2523
26#: ``ColorIntensityData`` fields :func:`write_cluster_npz` writes itself, with24#: ``ColorIntensityData`` fields :func:`write_cluster_npz` writes itself, with
27#: their own dtype handling; they must not be forwarded a second time.25#: their own dtype handling; they must not be forwarded a second time.
28EXPLICIT_CHANNEL_FIELDS: frozenset[str] = frozenset(26EXPLICIT_CHANNEL_FIELDS: frozenset[str] = frozenset(
Importance #6: src/iolabs_point_cloud_mask_clustering/cluster_io.py @@ -80,11 +78,11 @@
80 return extras78 return extras
8179
8280
83def write_cluster_npz(81def write_cluster_npz(
84 output_path: Path,82 output_path: pathlib.Path,
85 channels: PointChannels,83 channels: types.PointChannels,
86 segment_type: SegmentType,84 segment_type: types.SegmentType,
87) -> None:85) -> None:
88 """Write one cluster to *output_path* as an uncompressed, atomically-replaced NPZ.86 """Write one cluster to *output_path* as an uncompressed, atomically-replaced NPZ.
8987
90 Members are STORED, not deflated: tablecloth's reader chunk-streams cluster88 Members are STORED, not deflated: tablecloth's reader chunk-streams cluster
Importance #7: src/iolabs_point_cloud_mask_clustering/cluster_io.py @@ -97,9 +95,9 @@
97 channel beyond the five explicit ones is forwarded under its field95 channel beyond the five explicit ones is forwarded under its field
98 name, with its own dtype.96 name, with its own dtype.
99 segment_type: Road-marking class recorded alongside the points.97 segment_type: Road-marking class recorded alongside the points.
100 """98 """
101 atomic_savez(99 atomic_io.atomic_savez(
102 output_path,100 output_path,
103 compress=False,101 compress=False,
104 points=np.asarray(channels.points, dtype=np.float64),102 points=np.asarray(channels.points, dtype=np.float64),
105 scan_angle=_round_clip(channels.data.scan_angle_rank, np.dtype(np.int8)),103 scan_angle=_round_clip(channels.data.scan_angle_rank, np.dtype(np.int8)),
Importance #8: src/iolabs_point_cloud_mask_clustering/input_io.py @@ -6,28 +6,25 @@
6``ColorIntensityData`` flows through this module with no code change here.6``ColorIntensityData`` flows through this module with no code change here.
7"""7"""
88
9import dataclasses9import dataclasses
10from collections.abc import Iterable, Mapping10import pathlib
11from dataclasses import dataclass11from collections import abc
12from pathlib import Path
1312
14import numpy as np13import numpy as np
15from iolabs.common import segment_points_io14from iolabs import logstash
16from iolabs.common.color_intensity_data import ColorIntensityData15from iolabs.common import color_intensity_data, segment_points_io
17from iolabs.logstash import get_props_logger
1816
19from ._log_props import LOG_PROPS17from . import _log_props, types
20from .types import PointChannels, RasterFrame
2118
22logger = get_props_logger(__name__, LOG_PROPS)19logger = logstash.get_props_logger(__name__, _log_props.LOG_PROPS)
2320
24#: Deprecated alias for the shared run3 point-record schema; use21#: Deprecated alias for the shared run3 point-record schema; use
25#: :data:`iolabs.common.segment_points_io.POINT_RECORD_KEYS` instead.22#: :data:`iolabs.common.segment_points_io.POINT_RECORD_KEYS` instead.
26REQUIRED_ARRAYS: tuple[str, ...] = segment_points_io.POINT_RECORD_KEYS23REQUIRED_ARRAYS: tuple[str, ...] = segment_points_io.POINT_RECORD_KEYS
2724
28#: Record keys whose name differs from the matching ``ColorIntensityData`` field.25#: Record keys whose name differs from the matching ``ColorIntensityData`` field.
29_RECORD_KEY_TO_CHANNEL_FIELD: Mapping[str, str] = {"scan_angle": "scan_angle_rank"}26_RECORD_KEY_TO_CHANNEL_FIELD: abc.Mapping[str, str] = {"scan_angle": "scan_angle_rank"}
3027
31#: Keys the lean separation path reads directly from the npz.28#: Keys the lean separation path reads directly from the npz.
32_LEAN_KEYS: tuple[str, ...] = ("points", "intensity")29_LEAN_KEYS: tuple[str, ...] = ("points", "intensity")
3330
Importance #9: src/iolabs_point_cloud_mask_clustering/input_io.py @@ -43,62 +40,64 @@
43 Derived from the installed ``iolabs.common`` contract: a key added to both40 Derived from the installed ``iolabs.common`` contract: a key added to both
44 the point-record schema and ``ColorIntensityData`` appears here41 the point-record schema and ``ColorIntensityData`` appears here
45 automatically, and keys the installed dataclass cannot hold are skipped.42 automatically, and keys the installed dataclass cannot hold are skipped.
46 """43 """
47 fields = {field.name for field in dataclasses.fields(ColorIntensityData)}44 fields = {field.name for field in dataclasses.fields(color_intensity_data.ColorIntensityData)}
48 return tuple(45 return tuple(
49 key46 key
50 for key in segment_points_io.POINT_RECORD_KEYS47 for key in segment_points_io.POINT_RECORD_KEYS
51 if key != "points" and _channel_field(key) in fields48 if key != "points" and _channel_field(key) in fields
52 )49 )
5350
5451
55def channel_dtypes_of(data: ColorIntensityData) -> dict[str, np.dtype]:52def channel_dtypes_of(data: color_intensity_data.ColorIntensityData) -> dict[str, np.dtype]:
56 """Return *data*'s per-channel storage dtypes, keyed by run3 record key."""53 """Return *data*'s per-channel storage dtypes, keyed by run3 record key."""
57 return {54 return {
58 key: getattr(data, _channel_field(key)).dtype for key in channel_record_keys()55 key: getattr(data, _channel_field(key)).dtype for key in channel_record_keys()
59 }56 }
6057
6158
62def _channels_from_record(record: Mapping[str, np.ndarray]) -> ColorIntensityData:59def _channels_from_record(
60 record: abc.Mapping[str, np.ndarray],
61) -> color_intensity_data.ColorIntensityData:
63 """Build channels from a run3 record without naming each field."""62 """Build channels from a run3 record without naming each field."""
64 return ColorIntensityData(63 return color_intensity_data.ColorIntensityData(
65 **{64 **{
66 _channel_field(key): record[key]65 _channel_field(key): record[key]
67 for key in channel_record_keys()66 for key in channel_record_keys()
68 if key in record67 if key in record
69 }68 }
70 )69 )
7170
7271
73def _as_points(path: Path, raw: np.ndarray) -> np.ndarray:72def _as_points(path: pathlib.Path, raw: np.ndarray) -> np.ndarray:
74 points = np.asarray(raw, dtype=np.float64)73 points = np.asarray(raw, dtype=np.float64)
75 if points.ndim != 2 or points.shape[1] != 3:74 if points.ndim != 2 or points.shape[1] != 3:
76 raise ValueError(f"{path}: points must have shape (N, 3), got {points.shape}")75 raise ValueError(f"{path}: points must have shape (N, 3), got {points.shape}")
77 return points76 return points
7877
7978
80def _as_channel(path: Path, name: str, raw: np.ndarray, count: int) -> np.ndarray:79def _as_channel(path: pathlib.Path, name: str, raw: np.ndarray, count: int) -> np.ndarray:
81 array = np.asarray(raw)80 array = np.asarray(raw)
82 if array.ndim != 1 or len(array) != count:81 if array.ndim != 1 or len(array) != count:
83 raise ValueError(82 raise ValueError(
84 f"{path}: {name} must have shape ({count},), got {array.shape}"83 f"{path}: {name} must have shape ({count},), got {array.shape}"
85 )84 )
86 return array85 return array
8786
8887
89@dataclass(frozen=True)88@dataclasses.dataclass(frozen=True)
90class SeparationArrays:89class SeparationArrays:
91 """Preallocated full-segment arrays for intensity separation."""90 """Preallocated full-segment arrays for intensity separation."""
9291
93 xyz: np.ndarray92 xyz: np.ndarray
94 rows: np.ndarray93 rows: np.ndarray
95 cols: np.ndarray94 cols: np.ndarray
96 intensity: np.ndarray95 intensity: np.ndarray
97 channels: ColorIntensityData | None96 channels: color_intensity_data.ColorIntensityData | None
9897
9998
100def load_step3_file(path: Path) -> PointChannels:99def load_step3_file(path: pathlib.Path) -> types.PointChannels:
101 """Load one Step 3 chunk with all its channels.100 """Load one Step 3 chunk with all its channels.
102101
103 The record is read and schema-validated by102 The record is read and schema-validated by
104 :func:`iolabs.common.segment_points_io.load_points_npz`; XYZ is then cast to103 :func:`iolabs.common.segment_points_io.load_points_npz`; XYZ is then cast to
Importance #10: src/iolabs_point_cloud_mask_clustering/input_io.py @@ -113,17 +112,17 @@
113 Raises:112 Raises:
114 ValueError: A required array is missing or has the wrong shape.113 ValueError: A required array is missing or has the wrong shape.
115 """114 """
116 record = segment_points_io.load_points_npz(path)115 record = segment_points_io.load_points_npz(path)
117 return PointChannels(116 return types.PointChannels(
118 points=_as_points(path, record["points"]),117 points=_as_points(path, record["points"]),
119 data=_channels_from_record(record),118 data=_channels_from_record(record),
120 )119 )
121120
122121
123def _load_separation_chunk(122def _load_separation_chunk(
124 path: Path, *, with_channels: bool123 path: pathlib.Path, *, with_channels: bool
125) -> tuple[np.ndarray, np.ndarray, ColorIntensityData | None]:124) -> tuple[np.ndarray, np.ndarray, color_intensity_data.ColorIntensityData | None]:
126 """Load one Step 3 chunk for separation.125 """Load one Step 3 chunk for separation.
127126
128 When ``with_channels`` is false, only ``points`` and ``intensity`` are read127 When ``with_channels`` is false, only ``points`` and ``intensity`` are read
129 from the npz; the other record keys are neither materialized nor validated128 from the npz; the other record keys are neither materialized nor validated
Importance #11: src/iolabs_point_cloud_mask_clustering/input_io.py @@ -146,9 +145,9 @@
146 intensity = _as_channel(path, "intensity", payload["intensity"], len(points))145 intensity = _as_channel(path, "intensity", payload["intensity"], len(points))
147 return points, intensity, None146 return points, intensity, None
148147
149148
150def load_step3_points(paths: Iterable[Path]) -> PointChannels:149def load_step3_points(paths: abc.Iterable[pathlib.Path]) -> types.PointChannels:
151 """Load and concatenate several Step 3 chunks.150 """Load and concatenate several Step 3 chunks.
152151
153 Args:152 Args:
154 paths: Step 3 ``.npz`` chunks; loaded in sorted order.153 paths: Step 3 ``.npz`` chunks; loaded in sorted order.
Importance #12: src/iolabs_point_cloud_mask_clustering/input_io.py @@ -165,17 +164,17 @@
165 points = np.concatenate([chunk.points for chunk in chunks])164 points = np.concatenate([chunk.points for chunk in chunks])
166 data = chunks[0].data165 data = chunks[0].data
167 for chunk in chunks[1:]:166 for chunk in chunks[1:]:
168 data = data.append(chunk.data)167 data = data.append(chunk.data)
169 return PointChannels(points=points, data=data)168 return types.PointChannels(points=points, data=data)
170169
171170
172def load_separation_arrays(171def load_separation_arrays(
173 paths: Iterable[Path],172 paths: abc.Iterable[pathlib.Path],
174 frame: RasterFrame,173 frame: types.RasterFrame,
175 total_count: int,174 total_count: int,
176 *,175 *,
177 channel_dtypes: Mapping[str, np.dtype],176 channel_dtypes: abc.Mapping[str, np.dtype],
178 with_channels: bool,177 with_channels: bool,
179) -> SeparationArrays:178) -> SeparationArrays:
180 """Stream Step 3 chunks into preallocated separation arrays.179 """Stream Step 3 chunks into preallocated separation arrays.
181180
Importance #13: src/iolabs_point_cloud_mask_clustering/input_io.py @@ -250,9 +249,9 @@
250 )249 )
251250
252 channels = None251 channels = None
253 if with_channels:252 if with_channels:
254 channels = ColorIntensityData(253 channels = color_intensity_data.ColorIntensityData(
255 intensity=intensity,254 intensity=intensity,
256 **{_channel_field(key): buffers[key] for key in extra_keys},255 **{_channel_field(key): buffers[key] for key in extra_keys},
257 )256 )
258 return SeparationArrays(257 return SeparationArrays(
Importance #14: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -76,15 +70,15 @@
7670
7771
78def process_segment(72def process_segment(
79 *,73 *,
80 segment_dir: Path,74 segment_dir: pathlib.Path,
81 mask_or_vectors_path: Path,75 mask_or_vectors_path: pathlib.Path,
82 raster_metadata_path: Path,76 raster_metadata_path: pathlib.Path,
83 output_dir: Path,77 output_dir: pathlib.Path,
84 config: MaskClusteringConfig | dict[str, Any],78 config: _config.MaskClusteringConfig | dict[str, Any],
85 overwrite: bool = False,79 overwrite: bool = False,
86) -> SegmentResult:80) -> types.SegmentResult:
87 """Cluster one segment's classified mask into Step 6-compatible artifacts.81 """Cluster one segment's classified mask into Step 6-compatible artifacts.
8882
89 Args:83 Args:
90 segment_dir: Segment directory holding the Step 3 point NPZ chunks.84 segment_dir: Segment directory holding the Step 3 point NPZ chunks.
Importance #15: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -105,24 +99,24 @@
105 the configured suffix, or *raster_metadata_path* does not exist.99 the configured suffix, or *raster_metadata_path* does not exist.
106 ValueError: The segment holds no points, or the mask, sidecar and100 ValueError: The segment holds no points, or the mask, sidecar and
107 reconstructed frame disagree.101 reconstructed frame disagree.
108 """102 """
109 started_at = datetime.now(UTC)103 started_at = datetime.datetime.now(datetime.UTC)
110 cfg = MaskClusteringConfig.coerce(config)104 cfg = _config.MaskClusteringConfig.coerce(config)
111 manifest_path = output_dir / cfg.output.manifest_filename105 manifest_path = output_dir / cfg.output.manifest_filename
112 if manifest_path.exists():106 if manifest_path.exists():
113 if not overwrite:107 if not overwrite:
114 raise FileExistsError(108 raise FileExistsError(
115 f"Completed mask-clustering manifest already exists: {manifest_path}"109 f"Completed mask-clustering manifest already exists: {manifest_path}"
116 )110 )
117 _remove_prior_artifacts(output_dir, manifest_path)111 _remove_prior_artifacts(output_dir, manifest_path)
118112
119 step3_paths = segment_record_files(113 step3_paths = segments.segment_record_files(
120 segment_dir, cfg.file_naming.segment_points_suffix, required=True114 segment_dir, cfg.file_naming.segment_points_suffix, required=True
121 )115 )
122 metadata = read_stats(raster_metadata_path, require_dict=True)116 segment_metadata = run_stats.read_stats(raster_metadata_path, require_dict=True)
123 segment_name = str(117 segment_name = str(
124 metadata.get("segment_name", metadata.get("segment_id", segment_dir.name))118 segment_metadata.get("segment_name", segment_metadata.get("segment_id", segment_dir.name))
125 )119 )
126120
127 source_count = 0121 source_count = 0
128 xy_min = np.array([np.inf, np.inf])122 xy_min = np.array([np.inf, np.inf])
Importance #16: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -130,11 +124,11 @@
130 z_min = np.inf124 z_min = np.inf
131 z_max = -np.inf125 z_max = -np.inf
132 channel_dtypes: dict[str, np.dtype] | None = None126 channel_dtypes: dict[str, np.dtype] | None = None
133 for path in step3_paths:127 for path in step3_paths:
134 chunk = load_step3_file(path)128 chunk = input_io.load_step3_file(path)
135 source_count += len(chunk.points)129 source_count += len(chunk.points)
136 chunk_dtypes = channel_dtypes_of(chunk.data)130 chunk_dtypes = input_io.channel_dtypes_of(chunk.data)
137 if channel_dtypes is None:131 if channel_dtypes is None:
138 channel_dtypes = chunk_dtypes132 channel_dtypes = chunk_dtypes
139 else:133 else:
140 channel_dtypes = {134 channel_dtypes = {
Importance #17: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -153,30 +147,30 @@
153 if source_count == 0:147 if source_count == 0:
154 raise ValueError(f"Step 3 source in {segment_dir} contains no points")148 raise ValueError(f"Step 3 source in {segment_dir} contains no points")
155 assert channel_dtypes is not None149 assert channel_dtypes is not None
156150
157 mask_shape = (int(metadata["pixel_y"]), int(metadata["pixel_x"]))151 mask_shape = (int(segment_metadata["pixel_y"]), int(segment_metadata["pixel_x"]))
158 mask_cfg = cfg.mask152 mask_cfg = cfg.mask
159 extrema = np.array(153 extrema = np.array(
160 [[xy_min[0], xy_min[1], z_min], [xy_max[0], xy_max[1], z_max]],154 [[xy_min[0], xy_min[1], z_min], [xy_max[0], xy_max[1], z_max]],
161 dtype=np.float64,155 dtype=np.float64,
162 )156 )
163 frame = reconstruct_raster_frame(157 frame = raster_frame.reconstruct_raster_frame(
164 extrema,158 extrema,
165 metadata,159 segment_metadata,
166 mask_shape=mask_shape,160 mask_shape=mask_shape,
167 margin_pixels=cfg.raster_frame.margin_pixels,161 margin_pixels=cfg.raster_frame.margin_pixels,
168 origin_tolerance_pixels=cfg.raster_frame.metadata_origin_tolerance_pixels,162 origin_tolerance_pixels=cfg.raster_frame.metadata_origin_tolerance_pixels,
169 )163 )
170 classified_mask = load_classified_mask(164 classified_mask = mask_components.load_classified_mask(
171 mask_or_vectors_path,165 mask_or_vectors_path,
172 shape=mask_shape,166 shape=mask_shape,
173 background_class=mask_cfg.background_class,167 background_class=mask_cfg.background_class,
174 solid_class=mask_cfg.solid_class,168 solid_class=mask_cfg.solid_class,
175 dashed_class=mask_cfg.dashed_class,169 dashed_class=mask_cfg.dashed_class,
176 vector_stroke_px=mask_cfg.vector_stroke_px,170 vector_stroke_px=mask_cfg.vector_stroke_px,
177 )171 )
178 components, label_image = label_components(172 components, label_image = mask_components.label_components(
179 classified_mask,173 classified_mask,
180 solid_class=mask_cfg.solid_class,174 solid_class=mask_cfg.solid_class,
181 dashed_class=mask_cfg.dashed_class,175 dashed_class=mask_cfg.dashed_class,
182 connectivity=mask_cfg.connectivity,176 connectivity=mask_cfg.connectivity,
Importance #18: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -187,10 +181,10 @@
187 segment_name,181 segment_name,
188 extra={"segment_name": segment_name, "action": "empty_success"},182 extra={"segment_name": segment_name, "action": "empty_success"},
189 )183 )
190184
191 assigned, out_of_frame = assign_point_chunks(185 assigned, out_of_frame = point_projection.assign_point_chunks(
192 (load_step3_file(path) for path in step3_paths),186 (input_io.load_step3_file(path) for path in step3_paths),
193 frame,187 frame,
194 label_image,188 label_image,
195 components,189 components,
196 )190 )
Importance #19: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -209,17 +203,17 @@
209 # point-cloud viewer. Written to a separate subdir so Step 6b never rasterises them.203 # point-cloud viewer. Written to a separate subdir so Step 6b never rasterises them.
210 save_padded = separation_enabled and bool(separation_cfg.get("save_padded_clusters"))204 save_padded = separation_enabled and bool(separation_cfg.get("save_padded_clusters"))
211 padding_debug_dir = output_dir / "padding_debug"205 padding_debug_dir = output_dir / "padding_debug"
212 dilation_px = int(separation_cfg["dilation_px"])206 dilation_px = int(separation_cfg["dilation_px"])
213 separations: list[ClusterSeparation] = []207 separations: list[intensity_separation.ClusterSeparation] = []
214 # The asphalt sampling ring lies outside each component's mask, so it needs more208 # The asphalt sampling ring lies outside each component's mask, so it needs more
215 # than the per-component assigned points. Stream-load and project the full segment209 # than the per-component assigned points. Stream-load and project the full segment
216 # once, up front, when separation is enabled. (Cropping to the union of dilated210 # once, up front, when separation is enabled. (Cropping to the union of dilated
217 # component masks would also work; full-segment streaming is simpler and the lean211 # component masks would also work; full-segment streaming is simpler and the lean
218 # xyz/rows/cols/intensity buffers keep it affordable.)212 # xyz/rows/cols/intensity buffers keep it affordable.)
219 sep_channels: ColorIntensityData | None = None213 sep_channels: color_intensity_data.ColorIntensityData | None = None
220 if separation_enabled:214 if separation_enabled:
221 sep = load_separation_arrays(215 sep = input_io.load_separation_arrays(
222 step3_paths,216 step3_paths,
223 frame,217 frame,
224 source_count,218 source_count,
225 channel_dtypes=channel_dtypes,219 channel_dtypes=channel_dtypes,
Importance #20: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -246,14 +240,14 @@
246 warn_below_points=cluster_cfg.warn_below_points,240 warn_below_points=cluster_cfg.warn_below_points,
247 min_points_per_cluster=cluster_cfg.min_points_per_cluster,241 min_points_per_cluster=cluster_cfg.min_points_per_cluster,
248 logger=logger,242 logger=logger,
249 )243 )
250 separation: ClusterSeparation | None = None244 separation: intensity_separation.ClusterSeparation | None = None
251 cluster_path: Path | None = None245 cluster_path: pathlib.Path | None = None
252 padding_debug_path: Path | None = None246 padding_debug_path: pathlib.Path | None = None
253 if status == "retained":247 if status == "retained":
254 if separation_enabled:248 if separation_enabled:
255 separation = separate_cluster(249 separation = intensity_separation.separate_cluster(
256 component,250 component,
257 channels.data.intensity,251 channels.data.intensity,
258 points_xyz=sep_xyz,252 points_xyz=sep_xyz,
259 rows=sep_rows,253 rows=sep_rows,
Importance #21: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -262,39 +256,39 @@
262 cfg=separation_cfg,256 cfg=separation_cfg,
263 )257 )
264 separations.append(separation)258 separations.append(separation)
265 if apply_filter and separation.cutoff is not None:259 if apply_filter and separation.cutoff is not None:
266 channels = PointChannels(260 channels = types.PointChannels(
267 points=channels.points[separation.paint_mask],261 points=channels.points[separation.paint_mask],
268 data=channels.data.select_by_mask(separation.paint_mask),262 data=channels.data.select_by_mask(separation.paint_mask),
269 )263 )
270 cluster_path = output_dir / f"{cluster_prefix}{retained_index:03d}.npz"264 cluster_path = output_dir / f"{cluster_prefix}{retained_index:03d}.npz"
271 write_cluster_npz(cluster_path, channels, component.segment_type)265 cluster_io.write_cluster_npz(cluster_path, channels, component.segment_type)
272 if save_padded:266 if save_padded:
273 assert sep_channels is not None267 assert sep_channels is not None
274 padded_mask = dilate_mask(component.binary_mask, dilation_px)268 padded_mask = intensity_separation.dilate_mask(component.binary_mask, dilation_px)
275 in_padded = padded_mask[sep_rows, sep_cols]269 in_padded = padded_mask[sep_rows, sep_cols]
276 padded_channels = PointChannels(270 padded_channels = types.PointChannels(
277 points=sep_xyz[in_padded],271 points=sep_xyz[in_padded],
278 data=sep_channels.select_by_mask(in_padded),272 data=sep_channels.select_by_mask(in_padded),
279 )273 )
280 padding_debug_dir.mkdir(parents=True, exist_ok=True)274 padding_debug_dir.mkdir(parents=True, exist_ok=True)
281 padding_debug_path = padding_debug_dir / f"padded_{retained_index:03d}.npz"275 padding_debug_path = padding_debug_dir / f"padded_{retained_index:03d}.npz"
282 write_cluster_npz(276 cluster_io.write_cluster_npz(
283 padding_debug_path, padded_channels, component.segment_type277 padding_debug_path, padded_channels, component.segment_type
284 )278 )
285 retained_index += 1279 retained_index += 1
286280
287 prism_path = geometry_dir / (281 prism_path = geometry_dir / (
288 f"component_{component.component_id:04d}_{component.segment_type.value}_prism.ply"282 f"component_{component.component_id:04d}_{component.segment_type.value}_prism.ply"
289 )283 )
290 polygons = mask_polygons_xy(284 polygons = geometry.mask_polygons_xy(
291 component.binary_mask,285 component.binary_mask,
292 frame,286 frame,
293 simplify_tolerance_px=geometry_cfg.simplify_tolerance_px,287 simplify_tolerance_px=geometry_cfg.simplify_tolerance_px,
294 )288 )
295 if geometry_cfg.write_ply:289 if geometry_cfg.write_ply:
296 write_prism_ply(290 geometry.write_prism_ply(
297 prism_path,291 prism_path,
298 polygons,292 polygons,
299 z_min=float(z_min),293 z_min=float(z_min),
300 z_max=float(z_max),294 z_max=float(z_max),
Importance #22: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -341,9 +335,9 @@
341 }335 }
342 geojson_features.append(336 geojson_features.append(
343 {337 {
344 "type": "Feature",338 "type": "Feature",
345 "geometry": component_geojson_geometry(polygons),339 "geometry": geometry.component_geojson_geometry(polygons),
346 "properties": properties,340 "properties": properties,
347 }341 }
348 )342 )
349 warning_state = point_count < cluster_cfg.warn_below_points343 warning_state = point_count < cluster_cfg.warn_below_points
Importance #23: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -361,9 +355,9 @@
361 **separation_fields,355 **separation_fields,
362 }356 }
363 )357 )
364 component_results.append(358 component_results.append(
365 ComponentResult(359 types.ComponentResult(
366 component_id=component.component_id,360 component_id=component.component_id,
367 segment_type=component.segment_type,361 segment_type=component.segment_type,
368 mask_pixel_count=component.pixel_count,362 mask_pixel_count=component.pixel_count,
369 point_count=point_count,363 point_count=point_count,
Importance #24: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -422,12 +416,12 @@
422 "configuration": cfg.raw,416 "configuration": cfg.raw,
423 "counts": {417 "counts": {
424 "foreground_components": len(components),418 "foreground_components": len(components),
425 "solid_components": sum(419 "solid_components": sum(
426 item.segment_type is SegmentType.SOLID for item in components420 item.segment_type is types.SegmentType.SOLID for item in components
427 ),421 ),
428 "dashed_components": sum(422 "dashed_components": sum(
429 item.segment_type is SegmentType.DASHED for item in components423 item.segment_type is types.SegmentType.DASHED for item in components
430 ),424 ),
431 "retained_clusters": retained_index,425 "retained_clusters": retained_index,
432 "dropped_clusters": dropped_count,426 "dropped_clusters": dropped_count,
433 "out_of_frame_points": out_of_frame,427 "out_of_frame_points": out_of_frame,
Importance #25: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -452,9 +446,9 @@
452 "dashed_count": manifest["counts"]["dashed_components"],446 "dashed_count": manifest["counts"]["dashed_components"],
453 "warning_count": sum(item["warning"] for item in manifest_components),447 "warning_count": sum(item["warning"] for item in manifest_components),
454 },448 },
455 )449 )
456 return SegmentResult(450 return types.SegmentResult(
457 segment_name=segment_name,451 segment_name=segment_name,
458 manifest_path=manifest_path,452 manifest_path=manifest_path,
459 source_point_count=source_count,453 source_point_count=source_count,
460 retained_count=retained_index,454 retained_count=retained_index,
Importance #26: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -462,23 +456,23 @@
462 components=tuple(component_results),456 components=tuple(component_results),
463 )457 )
464458
465459
466def _write_json_atomic(path: Path, value: dict[str, Any]) -> None:460def _write_json_atomic(path: pathlib.Path, value: dict[str, Any]) -> None:
467 path.parent.mkdir(parents=True, exist_ok=True)461 path.parent.mkdir(parents=True, exist_ok=True)
468 descriptor, name = tempfile.mkstemp(462 descriptor, name = tempfile.mkstemp(
469 prefix=f".{path.name}.", suffix=".tmp", dir=path.parent463 prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
470 )464 )
471 os.close(descriptor)465 os.close(descriptor)
472 try:466 try:
473 write_stats(name, value, sort_keys=True, trailing_newline=True)467 run_stats.write_stats(name, value, sort_keys=True, trailing_newline=True)
474 os.replace(name, path)468 os.replace(name, path)
475 finally:469 finally:
476 Path(name).unlink(missing_ok=True)470 pathlib.Path(name).unlink(missing_ok=True)
477471
478472
479def _remove_prior_artifacts(output_dir: Path, manifest_path: Path) -> None:473def _remove_prior_artifacts(output_dir: pathlib.Path, manifest_path: pathlib.Path) -> None:
480 previous = read_stats(manifest_path, require_dict=True)474 previous = run_stats.read_stats(manifest_path, require_dict=True)
481 relative_paths: list[str] = []475 relative_paths: list[str] = []
482 for component in previous.get("components", []):476 for component in previous.get("components", []):
483 for key in ("cluster_path", "padding_debug_path", "prism_path"):477 for key in ("cluster_path", "padding_debug_path", "prism_path"):
484 if component.get(key):478 if component.get(key):
Importance #27: tests/test_cluster_io.py @@ -1,19 +1,16 @@
1import dataclasses1import dataclasses
2import logging2import logging
3import pathlib
3import zipfile4import zipfile
4from pathlib import Path
5from typing import BinaryIO5from typing import BinaryIO
66
7import numpy as np7import numpy as np
8import pytest8import pytest
9from iolabs.common import atomic_io9from iolabs.common import atomic_io, color_intensity_data
10from iolabs.common.color_intensity_data import ColorIntensityData10from iolabs_point_cloud_filtering_clusters import clustering_gpu_io
11from iolabs_point_cloud_filtering_clusters.clustering_gpu_io import load_cluster_artifact_npz
1211
13from iolabs_point_cloud_mask_clustering import cluster_io12from iolabs_point_cloud_mask_clustering import cluster_io, types
14from iolabs_point_cloud_mask_clustering.cluster_io import write_cluster_npz
15from iolabs_point_cloud_mask_clustering.types import PointChannels, SegmentType
1613
17FIXED_MEMBERS = {14FIXED_MEMBERS = {
18 "points",15 "points",
19 "scan_angle",16 "scan_angle",
Importance #28: tests/test_cluster_io.py @@ -43,13 +40,13 @@
43 scan_angle: np.ndarray = dataclasses.field(default_factory=lambda: np.array([7, 8]))40 scan_angle: np.ndarray = dataclasses.field(default_factory=lambda: np.array([7, 8]))
4441
4542
46def _installed_channel_fields() -> set[str]:43def _installed_channel_fields() -> set[str]:
47 return {field.name for field in dataclasses.fields(ColorIntensityData)}44 return {field.name for field in dataclasses.fields(color_intensity_data.ColorIntensityData)}
4845
4946
50def _stub_channels(stub: type) -> PointChannels:47def _stub_channels(stub: type) -> types.PointChannels:
51 return PointChannels(48 return types.PointChannels(
52 points=np.array([[1.123456789, 2, 3], [4, 5, 6]], dtype=np.float64),49 points=np.array([[1.123456789, 2, 3], [4, 5, 6]], dtype=np.float64),
53 data=stub(50 data=stub(
54 red=np.array([1.6, 2.4]),51 red=np.array([1.6, 2.4]),
55 green=np.array([3.5, 4.5]),52 green=np.array([3.5, 4.5]),
Importance #29: tests/test_cluster_io.py @@ -59,12 +56,12 @@
59 ),56 ),
60 )57 )
6158
6259
63def _sample() -> PointChannels:60def _sample() -> types.PointChannels:
64 return PointChannels(61 return types.PointChannels(
65 points=np.array([[1.123456789, 2, 3], [4, 5, 6]], dtype=np.float64),62 points=np.array([[1.123456789, 2, 3], [4, 5, 6]], dtype=np.float64),
66 data=ColorIntensityData(63 data=color_intensity_data.ColorIntensityData(
67 scan_angle_rank=np.array([-200.1, 127.4]),64 scan_angle_rank=np.array([-200.1, 127.4]),
68 intensity=np.array([-1.0, 70000.0]),65 intensity=np.array([-1.0, 70000.0]),
69 red=np.array([1.6, 2.4]),66 red=np.array([1.6, 2.4]),
70 green=np.array([3.5, 4.5]),67 green=np.array([3.5, 4.5]),
Importance #30: tests/test_cluster_io.py @@ -72,12 +69,12 @@
72 ),69 ),
73 )70 )
7471
7572
76def test_writes_step6_compatible_dtypes_and_segment_type(tmp_path: Path) -> None:73def test_writes_step6_compatible_dtypes_and_segment_type(tmp_path: pathlib.Path) -> None:
77 path = tmp_path / "run6_cluster_000.npz"74 path = tmp_path / "run6_cluster_000.npz"
78 source = _sample()75 source = _sample()
79 write_cluster_npz(path, source, SegmentType.DASHED)76 cluster_io.write_cluster_npz(path, source, types.SegmentType.DASHED)
80 with np.load(path) as payload:77 with np.load(path) as payload:
81 assert payload["points"].dtype == np.float6478 assert payload["points"].dtype == np.float64
82 assert payload["scan_angle"].dtype == np.int879 assert payload["scan_angle"].dtype == np.int8
83 assert payload["intensity"].dtype == np.uint1680 assert payload["intensity"].dtype == np.uint16
Importance #31: tests/test_cluster_io.py @@ -87,38 +84,38 @@
87 np.testing.assert_array_equal(payload["scan_angle"], [-128, 127])84 np.testing.assert_array_equal(payload["scan_angle"], [-128, 127])
88 np.testing.assert_array_equal(payload["intensity"], [0, 65535])85 np.testing.assert_array_equal(payload["intensity"], [0, 65535])
89 np.testing.assert_array_equal(payload["red"], [2, 2])86 np.testing.assert_array_equal(payload["red"], [2, 2])
90 np.testing.assert_array_equal(payload["green"], [4, 4])87 np.testing.assert_array_equal(payload["green"], [4, 4])
91 artifact = load_cluster_artifact_npz(path)88 artifact = clustering_gpu_io.load_cluster_artifact_npz(path)
92 np.testing.assert_array_equal(artifact.points, source.points)89 np.testing.assert_array_equal(artifact.points, source.points)
9390
9491
95def test_members_are_stored_not_deflated(tmp_path: Path) -> None:92def test_members_are_stored_not_deflated(tmp_path: pathlib.Path) -> None:
96 # Tablecloth chunk-streams cluster archives, which only works for STORED members.93 # Tablecloth chunk-streams cluster archives, which only works for STORED members.
97 path = tmp_path / "run6_cluster_000.npz"94 path = tmp_path / "run6_cluster_000.npz"
98 write_cluster_npz(path, _sample(), SegmentType.SOLID)95 cluster_io.write_cluster_npz(path, _sample(), types.SegmentType.SOLID)
99 with zipfile.ZipFile(path) as archive:96 with zipfile.ZipFile(path) as archive:
100 methods = {info.filename: info.compress_type for info in archive.infolist()}97 methods = {info.filename: info.compress_type for info in archive.infolist()}
101 assert methods98 assert methods
102 assert set(methods.values()) == {zipfile.ZIP_STORED}99 assert set(methods.values()) == {zipfile.ZIP_STORED}
103100
104101
105def test_five_field_channels_write_only_the_fixed_members(tmp_path: Path) -> None:102def test_five_field_channels_write_only_the_fixed_members(tmp_path: pathlib.Path) -> None:
106 # A ColorIntensityData declaring only the explicit five (iolabs-common 0.7.0)103 # A ColorIntensityData declaring only the explicit five (iolabs-common 0.7.0)
107 # must produce exactly the pre-AI3D-382 member set: no generic extras.104 # must produce exactly the pre-AI3D-382 member set: no generic extras.
108 path = tmp_path / "run6_cluster_000.npz"105 path = tmp_path / "run6_cluster_000.npz"
109 write_cluster_npz(path, _stub_channels(_FiveChannels), SegmentType.SOLID)106 cluster_io.write_cluster_npz(path, _stub_channels(_FiveChannels), types.SegmentType.SOLID)
110 with np.load(path) as payload:107 with np.load(path) as payload:
111 assert set(payload.files) == FIXED_MEMBERS108 assert set(payload.files) == FIXED_MEMBERS
112 assert payload["scan_angle"].dtype == np.int8109 assert payload["scan_angle"].dtype == np.int8
113 np.testing.assert_array_equal(payload["scan_angle"], [-128, 127])110 np.testing.assert_array_equal(payload["scan_angle"], [-128, 127])
114111
115112
116def test_member_set_follows_the_installed_channel_schema(tmp_path: Path) -> None:113def test_member_set_follows_the_installed_channel_schema(tmp_path: pathlib.Path) -> None:
117 # Holds on any iolabs-common: the extras are exactly the installed114 # Holds on any iolabs-common: the extras are exactly the installed
118 # dataclass's non-explicit init fields.115 # dataclass's non-explicit init fields.
119 path = tmp_path / "run6_cluster_000.npz"116 path = tmp_path / "run6_cluster_000.npz"
120 write_cluster_npz(path, _sample(), SegmentType.SOLID)117 cluster_io.write_cluster_npz(path, _sample(), types.SegmentType.SOLID)
121 expected_extras = _installed_channel_fields() - cluster_io.EXPLICIT_CHANNEL_FIELDS118 expected_extras = _installed_channel_fields() - cluster_io.EXPLICIT_CHANNEL_FIELDS
122 with np.load(path) as payload:119 with np.load(path) as payload:
123 assert set(payload.files) == FIXED_MEMBERS | expected_extras120 assert set(payload.files) == FIXED_MEMBERS | expected_extras
124121
Importance #32: tests/test_cluster_io.py @@ -126,13 +123,13 @@
126@pytest.mark.skipif(123@pytest.mark.skipif(
127 "number_of_returns" not in _installed_channel_fields(),124 "number_of_returns" not in _installed_channel_fields(),
128 reason="installed ColorIntensityData predates number_of_returns",125 reason="installed ColorIntensityData predates number_of_returns",
129)126)
130def test_number_of_returns_is_forwarded_aligned_with_points(tmp_path: Path) -> None:127def test_number_of_returns_is_forwarded_aligned_with_points(tmp_path: pathlib.Path) -> None:
131 source = _sample()128 source = _sample()
132 source.data.number_of_returns = np.array([1, 3], dtype=np.uint8)129 source.data.number_of_returns = np.array([1, 3], dtype=np.uint8)
133 path = tmp_path / "run6_cluster_000.npz"130 path = tmp_path / "run6_cluster_000.npz"
134 write_cluster_npz(path, source, SegmentType.DASHED)131 cluster_io.write_cluster_npz(path, source, types.SegmentType.DASHED)
135 with np.load(path) as payload:132 with np.load(path) as payload:
136 returns = payload["number_of_returns"]133 returns = payload["number_of_returns"]
137 assert returns.dtype == np.uint8134 assert returns.dtype == np.uint8
138 assert len(returns) == len(source.points)135 assert len(returns) == len(source.points)
Importance #33: tests/test_cluster_io.py @@ -142,23 +139,25 @@
142@pytest.mark.skipif(139@pytest.mark.skipif(
143 "number_of_returns" not in _installed_channel_fields(),140 "number_of_returns" not in _installed_channel_fields(),
144 reason="installed ColorIntensityData predates number_of_returns",141 reason="installed ColorIntensityData predates number_of_returns",
145)142)
146def test_omitted_number_of_returns_is_forwarded_as_unknown_zeros(tmp_path: Path) -> None:143def test_omitted_number_of_returns_is_forwarded_as_unknown_zeros(tmp_path: pathlib.Path) -> None:
147 path = tmp_path / "run6_cluster_000.npz"144 path = tmp_path / "run6_cluster_000.npz"
148 write_cluster_npz(path, _sample(), SegmentType.DASHED)145 cluster_io.write_cluster_npz(path, _sample(), types.SegmentType.DASHED)
149 with np.load(path) as payload:146 with np.load(path) as payload:
150 returns = payload["number_of_returns"]147 returns = payload["number_of_returns"]
151 assert returns.dtype == np.uint8148 assert returns.dtype == np.uint8
152 np.testing.assert_array_equal(returns, [0, 0])149 np.testing.assert_array_equal(returns, [0, 0])
153150
154151
155def test_field_colliding_with_a_fixed_member_is_skipped_with_a_warning(152def test_field_colliding_with_a_fixed_member_is_skipped_with_a_warning(
156 tmp_path: Path, caplog: pytest.LogCaptureFixture153 tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
157) -> None:154) -> None:
158 path = tmp_path / "run6_cluster_000.npz"155 path = tmp_path / "run6_cluster_000.npz"
159 with caplog.at_level(logging.WARNING, logger=cluster_io.__name__):156 with caplog.at_level(logging.WARNING, logger=cluster_io.__name__):
160 write_cluster_npz(path, _stub_channels(_CollidingChannels), SegmentType.SOLID)157 cluster_io.write_cluster_npz(
158 path, _stub_channels(_CollidingChannels), types.SegmentType.SOLID
159 )
161 assert any("scan_angle" in record.getMessage() for record in caplog.records)160 assert any("scan_angle" in record.getMessage() for record in caplog.records)
162 with np.load(path) as payload:161 with np.load(path) as payload:
163 assert set(payload.files) == FIXED_MEMBERS162 assert set(payload.files) == FIXED_MEMBERS
164 # The fixed member survived: it is the clipped scan_angle_rank, not [7, 8].163 # The fixed member survived: it is the clipped scan_angle_rank, not [7, 8].
Importance #34: tests/test_cluster_io.py @@ -166,9 +165,9 @@
166 np.testing.assert_array_equal(payload["scan_angle"], [-128, 127])165 np.testing.assert_array_equal(payload["scan_angle"], [-128, 127])
167166
168167
169def test_atomic_failure_leaves_no_partial_output(168def test_atomic_failure_leaves_no_partial_output(
170 tmp_path: Path, monkeypatch: pytest.MonkeyPatch169 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
171) -> None:170) -> None:
172 path = tmp_path / "run6_cluster_000.npz"171 path = tmp_path / "run6_cluster_000.npz"
173172
174 def fail(handle: BinaryIO, **payload: np.ndarray) -> None:173 def fail(handle: BinaryIO, **payload: np.ndarray) -> None:
Importance #35: tests/test_cluster_io.py @@ -176,7 +175,7 @@
176 raise RuntimeError("disk failure")175 raise RuntimeError("disk failure")
177176
178 monkeypatch.setattr(atomic_io.np, "savez", fail)177 monkeypatch.setattr(atomic_io.np, "savez", fail)
179 with pytest.raises(RuntimeError):178 with pytest.raises(RuntimeError):
180 write_cluster_npz(path, _sample(), SegmentType.SOLID)179 cluster_io.write_cluster_npz(path, _sample(), types.SegmentType.SOLID)
181 assert not path.exists()180 assert not path.exists()
182 assert list(tmp_path.iterdir()) == []181 assert list(tmp_path.iterdir()) == []
Importance #36: src/iolabs_point_cloud_mask_clustering/input_io.py @@ -6,28 +6,25 @@
6``ColorIntensityData`` flows through this module with no code change here.6``ColorIntensityData`` flows through this module with no code change here.
7"""7"""
88
9import dataclasses9import dataclasses
10from collections.abc import Iterable, Mapping10import pathlib
11from dataclasses import dataclass11from collections import abc
12from pathlib import Path
1312
14import numpy as np13import numpy as np
15from iolabs.common import segment_points_io14from iolabs import logstash
16from iolabs.common.color_intensity_data import ColorIntensityData15from iolabs.common import color_intensity_data, segment_points_io
17from iolabs.logstash import get_props_logger
1816
19from ._log_props import LOG_PROPS17from . import _log_props, types
20from .types import PointChannels, RasterFrame
2118
22logger = get_props_logger(__name__, LOG_PROPS)19logger = logstash.get_props_logger(__name__, _log_props.LOG_PROPS)
2320
24#: Deprecated alias for the shared run3 point-record schema; use21#: Deprecated alias for the shared run3 point-record schema; use
25#: :data:`iolabs.common.segment_points_io.POINT_RECORD_KEYS` instead.22#: :data:`iolabs.common.segment_points_io.POINT_RECORD_KEYS` instead.
26REQUIRED_ARRAYS: tuple[str, ...] = segment_points_io.POINT_RECORD_KEYS23REQUIRED_ARRAYS: tuple[str, ...] = segment_points_io.POINT_RECORD_KEYS
2724
28#: Record keys whose name differs from the matching ``ColorIntensityData`` field.25#: Record keys whose name differs from the matching ``ColorIntensityData`` field.
29_RECORD_KEY_TO_CHANNEL_FIELD: Mapping[str, str] = {"scan_angle": "scan_angle_rank"}26_RECORD_KEY_TO_CHANNEL_FIELD: abc.Mapping[str, str] = {"scan_angle": "scan_angle_rank"}
3027
31#: Keys the lean separation path reads directly from the npz.28#: Keys the lean separation path reads directly from the npz.
32_LEAN_KEYS: tuple[str, ...] = ("points", "intensity")29_LEAN_KEYS: tuple[str, ...] = ("points", "intensity")
3330
Importance #37: src/iolabs_point_cloud_mask_clustering/input_io.py @@ -43,62 +40,64 @@
43 Derived from the installed ``iolabs.common`` contract: a key added to both40 Derived from the installed ``iolabs.common`` contract: a key added to both
44 the point-record schema and ``ColorIntensityData`` appears here41 the point-record schema and ``ColorIntensityData`` appears here
45 automatically, and keys the installed dataclass cannot hold are skipped.42 automatically, and keys the installed dataclass cannot hold are skipped.
46 """43 """
47 fields = {field.name for field in dataclasses.fields(ColorIntensityData)}44 fields = {field.name for field in dataclasses.fields(color_intensity_data.ColorIntensityData)}
48 return tuple(45 return tuple(
49 key46 key
50 for key in segment_points_io.POINT_RECORD_KEYS47 for key in segment_points_io.POINT_RECORD_KEYS
51 if key != "points" and _channel_field(key) in fields48 if key != "points" and _channel_field(key) in fields
52 )49 )
5350
5451
55def channel_dtypes_of(data: ColorIntensityData) -> dict[str, np.dtype]:52def channel_dtypes_of(data: color_intensity_data.ColorIntensityData) -> dict[str, np.dtype]:
56 """Return *data*'s per-channel storage dtypes, keyed by run3 record key."""53 """Return *data*'s per-channel storage dtypes, keyed by run3 record key."""
57 return {54 return {
58 key: getattr(data, _channel_field(key)).dtype for key in channel_record_keys()55 key: getattr(data, _channel_field(key)).dtype for key in channel_record_keys()
59 }56 }
6057
6158
62def _channels_from_record(record: Mapping[str, np.ndarray]) -> ColorIntensityData:59def _channels_from_record(
60 record: abc.Mapping[str, np.ndarray],
61) -> color_intensity_data.ColorIntensityData:
63 """Build channels from a run3 record without naming each field."""62 """Build channels from a run3 record without naming each field."""
64 return ColorIntensityData(63 return color_intensity_data.ColorIntensityData(
65 **{64 **{
66 _channel_field(key): record[key]65 _channel_field(key): record[key]
67 for key in channel_record_keys()66 for key in channel_record_keys()
68 if key in record67 if key in record
69 }68 }
70 )69 )
7170
7271
73def _as_points(path: Path, raw: np.ndarray) -> np.ndarray:72def _as_points(path: pathlib.Path, raw: np.ndarray) -> np.ndarray:
74 points = np.asarray(raw, dtype=np.float64)73 points = np.asarray(raw, dtype=np.float64)
75 if points.ndim != 2 or points.shape[1] != 3:74 if points.ndim != 2 or points.shape[1] != 3:
76 raise ValueError(f"{path}: points must have shape (N, 3), got {points.shape}")75 raise ValueError(f"{path}: points must have shape (N, 3), got {points.shape}")
77 return points76 return points
7877
7978
80def _as_channel(path: Path, name: str, raw: np.ndarray, count: int) -> np.ndarray:79def _as_channel(path: pathlib.Path, name: str, raw: np.ndarray, count: int) -> np.ndarray:
81 array = np.asarray(raw)80 array = np.asarray(raw)
82 if array.ndim != 1 or len(array) != count:81 if array.ndim != 1 or len(array) != count:
83 raise ValueError(82 raise ValueError(
84 f"{path}: {name} must have shape ({count},), got {array.shape}"83 f"{path}: {name} must have shape ({count},), got {array.shape}"
85 )84 )
86 return array85 return array
8786
8887
89@dataclass(frozen=True)88@dataclasses.dataclass(frozen=True)
90class SeparationArrays:89class SeparationArrays:
91 """Preallocated full-segment arrays for intensity separation."""90 """Preallocated full-segment arrays for intensity separation."""
9291
93 xyz: np.ndarray92 xyz: np.ndarray
94 rows: np.ndarray93 rows: np.ndarray
95 cols: np.ndarray94 cols: np.ndarray
96 intensity: np.ndarray95 intensity: np.ndarray
97 channels: ColorIntensityData | None96 channels: color_intensity_data.ColorIntensityData | None
9897
9998
100def load_step3_file(path: Path) -> PointChannels:99def load_step3_file(path: pathlib.Path) -> types.PointChannels:
101 """Load one Step 3 chunk with all its channels.100 """Load one Step 3 chunk with all its channels.
102101
103 The record is read and schema-validated by102 The record is read and schema-validated by
104 :func:`iolabs.common.segment_points_io.load_points_npz`; XYZ is then cast to103 :func:`iolabs.common.segment_points_io.load_points_npz`; XYZ is then cast to
Importance #38: src/iolabs_point_cloud_mask_clustering/input_io.py @@ -113,17 +112,17 @@
113 Raises:112 Raises:
114 ValueError: A required array is missing or has the wrong shape.113 ValueError: A required array is missing or has the wrong shape.
115 """114 """
116 record = segment_points_io.load_points_npz(path)115 record = segment_points_io.load_points_npz(path)
117 return PointChannels(116 return types.PointChannels(
118 points=_as_points(path, record["points"]),117 points=_as_points(path, record["points"]),
119 data=_channels_from_record(record),118 data=_channels_from_record(record),
120 )119 )
121120
122121
123def _load_separation_chunk(122def _load_separation_chunk(
124 path: Path, *, with_channels: bool123 path: pathlib.Path, *, with_channels: bool
125) -> tuple[np.ndarray, np.ndarray, ColorIntensityData | None]:124) -> tuple[np.ndarray, np.ndarray, color_intensity_data.ColorIntensityData | None]:
126 """Load one Step 3 chunk for separation.125 """Load one Step 3 chunk for separation.
127126
128 When ``with_channels`` is false, only ``points`` and ``intensity`` are read127 When ``with_channels`` is false, only ``points`` and ``intensity`` are read
129 from the npz; the other record keys are neither materialized nor validated128 from the npz; the other record keys are neither materialized nor validated
Importance #39: src/iolabs_point_cloud_mask_clustering/input_io.py @@ -146,9 +145,9 @@
146 intensity = _as_channel(path, "intensity", payload["intensity"], len(points))145 intensity = _as_channel(path, "intensity", payload["intensity"], len(points))
147 return points, intensity, None146 return points, intensity, None
148147
149148
150def load_step3_points(paths: Iterable[Path]) -> PointChannels:149def load_step3_points(paths: abc.Iterable[pathlib.Path]) -> types.PointChannels:
151 """Load and concatenate several Step 3 chunks.150 """Load and concatenate several Step 3 chunks.
152151
153 Args:152 Args:
154 paths: Step 3 ``.npz`` chunks; loaded in sorted order.153 paths: Step 3 ``.npz`` chunks; loaded in sorted order.
Importance #40: src/iolabs_point_cloud_mask_clustering/input_io.py @@ -165,17 +164,17 @@
165 points = np.concatenate([chunk.points for chunk in chunks])164 points = np.concatenate([chunk.points for chunk in chunks])
166 data = chunks[0].data165 data = chunks[0].data
167 for chunk in chunks[1:]:166 for chunk in chunks[1:]:
168 data = data.append(chunk.data)167 data = data.append(chunk.data)
169 return PointChannels(points=points, data=data)168 return types.PointChannels(points=points, data=data)
170169
171170
172def load_separation_arrays(171def load_separation_arrays(
173 paths: Iterable[Path],172 paths: abc.Iterable[pathlib.Path],
174 frame: RasterFrame,173 frame: types.RasterFrame,
175 total_count: int,174 total_count: int,
176 *,175 *,
177 channel_dtypes: Mapping[str, np.dtype],176 channel_dtypes: abc.Mapping[str, np.dtype],
178 with_channels: bool,177 with_channels: bool,
179) -> SeparationArrays:178) -> SeparationArrays:
180 """Stream Step 3 chunks into preallocated separation arrays.179 """Stream Step 3 chunks into preallocated separation arrays.
181180
Importance #41: src/iolabs_point_cloud_mask_clustering/input_io.py @@ -250,9 +249,9 @@
250 )249 )
251250
252 channels = None251 channels = None
253 if with_channels:252 if with_channels:
254 channels = ColorIntensityData(253 channels = color_intensity_data.ColorIntensityData(
255 intensity=intensity,254 intensity=intensity,
256 **{_channel_field(key): buffers[key] for key in extra_keys},255 **{_channel_field(key): buffers[key] for key in extra_keys},
257 )256 )
258 return SeparationArrays(257 return SeparationArrays(
Importance #42: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -1,44 +1,38 @@
1"""Run the mask-clustering step end to end for one segment."""1"""Run the mask-clustering step end to end for one segment."""
22
3import datetime
3import os4import os
5import pathlib
4import tempfile6import tempfile
5from datetime import UTC, datetime7from importlib import metadata
6from importlib.metadata import PackageNotFoundError, version
7from pathlib import Path
8from typing import Any8from typing import Any
99
10import numpy as np10import numpy as np
11from iolabs.common.color_intensity_data import ColorIntensityData11from iolabs import logstash
12from iolabs.common.run_stats import read_stats, write_stats12from iolabs.common import color_intensity_data, run_stats, segments, version_info
13from iolabs.common.segments import segment_record_files
14from iolabs.common.version_info import save_version_json
15from iolabs.logstash import get_props_logger
1613
17from ._config import MaskClusteringConfig14from . import (
18from ._log_props import LOG_PROPS15 _config,
19from .cluster_io import write_cluster_npz16 _log_props,
20from .geometry import component_geojson_geometry, mask_polygons_xy, write_prism_ply17 cluster_io,
21from .input_io import channel_dtypes_of, load_separation_arrays, load_step3_file18 geometry,
22from .intensity_separation import (19 input_io,
23 ClusterSeparation,20 intensity_separation,
24 dilate_mask,21 mask_components,
25 separate_cluster,22 point_projection,
26 write_separation_pdf,23 raster_frame,
24 types,
27)25)
28from .mask_components import label_components, load_classified_mask
29from .point_projection import assign_point_chunks
30from .raster_frame import reconstruct_raster_frame
31from .types import ComponentResult, PointChannels, SegmentResult, SegmentType
3226
33logger = get_props_logger(__name__, LOG_PROPS)27logger = logstash.get_props_logger(__name__, _log_props.LOG_PROPS)
3428
3529
36def apply_sparse_policy(30def apply_sparse_policy(
37 *,31 *,
38 segment_name: str,32 segment_name: str,
39 component_id: int,33 component_id: int,
40 segment_type: SegmentType,34 segment_type: types.SegmentType,
41 point_count: int,35 point_count: int,
42 warn_below_points: int,36 warn_below_points: int,
43 min_points_per_cluster: int,37 min_points_per_cluster: int,
44 logger: Any,38 logger: Any,
Importance #43: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -76,15 +70,15 @@
7670
7771
78def process_segment(72def process_segment(
79 *,73 *,
80 segment_dir: Path,74 segment_dir: pathlib.Path,
81 mask_or_vectors_path: Path,75 mask_or_vectors_path: pathlib.Path,
82 raster_metadata_path: Path,76 raster_metadata_path: pathlib.Path,
83 output_dir: Path,77 output_dir: pathlib.Path,
84 config: MaskClusteringConfig | dict[str, Any],78 config: _config.MaskClusteringConfig | dict[str, Any],
85 overwrite: bool = False,79 overwrite: bool = False,
86) -> SegmentResult:80) -> types.SegmentResult:
87 """Cluster one segment's classified mask into Step 6-compatible artifacts.81 """Cluster one segment's classified mask into Step 6-compatible artifacts.
8882
89 Args:83 Args:
90 segment_dir: Segment directory holding the Step 3 point NPZ chunks.84 segment_dir: Segment directory holding the Step 3 point NPZ chunks.
Importance #44: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -105,24 +99,24 @@
105 the configured suffix, or *raster_metadata_path* does not exist.99 the configured suffix, or *raster_metadata_path* does not exist.
106 ValueError: The segment holds no points, or the mask, sidecar and100 ValueError: The segment holds no points, or the mask, sidecar and
107 reconstructed frame disagree.101 reconstructed frame disagree.
108 """102 """
109 started_at = datetime.now(UTC)103 started_at = datetime.datetime.now(datetime.UTC)
110 cfg = MaskClusteringConfig.coerce(config)104 cfg = _config.MaskClusteringConfig.coerce(config)
111 manifest_path = output_dir / cfg.output.manifest_filename105 manifest_path = output_dir / cfg.output.manifest_filename
112 if manifest_path.exists():106 if manifest_path.exists():
113 if not overwrite:107 if not overwrite:
114 raise FileExistsError(108 raise FileExistsError(
115 f"Completed mask-clustering manifest already exists: {manifest_path}"109 f"Completed mask-clustering manifest already exists: {manifest_path}"
116 )110 )
117 _remove_prior_artifacts(output_dir, manifest_path)111 _remove_prior_artifacts(output_dir, manifest_path)
118112
119 step3_paths = segment_record_files(113 step3_paths = segments.segment_record_files(
120 segment_dir, cfg.file_naming.segment_points_suffix, required=True114 segment_dir, cfg.file_naming.segment_points_suffix, required=True
121 )115 )
122 metadata = read_stats(raster_metadata_path, require_dict=True)116 segment_metadata = run_stats.read_stats(raster_metadata_path, require_dict=True)
123 segment_name = str(117 segment_name = str(
124 metadata.get("segment_name", metadata.get("segment_id", segment_dir.name))118 segment_metadata.get("segment_name", segment_metadata.get("segment_id", segment_dir.name))
125 )119 )
126120
127 source_count = 0121 source_count = 0
128 xy_min = np.array([np.inf, np.inf])122 xy_min = np.array([np.inf, np.inf])
Importance #45: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -130,11 +124,11 @@
130 z_min = np.inf124 z_min = np.inf
131 z_max = -np.inf125 z_max = -np.inf
132 channel_dtypes: dict[str, np.dtype] | None = None126 channel_dtypes: dict[str, np.dtype] | None = None
133 for path in step3_paths:127 for path in step3_paths:
134 chunk = load_step3_file(path)128 chunk = input_io.load_step3_file(path)
135 source_count += len(chunk.points)129 source_count += len(chunk.points)
136 chunk_dtypes = channel_dtypes_of(chunk.data)130 chunk_dtypes = input_io.channel_dtypes_of(chunk.data)
137 if channel_dtypes is None:131 if channel_dtypes is None:
138 channel_dtypes = chunk_dtypes132 channel_dtypes = chunk_dtypes
139 else:133 else:
140 channel_dtypes = {134 channel_dtypes = {
Importance #46: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -153,30 +147,30 @@
153 if source_count == 0:147 if source_count == 0:
154 raise ValueError(f"Step 3 source in {segment_dir} contains no points")148 raise ValueError(f"Step 3 source in {segment_dir} contains no points")
155 assert channel_dtypes is not None149 assert channel_dtypes is not None
156150
157 mask_shape = (int(metadata["pixel_y"]), int(metadata["pixel_x"]))151 mask_shape = (int(segment_metadata["pixel_y"]), int(segment_metadata["pixel_x"]))
158 mask_cfg = cfg.mask152 mask_cfg = cfg.mask
159 extrema = np.array(153 extrema = np.array(
160 [[xy_min[0], xy_min[1], z_min], [xy_max[0], xy_max[1], z_max]],154 [[xy_min[0], xy_min[1], z_min], [xy_max[0], xy_max[1], z_max]],
161 dtype=np.float64,155 dtype=np.float64,
162 )156 )
163 frame = reconstruct_raster_frame(157 frame = raster_frame.reconstruct_raster_frame(
164 extrema,158 extrema,
165 metadata,159 segment_metadata,
166 mask_shape=mask_shape,160 mask_shape=mask_shape,
167 margin_pixels=cfg.raster_frame.margin_pixels,161 margin_pixels=cfg.raster_frame.margin_pixels,
168 origin_tolerance_pixels=cfg.raster_frame.metadata_origin_tolerance_pixels,162 origin_tolerance_pixels=cfg.raster_frame.metadata_origin_tolerance_pixels,
169 )163 )
170 classified_mask = load_classified_mask(164 classified_mask = mask_components.load_classified_mask(
171 mask_or_vectors_path,165 mask_or_vectors_path,
172 shape=mask_shape,166 shape=mask_shape,
173 background_class=mask_cfg.background_class,167 background_class=mask_cfg.background_class,
174 solid_class=mask_cfg.solid_class,168 solid_class=mask_cfg.solid_class,
175 dashed_class=mask_cfg.dashed_class,169 dashed_class=mask_cfg.dashed_class,
176 vector_stroke_px=mask_cfg.vector_stroke_px,170 vector_stroke_px=mask_cfg.vector_stroke_px,
177 )171 )
178 components, label_image = label_components(172 components, label_image = mask_components.label_components(
179 classified_mask,173 classified_mask,
180 solid_class=mask_cfg.solid_class,174 solid_class=mask_cfg.solid_class,
181 dashed_class=mask_cfg.dashed_class,175 dashed_class=mask_cfg.dashed_class,
182 connectivity=mask_cfg.connectivity,176 connectivity=mask_cfg.connectivity,
Importance #47: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -187,10 +181,10 @@
187 segment_name,181 segment_name,
188 extra={"segment_name": segment_name, "action": "empty_success"},182 extra={"segment_name": segment_name, "action": "empty_success"},
189 )183 )
190184
191 assigned, out_of_frame = assign_point_chunks(185 assigned, out_of_frame = point_projection.assign_point_chunks(
192 (load_step3_file(path) for path in step3_paths),186 (input_io.load_step3_file(path) for path in step3_paths),
193 frame,187 frame,
194 label_image,188 label_image,
195 components,189 components,
196 )190 )
Importance #48: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -209,17 +203,17 @@
209 # point-cloud viewer. Written to a separate subdir so Step 6b never rasterises them.203 # point-cloud viewer. Written to a separate subdir so Step 6b never rasterises them.
210 save_padded = separation_enabled and bool(separation_cfg.get("save_padded_clusters"))204 save_padded = separation_enabled and bool(separation_cfg.get("save_padded_clusters"))
211 padding_debug_dir = output_dir / "padding_debug"205 padding_debug_dir = output_dir / "padding_debug"
212 dilation_px = int(separation_cfg["dilation_px"])206 dilation_px = int(separation_cfg["dilation_px"])
213 separations: list[ClusterSeparation] = []207 separations: list[intensity_separation.ClusterSeparation] = []
214 # The asphalt sampling ring lies outside each component's mask, so it needs more208 # The asphalt sampling ring lies outside each component's mask, so it needs more
215 # than the per-component assigned points. Stream-load and project the full segment209 # than the per-component assigned points. Stream-load and project the full segment
216 # once, up front, when separation is enabled. (Cropping to the union of dilated210 # once, up front, when separation is enabled. (Cropping to the union of dilated
217 # component masks would also work; full-segment streaming is simpler and the lean211 # component masks would also work; full-segment streaming is simpler and the lean
218 # xyz/rows/cols/intensity buffers keep it affordable.)212 # xyz/rows/cols/intensity buffers keep it affordable.)
219 sep_channels: ColorIntensityData | None = None213 sep_channels: color_intensity_data.ColorIntensityData | None = None
220 if separation_enabled:214 if separation_enabled:
221 sep = load_separation_arrays(215 sep = input_io.load_separation_arrays(
222 step3_paths,216 step3_paths,
223 frame,217 frame,
224 source_count,218 source_count,
225 channel_dtypes=channel_dtypes,219 channel_dtypes=channel_dtypes,
Importance #49: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -246,14 +240,14 @@
246 warn_below_points=cluster_cfg.warn_below_points,240 warn_below_points=cluster_cfg.warn_below_points,
247 min_points_per_cluster=cluster_cfg.min_points_per_cluster,241 min_points_per_cluster=cluster_cfg.min_points_per_cluster,
248 logger=logger,242 logger=logger,
249 )243 )
250 separation: ClusterSeparation | None = None244 separation: intensity_separation.ClusterSeparation | None = None
251 cluster_path: Path | None = None245 cluster_path: pathlib.Path | None = None
252 padding_debug_path: Path | None = None246 padding_debug_path: pathlib.Path | None = None
253 if status == "retained":247 if status == "retained":
254 if separation_enabled:248 if separation_enabled:
255 separation = separate_cluster(249 separation = intensity_separation.separate_cluster(
256 component,250 component,
257 channels.data.intensity,251 channels.data.intensity,
258 points_xyz=sep_xyz,252 points_xyz=sep_xyz,
259 rows=sep_rows,253 rows=sep_rows,
Importance #50: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -262,39 +256,39 @@
262 cfg=separation_cfg,256 cfg=separation_cfg,
263 )257 )
264 separations.append(separation)258 separations.append(separation)
265 if apply_filter and separation.cutoff is not None:259 if apply_filter and separation.cutoff is not None:
266 channels = PointChannels(260 channels = types.PointChannels(
267 points=channels.points[separation.paint_mask],261 points=channels.points[separation.paint_mask],
268 data=channels.data.select_by_mask(separation.paint_mask),262 data=channels.data.select_by_mask(separation.paint_mask),
269 )263 )
270 cluster_path = output_dir / f"{cluster_prefix}{retained_index:03d}.npz"264 cluster_path = output_dir / f"{cluster_prefix}{retained_index:03d}.npz"
271 write_cluster_npz(cluster_path, channels, component.segment_type)265 cluster_io.write_cluster_npz(cluster_path, channels, component.segment_type)
272 if save_padded:266 if save_padded:
273 assert sep_channels is not None267 assert sep_channels is not None
274 padded_mask = dilate_mask(component.binary_mask, dilation_px)268 padded_mask = intensity_separation.dilate_mask(component.binary_mask, dilation_px)
275 in_padded = padded_mask[sep_rows, sep_cols]269 in_padded = padded_mask[sep_rows, sep_cols]
276 padded_channels = PointChannels(270 padded_channels = types.PointChannels(
277 points=sep_xyz[in_padded],271 points=sep_xyz[in_padded],
278 data=sep_channels.select_by_mask(in_padded),272 data=sep_channels.select_by_mask(in_padded),
279 )273 )
280 padding_debug_dir.mkdir(parents=True, exist_ok=True)274 padding_debug_dir.mkdir(parents=True, exist_ok=True)
281 padding_debug_path = padding_debug_dir / f"padded_{retained_index:03d}.npz"275 padding_debug_path = padding_debug_dir / f"padded_{retained_index:03d}.npz"
282 write_cluster_npz(276 cluster_io.write_cluster_npz(
283 padding_debug_path, padded_channels, component.segment_type277 padding_debug_path, padded_channels, component.segment_type
284 )278 )
285 retained_index += 1279 retained_index += 1
286280
287 prism_path = geometry_dir / (281 prism_path = geometry_dir / (
288 f"component_{component.component_id:04d}_{component.segment_type.value}_prism.ply"282 f"component_{component.component_id:04d}_{component.segment_type.value}_prism.ply"
289 )283 )
290 polygons = mask_polygons_xy(284 polygons = geometry.mask_polygons_xy(
291 component.binary_mask,285 component.binary_mask,
292 frame,286 frame,
293 simplify_tolerance_px=geometry_cfg.simplify_tolerance_px,287 simplify_tolerance_px=geometry_cfg.simplify_tolerance_px,
294 )288 )
295 if geometry_cfg.write_ply:289 if geometry_cfg.write_ply:
296 write_prism_ply(290 geometry.write_prism_ply(
297 prism_path,291 prism_path,
298 polygons,292 polygons,
299 z_min=float(z_min),293 z_min=float(z_min),
300 z_max=float(z_max),294 z_max=float(z_max),
Importance #51: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -341,9 +335,9 @@
341 }335 }
342 geojson_features.append(336 geojson_features.append(
343 {337 {
344 "type": "Feature",338 "type": "Feature",
345 "geometry": component_geojson_geometry(polygons),339 "geometry": geometry.component_geojson_geometry(polygons),
346 "properties": properties,340 "properties": properties,
347 }341 }
348 )342 )
349 warning_state = point_count < cluster_cfg.warn_below_points343 warning_state = point_count < cluster_cfg.warn_below_points
Importance #52: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -361,9 +355,9 @@
361 **separation_fields,355 **separation_fields,
362 }356 }
363 )357 )
364 component_results.append(358 component_results.append(
365 ComponentResult(359 types.ComponentResult(
366 component_id=component.component_id,360 component_id=component.component_id,
367 segment_type=component.segment_type,361 segment_type=component.segment_type,
368 mask_pixel_count=component.pixel_count,362 mask_pixel_count=component.pixel_count,
369 point_count=point_count,363 point_count=point_count,
Importance #53: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -387,16 +381,16 @@
387381
388 separation_pdf_relative: str | None = None382 separation_pdf_relative: str | None = None
389 if separation_enabled:383 if separation_enabled:
390 separation_pdf_path = output_dir / str(separation_cfg["pdf_filename"])384 separation_pdf_path = output_dir / str(separation_cfg["pdf_filename"])
391 write_separation_pdf(separations, separation_pdf_path, separation_cfg)385 intensity_separation.write_separation_pdf(separations, separation_pdf_path, separation_cfg)
392 separation_pdf_relative = separation_pdf_path.relative_to(output_dir).as_posix()386 separation_pdf_relative = separation_pdf_path.relative_to(output_dir).as_posix()
393387
394 versions_path = output_dir / "run6c_versions.json"388 versions_path = output_dir / "run6c_versions.json"
395 save_version_json(versions_path, "step6c_mask_clustering")389 version_info.save_version_json(versions_path, "step6c_mask_clustering")
396 dropped_count = len(components) - retained_index390 dropped_count = len(components) - retained_index
397 package_version = _package_version()391 package_version = _package_version()
398 completed_at = datetime.now(UTC)392 completed_at = datetime.datetime.now(datetime.UTC)
399 manifest = {393 manifest = {
400 "schema": "iolabs.mask_clustering/1",394 "schema": "iolabs.mask_clustering/1",
401 "package_version": package_version,395 "package_version": package_version,
402 "timestamps": {396 "timestamps": {
Importance #54: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -422,12 +416,12 @@
422 "configuration": cfg.raw,416 "configuration": cfg.raw,
423 "counts": {417 "counts": {
424 "foreground_components": len(components),418 "foreground_components": len(components),
425 "solid_components": sum(419 "solid_components": sum(
426 item.segment_type is SegmentType.SOLID for item in components420 item.segment_type is types.SegmentType.SOLID for item in components
427 ),421 ),
428 "dashed_components": sum(422 "dashed_components": sum(
429 item.segment_type is SegmentType.DASHED for item in components423 item.segment_type is types.SegmentType.DASHED for item in components
430 ),424 ),
431 "retained_clusters": retained_index,425 "retained_clusters": retained_index,
432 "dropped_clusters": dropped_count,426 "dropped_clusters": dropped_count,
433 "out_of_frame_points": out_of_frame,427 "out_of_frame_points": out_of_frame,
Importance #55: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -452,9 +446,9 @@
452 "dashed_count": manifest["counts"]["dashed_components"],446 "dashed_count": manifest["counts"]["dashed_components"],
453 "warning_count": sum(item["warning"] for item in manifest_components),447 "warning_count": sum(item["warning"] for item in manifest_components),
454 },448 },
455 )449 )
456 return SegmentResult(450 return types.SegmentResult(
457 segment_name=segment_name,451 segment_name=segment_name,
458 manifest_path=manifest_path,452 manifest_path=manifest_path,
459 source_point_count=source_count,453 source_point_count=source_count,
460 retained_count=retained_index,454 retained_count=retained_index,
Importance #56: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -462,23 +456,23 @@
462 components=tuple(component_results),456 components=tuple(component_results),
463 )457 )
464458
465459
466def _write_json_atomic(path: Path, value: dict[str, Any]) -> None:460def _write_json_atomic(path: pathlib.Path, value: dict[str, Any]) -> None:
467 path.parent.mkdir(parents=True, exist_ok=True)461 path.parent.mkdir(parents=True, exist_ok=True)
468 descriptor, name = tempfile.mkstemp(462 descriptor, name = tempfile.mkstemp(
469 prefix=f".{path.name}.", suffix=".tmp", dir=path.parent463 prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
470 )464 )
471 os.close(descriptor)465 os.close(descriptor)
472 try:466 try:
473 write_stats(name, value, sort_keys=True, trailing_newline=True)467 run_stats.write_stats(name, value, sort_keys=True, trailing_newline=True)
474 os.replace(name, path)468 os.replace(name, path)
475 finally:469 finally:
476 Path(name).unlink(missing_ok=True)470 pathlib.Path(name).unlink(missing_ok=True)
477471
478472
479def _remove_prior_artifacts(output_dir: Path, manifest_path: Path) -> None:473def _remove_prior_artifacts(output_dir: pathlib.Path, manifest_path: pathlib.Path) -> None:
480 previous = read_stats(manifest_path, require_dict=True)474 previous = run_stats.read_stats(manifest_path, require_dict=True)
481 relative_paths: list[str] = []475 relative_paths: list[str] = []
482 for component in previous.get("components", []):476 for component in previous.get("components", []):
483 for key in ("cluster_path", "padding_debug_path", "prism_path"):477 for key in ("cluster_path", "padding_debug_path", "prism_path"):
484 if component.get(key):478 if component.get(key):
Importance #57: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -497,7 +491,7 @@
497491
498492
499def _package_version() -> str:493def _package_version() -> str:
500 try:494 try:
501 return version("iolabs-point-cloud-mask-clustering")495 return metadata.version("iolabs-point-cloud-mask-clustering")
502 except PackageNotFoundError:496 except metadata.PackageNotFoundError:
503 return "0.1.0"497 return "0.1.0"
Importance #58: tests/test_cluster_io.py @@ -1,19 +1,16 @@
1import dataclasses1import dataclasses
2import logging2import logging
3import pathlib
3import zipfile4import zipfile
4from pathlib import Path
5from typing import BinaryIO5from typing import BinaryIO
66
7import numpy as np7import numpy as np
8import pytest8import pytest
9from iolabs.common import atomic_io9from iolabs.common import atomic_io, color_intensity_data
10from iolabs.common.color_intensity_data import ColorIntensityData10from iolabs_point_cloud_filtering_clusters import clustering_gpu_io
11from iolabs_point_cloud_filtering_clusters.clustering_gpu_io import load_cluster_artifact_npz
1211
13from iolabs_point_cloud_mask_clustering import cluster_io12from iolabs_point_cloud_mask_clustering import cluster_io, types
14from iolabs_point_cloud_mask_clustering.cluster_io import write_cluster_npz
15from iolabs_point_cloud_mask_clustering.types import PointChannels, SegmentType
1613
17FIXED_MEMBERS = {14FIXED_MEMBERS = {
18 "points",15 "points",
19 "scan_angle",16 "scan_angle",
Importance #59: tests/test_cluster_io.py @@ -43,13 +40,13 @@
43 scan_angle: np.ndarray = dataclasses.field(default_factory=lambda: np.array([7, 8]))40 scan_angle: np.ndarray = dataclasses.field(default_factory=lambda: np.array([7, 8]))
4441
4542
46def _installed_channel_fields() -> set[str]:43def _installed_channel_fields() -> set[str]:
47 return {field.name for field in dataclasses.fields(ColorIntensityData)}44 return {field.name for field in dataclasses.fields(color_intensity_data.ColorIntensityData)}
4845
4946
50def _stub_channels(stub: type) -> PointChannels:47def _stub_channels(stub: type) -> types.PointChannels:
51 return PointChannels(48 return types.PointChannels(
52 points=np.array([[1.123456789, 2, 3], [4, 5, 6]], dtype=np.float64),49 points=np.array([[1.123456789, 2, 3], [4, 5, 6]], dtype=np.float64),
53 data=stub(50 data=stub(
54 red=np.array([1.6, 2.4]),51 red=np.array([1.6, 2.4]),
55 green=np.array([3.5, 4.5]),52 green=np.array([3.5, 4.5]),
Importance #60: tests/test_cluster_io.py @@ -59,12 +56,12 @@
59 ),56 ),
60 )57 )
6158
6259
63def _sample() -> PointChannels:60def _sample() -> types.PointChannels:
64 return PointChannels(61 return types.PointChannels(
65 points=np.array([[1.123456789, 2, 3], [4, 5, 6]], dtype=np.float64),62 points=np.array([[1.123456789, 2, 3], [4, 5, 6]], dtype=np.float64),
66 data=ColorIntensityData(63 data=color_intensity_data.ColorIntensityData(
67 scan_angle_rank=np.array([-200.1, 127.4]),64 scan_angle_rank=np.array([-200.1, 127.4]),
68 intensity=np.array([-1.0, 70000.0]),65 intensity=np.array([-1.0, 70000.0]),
69 red=np.array([1.6, 2.4]),66 red=np.array([1.6, 2.4]),
70 green=np.array([3.5, 4.5]),67 green=np.array([3.5, 4.5]),
Importance #61: tests/test_cluster_io.py @@ -72,12 +69,12 @@
72 ),69 ),
73 )70 )
7471
7572
76def test_writes_step6_compatible_dtypes_and_segment_type(tmp_path: Path) -> None:73def test_writes_step6_compatible_dtypes_and_segment_type(tmp_path: pathlib.Path) -> None:
77 path = tmp_path / "run6_cluster_000.npz"74 path = tmp_path / "run6_cluster_000.npz"
78 source = _sample()75 source = _sample()
79 write_cluster_npz(path, source, SegmentType.DASHED)76 cluster_io.write_cluster_npz(path, source, types.SegmentType.DASHED)
80 with np.load(path) as payload:77 with np.load(path) as payload:
81 assert payload["points"].dtype == np.float6478 assert payload["points"].dtype == np.float64
82 assert payload["scan_angle"].dtype == np.int879 assert payload["scan_angle"].dtype == np.int8
83 assert payload["intensity"].dtype == np.uint1680 assert payload["intensity"].dtype == np.uint16
Importance #62: tests/test_cluster_io.py @@ -87,38 +84,38 @@
87 np.testing.assert_array_equal(payload["scan_angle"], [-128, 127])84 np.testing.assert_array_equal(payload["scan_angle"], [-128, 127])
88 np.testing.assert_array_equal(payload["intensity"], [0, 65535])85 np.testing.assert_array_equal(payload["intensity"], [0, 65535])
89 np.testing.assert_array_equal(payload["red"], [2, 2])86 np.testing.assert_array_equal(payload["red"], [2, 2])
90 np.testing.assert_array_equal(payload["green"], [4, 4])87 np.testing.assert_array_equal(payload["green"], [4, 4])
91 artifact = load_cluster_artifact_npz(path)88 artifact = clustering_gpu_io.load_cluster_artifact_npz(path)
92 np.testing.assert_array_equal(artifact.points, source.points)89 np.testing.assert_array_equal(artifact.points, source.points)
9390
9491
95def test_members_are_stored_not_deflated(tmp_path: Path) -> None:92def test_members_are_stored_not_deflated(tmp_path: pathlib.Path) -> None:
96 # Tablecloth chunk-streams cluster archives, which only works for STORED members.93 # Tablecloth chunk-streams cluster archives, which only works for STORED members.
97 path = tmp_path / "run6_cluster_000.npz"94 path = tmp_path / "run6_cluster_000.npz"
98 write_cluster_npz(path, _sample(), SegmentType.SOLID)95 cluster_io.write_cluster_npz(path, _sample(), types.SegmentType.SOLID)
99 with zipfile.ZipFile(path) as archive:96 with zipfile.ZipFile(path) as archive:
100 methods = {info.filename: info.compress_type for info in archive.infolist()}97 methods = {info.filename: info.compress_type for info in archive.infolist()}
101 assert methods98 assert methods
102 assert set(methods.values()) == {zipfile.ZIP_STORED}99 assert set(methods.values()) == {zipfile.ZIP_STORED}
103100
104101
105def test_five_field_channels_write_only_the_fixed_members(tmp_path: Path) -> None:102def test_five_field_channels_write_only_the_fixed_members(tmp_path: pathlib.Path) -> None:
106 # A ColorIntensityData declaring only the explicit five (iolabs-common 0.7.0)103 # A ColorIntensityData declaring only the explicit five (iolabs-common 0.7.0)
107 # must produce exactly the pre-AI3D-382 member set: no generic extras.104 # must produce exactly the pre-AI3D-382 member set: no generic extras.
108 path = tmp_path / "run6_cluster_000.npz"105 path = tmp_path / "run6_cluster_000.npz"
109 write_cluster_npz(path, _stub_channels(_FiveChannels), SegmentType.SOLID)106 cluster_io.write_cluster_npz(path, _stub_channels(_FiveChannels), types.SegmentType.SOLID)
110 with np.load(path) as payload:107 with np.load(path) as payload:
111 assert set(payload.files) == FIXED_MEMBERS108 assert set(payload.files) == FIXED_MEMBERS
112 assert payload["scan_angle"].dtype == np.int8109 assert payload["scan_angle"].dtype == np.int8
113 np.testing.assert_array_equal(payload["scan_angle"], [-128, 127])110 np.testing.assert_array_equal(payload["scan_angle"], [-128, 127])
114111
115112
116def test_member_set_follows_the_installed_channel_schema(tmp_path: Path) -> None:113def test_member_set_follows_the_installed_channel_schema(tmp_path: pathlib.Path) -> None:
117 # Holds on any iolabs-common: the extras are exactly the installed114 # Holds on any iolabs-common: the extras are exactly the installed
118 # dataclass's non-explicit init fields.115 # dataclass's non-explicit init fields.
119 path = tmp_path / "run6_cluster_000.npz"116 path = tmp_path / "run6_cluster_000.npz"
120 write_cluster_npz(path, _sample(), SegmentType.SOLID)117 cluster_io.write_cluster_npz(path, _sample(), types.SegmentType.SOLID)
121 expected_extras = _installed_channel_fields() - cluster_io.EXPLICIT_CHANNEL_FIELDS118 expected_extras = _installed_channel_fields() - cluster_io.EXPLICIT_CHANNEL_FIELDS
122 with np.load(path) as payload:119 with np.load(path) as payload:
123 assert set(payload.files) == FIXED_MEMBERS | expected_extras120 assert set(payload.files) == FIXED_MEMBERS | expected_extras
124121
Importance #63: tests/test_cluster_io.py @@ -126,13 +123,13 @@
126@pytest.mark.skipif(123@pytest.mark.skipif(
127 "number_of_returns" not in _installed_channel_fields(),124 "number_of_returns" not in _installed_channel_fields(),
128 reason="installed ColorIntensityData predates number_of_returns",125 reason="installed ColorIntensityData predates number_of_returns",
129)126)
130def test_number_of_returns_is_forwarded_aligned_with_points(tmp_path: Path) -> None:127def test_number_of_returns_is_forwarded_aligned_with_points(tmp_path: pathlib.Path) -> None:
131 source = _sample()128 source = _sample()
132 source.data.number_of_returns = np.array([1, 3], dtype=np.uint8)129 source.data.number_of_returns = np.array([1, 3], dtype=np.uint8)
133 path = tmp_path / "run6_cluster_000.npz"130 path = tmp_path / "run6_cluster_000.npz"
134 write_cluster_npz(path, source, SegmentType.DASHED)131 cluster_io.write_cluster_npz(path, source, types.SegmentType.DASHED)
135 with np.load(path) as payload:132 with np.load(path) as payload:
136 returns = payload["number_of_returns"]133 returns = payload["number_of_returns"]
137 assert returns.dtype == np.uint8134 assert returns.dtype == np.uint8
138 assert len(returns) == len(source.points)135 assert len(returns) == len(source.points)
Importance #64: tests/test_cluster_io.py @@ -142,23 +139,25 @@
142@pytest.mark.skipif(139@pytest.mark.skipif(
143 "number_of_returns" not in _installed_channel_fields(),140 "number_of_returns" not in _installed_channel_fields(),
144 reason="installed ColorIntensityData predates number_of_returns",141 reason="installed ColorIntensityData predates number_of_returns",
145)142)
146def test_omitted_number_of_returns_is_forwarded_as_unknown_zeros(tmp_path: Path) -> None:143def test_omitted_number_of_returns_is_forwarded_as_unknown_zeros(tmp_path: pathlib.Path) -> None:
147 path = tmp_path / "run6_cluster_000.npz"144 path = tmp_path / "run6_cluster_000.npz"
148 write_cluster_npz(path, _sample(), SegmentType.DASHED)145 cluster_io.write_cluster_npz(path, _sample(), types.SegmentType.DASHED)
149 with np.load(path) as payload:146 with np.load(path) as payload:
150 returns = payload["number_of_returns"]147 returns = payload["number_of_returns"]
151 assert returns.dtype == np.uint8148 assert returns.dtype == np.uint8
152 np.testing.assert_array_equal(returns, [0, 0])149 np.testing.assert_array_equal(returns, [0, 0])
153150
154151
155def test_field_colliding_with_a_fixed_member_is_skipped_with_a_warning(152def test_field_colliding_with_a_fixed_member_is_skipped_with_a_warning(
156 tmp_path: Path, caplog: pytest.LogCaptureFixture153 tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
157) -> None:154) -> None:
158 path = tmp_path / "run6_cluster_000.npz"155 path = tmp_path / "run6_cluster_000.npz"
159 with caplog.at_level(logging.WARNING, logger=cluster_io.__name__):156 with caplog.at_level(logging.WARNING, logger=cluster_io.__name__):
160 write_cluster_npz(path, _stub_channels(_CollidingChannels), SegmentType.SOLID)157 cluster_io.write_cluster_npz(
158 path, _stub_channels(_CollidingChannels), types.SegmentType.SOLID
159 )
161 assert any("scan_angle" in record.getMessage() for record in caplog.records)160 assert any("scan_angle" in record.getMessage() for record in caplog.records)
162 with np.load(path) as payload:161 with np.load(path) as payload:
163 assert set(payload.files) == FIXED_MEMBERS162 assert set(payload.files) == FIXED_MEMBERS
164 # The fixed member survived: it is the clipped scan_angle_rank, not [7, 8].163 # The fixed member survived: it is the clipped scan_angle_rank, not [7, 8].
Importance #65: tests/test_cluster_io.py @@ -166,9 +165,9 @@
166 np.testing.assert_array_equal(payload["scan_angle"], [-128, 127])165 np.testing.assert_array_equal(payload["scan_angle"], [-128, 127])
167166
168167
169def test_atomic_failure_leaves_no_partial_output(168def test_atomic_failure_leaves_no_partial_output(
170 tmp_path: Path, monkeypatch: pytest.MonkeyPatch169 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
171) -> None:170) -> None:
172 path = tmp_path / "run6_cluster_000.npz"171 path = tmp_path / "run6_cluster_000.npz"
173172
174 def fail(handle: BinaryIO, **payload: np.ndarray) -> None:173 def fail(handle: BinaryIO, **payload: np.ndarray) -> None:
Importance #66: tests/test_cluster_io.py @@ -176,7 +175,7 @@
176 raise RuntimeError("disk failure")175 raise RuntimeError("disk failure")
177176
178 monkeypatch.setattr(atomic_io.np, "savez", fail)177 monkeypatch.setattr(atomic_io.np, "savez", fail)
179 with pytest.raises(RuntimeError):178 with pytest.raises(RuntimeError):
180 write_cluster_npz(path, _sample(), SegmentType.SOLID)179 cluster_io.write_cluster_npz(path, _sample(), types.SegmentType.SOLID)
181 assert not path.exists()180 assert not path.exists()
182 assert list(tmp_path.iterdir()) == []181 assert list(tmp_path.iterdir()) == []