Back to report index

Step 6 maskclustering 9354e90: AI3D-379 Pydantic config models via iolabs-common ConfigModel

Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:37:12+02:00

Commit #37 ยท 51 snippets

 AGENTS.md                                          |   5 +-
 README.md                                          |  12 +
 pyproject.toml                                     |   3 +-
 src/iolabs_point_cloud_mask_clustering/_config.py  | 323 ++++++++-------------
 src/iolabs_point_cloud_mask_clustering/cli.py      |  14 +-
 src/iolabs_point_cloud_mask_clustering/overlay.py  |   8 +-
 src/iolabs_point_cloud_mask_clustering/pipeline.py |  17 +-
 tests/test_config.py                               |  67 +++++
 uv.lock                                            |   4 +-
 9 files changed, 229 insertions(+), 224 deletions(-)
Importance #1: src/iolabs_point_cloud_mask_clustering/_config.py @@ -32,93 +30,112 @@
32PACKAGE = "iolabs_point_cloud_mask_clustering"30PACKAGE = "iolabs_point_cloud_mask_clustering"
33DEFAULT_CONFIG_FILENAME = "mask_clustering.default.json"31DEFAULT_CONFIG_FILENAME = "mask_clustering.default.json"
3432
3533
36class MaskClusteringConfigError(ConfigError):34class MaskClusteringConfigError(config_loader.ConfigError):
37 """Raised when mask-clustering configuration is invalid."""35 """Raised when mask-clustering configuration is invalid."""
3836
3937
40@dataclass(frozen=True)38class MaskConfig(config_loader.ConfigModel):
41class MaskConfig:
42 """Mask rasterisation and labelling settings."""39 """Mask rasterisation and labelling settings."""
4340
44 background_class: int41 background_class: int = 0
45 solid_class: int42 solid_class: int = 1
46 dashed_class: int43 dashed_class: int = 2
47 connectivity: int44 connectivity: Literal[4, 8] = 8
48 vector_stroke_px: int45 vector_stroke_px: int = pydantic.Field(default=4, ge=1)
4946
5047
51@dataclass(frozen=True)48class ClustersConfig(config_loader.ConfigModel):
52class ClustersConfig:
53 """Sparse-cluster thresholds."""49 """Sparse-cluster thresholds."""
5450
55 min_points_per_cluster: int51 min_points_per_cluster: int = pydantic.Field(default=20, ge=0)
56 warn_below_points: int52 warn_below_points: int = pydantic.Field(default=200, ge=0)
57
5853
59@dataclass(frozen=True)54 @pydantic.model_validator(mode="after")
60class RasterFrameConfig:55 def _check_thresholds(self) -> "ClustersConfig":
56 """Reject a minimum above the warning threshold."""
57 if self.min_points_per_cluster > self.warn_below_points:
58 raise ValueError(
59 "clusters thresholds must satisfy "
60 "0 <= min_points_per_cluster <= warn_below_points"
61 )
62 return self
63
64
65class IntensitySeparationConfig(config_loader.ConfigModel):
66 """Paint/asphalt intensity-separation settings."""
67
68 enabled: bool = True
69 apply_filter: bool = True
70 save_padded_clusters: bool = False
71 attribute: Literal["intensity"] = "intensity"
72 dilation_px: int = pydantic.Field(default=4, ge=0)
73 core_center_fraction: float = pydantic.Field(default=0.85, ge=0.0, lt=1.0)
74 rim_edge_fraction: float = pydantic.Field(default=0.8, ge=0.0, lt=1.0)
75 n_anchors: int = pydantic.Field(default=15, ge=1)
76 search_radius_m: float = pydantic.Field(default=0.04, gt=0.0)
77 min_samples: int = pydantic.Field(default=50, ge=1)
78 min_median_gap_abs: float = pydantic.Field(default=0.0, ge=0.0)
79 min_median_gap_mads: float = pydantic.Field(default=2.0, ge=0.0)
80 overlap_percentile: float = pydantic.Field(default=20.0, gt=0.0, lt=50.0)
81 n_bins: int = pydantic.Field(default=64, ge=0)
82 device: Literal["cpu", "cuda"] = "cpu"
83 seed: int = pydantic.Field(default=0, ge=0)
84 clusters_per_page: int = pydantic.Field(default=3, ge=1)
85 pdf_filename: str = "intensity_separation.pdf"
86
87
88class RasterFrameConfig(config_loader.ConfigModel):
61 """Tolerances used when reconstructing the raster frame."""89 """Tolerances used when reconstructing the raster frame."""
6290
63 margin_pixels: float91 margin_pixels: float = pydantic.Field(default=1.0, ge=0.0)
64 metadata_origin_tolerance_pixels: float92 metadata_origin_tolerance_pixels: float = pydantic.Field(default=0.25, ge=0.0)
6593
6694
67@dataclass(frozen=True)95class GeometryConfig(config_loader.ConfigModel):
68class GeometryConfig:
69 """Which diagnostic geometry artifacts to write."""96 """Which diagnostic geometry artifacts to write."""
7097
71 write_geojson: bool98 write_geojson: bool = True
72 write_ply: bool99 write_ply: bool = True
73 simplify_tolerance_px: float100 simplify_tolerance_px: float = 0.0
74101
75102
76@dataclass(frozen=True)103class OutputConfig(config_loader.ConfigModel):
77class OutputConfig:
78 """Output directory and file-name layout."""104 """Output directory and file-name layout."""
79105
80 cluster_dir: str106 cluster_dir: str = "clusters_mask"
81 cluster_prefix: str107 cluster_prefix: str = "run6_cluster_"
82 geometry_dir: str108 geometry_dir: str = "mask_geometry"
83 manifest_filename: str109 manifest_filename: str = "mask_clustering_manifest.json"
84110
85111
86@dataclass(frozen=True)112class FileNamingConfig(config_loader.ConfigModel):
87class FileNamingConfig:
88 """How Step 3 inputs are discovered inside a segment directory."""113 """How Step 3 inputs are discovered inside a segment directory."""
89114
90 segment_points_suffix: str115 segment_points_suffix: str = "_run3_points.npz"
91
92116
93@dataclass(frozen=True)
94class MaskClusteringConfig:
95 """Typed view over a normalized configuration mapping.
96117
97 ``intensity_separation`` stays a mapping: it is consumed key-by-key deep118class MaskClusteringConfig(config_loader.ConfigModel):
98 inside :mod:`.intensity_separation`, where a mechanical field-by-field119 """Typed, validated mask-clustering configuration.
99 conversion would buy nothing. ``raw`` is the normalized mapping the manifest
100 records verbatim.
101120
102 Attributes:121 Attributes:
103 mask: Mask rasterisation and labelling settings.122 mask: Mask rasterisation and labelling settings.
104 clusters: Sparse-cluster thresholds.123 clusters: Sparse-cluster thresholds.
124 intensity_separation: Intensity-separation settings.
105 raster_frame: Raster-frame reconstruction tolerances.125 raster_frame: Raster-frame reconstruction tolerances.
106 geometry: Diagnostic geometry toggles.126 geometry: Diagnostic geometry toggles.
107 output: Output directory and file-name layout.127 output: Output directory and file-name layout.
108 file_naming: Step 3 input discovery settings.128 file_naming: Step 3 input discovery settings.
109 intensity_separation: Intensity-separation settings, untyped.
110 raw: The normalized configuration mapping.
111 """129 """
112130
113 mask: MaskConfig131 mask: MaskConfig = MaskConfig()
114 clusters: ClustersConfig132 clusters: ClustersConfig = ClustersConfig()
115 raster_frame: RasterFrameConfig133 intensity_separation: IntensitySeparationConfig = IntensitySeparationConfig()
116 geometry: GeometryConfig134 raster_frame: RasterFrameConfig = RasterFrameConfig()
117 output: OutputConfig135 geometry: GeometryConfig = GeometryConfig()
118 file_naming: FileNamingConfig136 output: OutputConfig = OutputConfig()
119 intensity_separation: dict[str, Any]137 file_naming: FileNamingConfig = FileNamingConfig()
120 raw: dict[str, Any]
121138
122 @classmethod139 @classmethod
123 def coerce(cls, config: "MaskClusteringConfig | dict[str, Any]") -> "MaskClusteringConfig":140 def coerce(cls, config: "MaskClusteringConfig | dict[str, Any]") -> "MaskClusteringConfig":
124 """Return *config* as a typed configuration, converting a mapping if needed.141 """Return *config* as a typed configuration, converting a mapping if needed.
Importance #2: src/iolabs_point_cloud_mask_clustering/_config.py @@ -286,15 +214,9 @@
286214
287 Raises:215 Raises:
288 MaskClusteringConfigError: An unknown key or an out-of-range value.216 MaskClusteringConfigError: An unknown key or an out-of-range value.
289 """217 """
290 defaults = _defaults()218 return _load_model(overrides=raw).model_dump()
291 validate_against_defaults(
292 raw, defaults, context="config", error_cls=MaskClusteringConfigError
293 )
294 config = deep_merge_dicts(defaults, raw)
295 _validate_values(config)
296 return config
297219
298220
299def load_config(config_path: str | Path | None = None) -> dict[str, Any]:221def load_config(config_path: str | Path | None = None) -> dict[str, Any]:
300 """Load a configuration JSON, or the packaged defaults when *config_path* is None.222 """Load a configuration JSON, or the packaged defaults when *config_path* is None.
Importance #3: src/iolabs_point_cloud_mask_clustering/_config.py @@ -305,15 +227,12 @@
305 Returns:227 Returns:
306 The merged, validated configuration.228 The merged, validated configuration.
307229
308 Raises:230 Raises:
309 MaskClusteringConfigError: An unknown key or an out-of-range value.231 MaskClusteringConfigError: A malformed config file, an unknown key or an
232 out-of-range value.
310 """233 """
311 if config_path is None:234 return _load_model(overrides=_read_overrides(config_path)).model_dump()
312 return normalize_config({})
313 with Path(config_path).open(encoding="utf-8") as handle:
314 raw = json.load(handle)
315 return normalize_config(raw)
316235
317236
318def build_config(237def build_config(
319 *,238 *,
Importance #4: src/iolabs_point_cloud_mask_clustering/_config.py @@ -1,29 +1,27 @@
1"""Load, merge, validate and type the mask-clustering configuration.1"""Load, merge, validate and type the mask-clustering configuration.
22
3The packaged JSON default is the schema: unknown keys fail, and every value is3The pydantic model tree below is the schema and mirrors the packaged JSON
4range-checked here rather than at the point of use. Loading and deep-merging are4default exactly: unknown keys fail, and every value is range-checked here rather
5delegated to :mod:`iolabs.common.config_loader`; the coercion and range checks5than at the point of use. Loading, deep-merging and validation are delegated to
6stay local because they encode this step's invariants.6:mod:`iolabs.common.config_loader`.
77
8:func:`load_config` / :func:`build_config` keep returning plain dicts, because8:func:`load_config` / :func:`build_config` keep returning plain dicts, because
9callers pass ``--set``-style overrides around as dicts and the run manifest9callers pass ``--set``-style overrides around as dicts and the run manifest
10embeds the normalized mapping verbatim. :class:`MaskClusteringConfig` is the10embeds the normalized mapping verbatim. :class:`MaskClusteringConfig` is the
11typed view the pipeline and the CLI actually read, so no production code path11typed view the pipeline and the CLI actually read, so no production code path
12indexes nested config dicts by string.12indexes nested config dicts by string.
13
14Adding a config key means adding the field to the model here and the same key to
15``mask_clustering.default.json`` โ€” nothing else.
13"""16"""
1417
15import json18import json
16from dataclasses import dataclass
17from pathlib import Path19from pathlib import Path
18from typing import Any20from typing import Any, Literal
1921
20from iolabs.common.config_loader import (22import pydantic
21 ConfigError,23from iolabs.common import config_loader
22 deep_merge_dicts,
23 load_packaged_json,
24 validate_against_defaults,
25)
26from iolabs.logstash import get_props_logger24from iolabs.logstash import get_props_logger
2725
28from ._log_props import LOG_PROPS26from ._log_props import LOG_PROPS
2927
Importance #5: src/iolabs_point_cloud_mask_clustering/_config.py @@ -137,9 +154,9 @@
137 return cls.from_mapping(config)154 return cls.from_mapping(config)
138155
139 @classmethod156 @classmethod
140 def from_mapping(cls, config: dict[str, Any]) -> "MaskClusteringConfig":157 def from_mapping(cls, config: dict[str, Any]) -> "MaskClusteringConfig":
141 """Build the typed view, normalizing *config* first if needed.158 """Build the typed view, merging *config* onto the packaged defaults.
142159
143 Args:160 Args:
144 config: A raw or already-normalized configuration mapping.161 config: A raw or already-normalized configuration mapping.
145162
Importance #6: src/iolabs_point_cloud_mask_clustering/_config.py @@ -148,132 +165,43 @@
148165
149 Raises:166 Raises:
150 MaskClusteringConfigError: The mapping is not a valid configuration.167 MaskClusteringConfigError: The mapping is not a valid configuration.
151 """168 """
152 normalized = normalize_config(config)169 return _load_model(overrides=config)
153 mask = normalized["mask"]170
154 clusters = normalized["clusters"]171
155 frame = normalized["raster_frame"]172def _load_model(overrides: dict[str, Any] | None = None) -> MaskClusteringConfig:
156 geometry = normalized["geometry"]173 """Merge *overrides* onto the packaged defaults and validate the result."""
157 output = normalized["output"]174 return config_loader.load_config(
158 naming = normalized["file_naming"]175 MaskClusteringConfig,
159 return cls(176 package=PACKAGE,
160 mask=MaskConfig(177 filename=DEFAULT_CONFIG_FILENAME,
161 background_class=int(mask["background_class"]),178 overrides=overrides,
162 solid_class=int(mask["solid_class"]),179 context="config",
163 dashed_class=int(mask["dashed_class"]),180 error_cls=MaskClusteringConfigError,
164 connectivity=int(mask["connectivity"]),181 )
165 vector_stroke_px=int(mask["vector_stroke_px"]),
166 ),
167 clusters=ClustersConfig(
168 min_points_per_cluster=int(clusters["min_points_per_cluster"]),
169 warn_below_points=int(clusters["warn_below_points"]),
170 ),
171 raster_frame=RasterFrameConfig(
172 margin_pixels=float(frame["margin_pixels"]),
173 metadata_origin_tolerance_pixels=float(
174 frame["metadata_origin_tolerance_pixels"]
175 ),
176 ),
177 geometry=GeometryConfig(
178 write_geojson=bool(geometry["write_geojson"]),
179 write_ply=bool(geometry["write_ply"]),
180 simplify_tolerance_px=float(geometry["simplify_tolerance_px"]),
181 ),
182 output=OutputConfig(
183 cluster_dir=str(output["cluster_dir"]),
184 cluster_prefix=str(output["cluster_prefix"]),
185 geometry_dir=str(output["geometry_dir"]),
186 manifest_filename=str(output["manifest_filename"]),
187 ),
188 file_naming=FileNamingConfig(
189 segment_points_suffix=str(naming["segment_points_suffix"]),
190 ),
191 intensity_separation=dict(normalized["intensity_separation"]),
192 raw=normalized,
193 )
194182
195183
196def _validate_values(config: dict[str, Any]) -> None:184def _read_overrides(config_path: str | Path | None) -> dict[str, Any]:
197 mask = config["mask"]185 """Read a JSON override file, or return an empty mapping when there is none.
198 clusters = config["clusters"]
199 frame = config["raster_frame"]
200 if mask["connectivity"] not in {4, 8}:
201 raise MaskClusteringConfigError("mask.connectivity must be 4 or 8")
202 if int(mask["vector_stroke_px"]) < 1:
203 raise MaskClusteringConfigError("mask.vector_stroke_px must be >= 1")
204 minimum = int(clusters["min_points_per_cluster"])
205 warning = int(clusters["warn_below_points"])
206 if not 0 <= minimum <= warning:
207 raise MaskClusteringConfigError(
208 "clusters thresholds must satisfy 0 <= min_points_per_cluster <= warn_below_points"
209 )
210 if float(frame["margin_pixels"]) < 0:
211 raise MaskClusteringConfigError("raster_frame.margin_pixels must be >= 0")
212 if float(frame["metadata_origin_tolerance_pixels"]) < 0:
213 raise MaskClusteringConfigError(
214 "raster_frame.metadata_origin_tolerance_pixels must be >= 0"
215 )
216 _validate_intensity_separation(config["intensity_separation"])
217186
218187 Raises:
219def _validate_intensity_separation(separation: dict[str, Any]) -> None:188 MaskClusteringConfigError: The file is not valid JSON, or does not hold
220 if separation["attribute"] != "intensity":189 a JSON object.
221 raise MaskClusteringConfigError(190 """
222 "intensity_separation.attribute must be 'intensity'"191 if config_path is None:
223 )192 return {}
224 if separation["device"] not in {"cpu", "cuda"}:193 path = Path(config_path)
225 raise MaskClusteringConfigError(194 try:
226 "intensity_separation.device must be 'cpu' or 'cuda'"195 with path.open(encoding="utf-8") as handle:
227 )196 loaded = json.load(handle)
228 non_negative_ints = (197 except json.JSONDecodeError as exc:
229 "dilation_px",198 raise MaskClusteringConfigError(f"Invalid JSON in config file {path}: {exc}") from exc
230 "n_anchors",199 if not isinstance(loaded, dict):
231 "n_bins",
232 "seed",
233 )
234 for key in non_negative_ints:
235 if int(separation[key]) < 0:
236 raise MaskClusteringConfigError(
237 f"intensity_separation.{key} must be >= 0"
238 )
239 for key in ("core_center_fraction", "rim_edge_fraction"):
240 if not 0.0 <= float(separation[key]) < 1.0:
241 raise MaskClusteringConfigError(
242 f"intensity_separation.{key} must be in [0, 1)"
243 )
244 if int(separation["min_samples"]) < 1:
245 raise MaskClusteringConfigError(
246 "intensity_separation.min_samples must be >= 1"
247 )
248 if int(separation["clusters_per_page"]) < 1:
249 raise MaskClusteringConfigError(
250 "intensity_separation.clusters_per_page must be >= 1"
251 )
252 if int(separation["n_anchors"]) < 1:
253 raise MaskClusteringConfigError(
254 "intensity_separation.n_anchors must be >= 1"
255 )
256 if float(separation["search_radius_m"]) <= 0:
257 raise MaskClusteringConfigError(
258 "intensity_separation.search_radius_m must be > 0"
259 )
260 if float(separation["min_median_gap_abs"]) < 0:
261 raise MaskClusteringConfigError(
262 "intensity_separation.min_median_gap_abs must be >= 0"
263 )
264 if float(separation["min_median_gap_mads"]) < 0:
265 raise MaskClusteringConfigError(
266 "intensity_separation.min_median_gap_mads must be >= 0"
267 )
268 if not 0 < float(separation["overlap_percentile"]) < 50:
269 raise MaskClusteringConfigError(200 raise MaskClusteringConfigError(
270 "intensity_separation.overlap_percentile must be in (0, 50)"201 f"Config file {path} must hold a JSON object, got {type(loaded).__name__}"
271 )202 )
272203 return loaded
273
274def _defaults() -> dict[str, Any]:
275 return load_packaged_json(PACKAGE, DEFAULT_CONFIG_FILENAME)
276204
277205
278def normalize_config(raw: dict[str, Any]) -> dict[str, Any]:206def normalize_config(raw: dict[str, Any]) -> dict[str, Any]:
279 """Merge *raw* onto the packaged defaults and validate the result.207 """Merge *raw* onto the packaged defaults and validate the result.
Importance #7: src/iolabs_point_cloud_mask_clustering/_config.py @@ -329,13 +248,11 @@
329 Returns:248 Returns:
330 The merged, validated configuration.249 The merged, validated configuration.
331250
332 Raises:251 Raises:
333 MaskClusteringConfigError: An unknown key or an out-of-range value.252 MaskClusteringConfigError: A malformed config file, an unknown key or an
253 out-of-range value.
334 """254 """
335 config = load_config(config_path)255 merged = config_loader.deep_merge_dicts(
336 if overrides:256 _read_overrides(config_path), dict(overrides or {})
337 validate_against_defaults(257 )
338 overrides, _defaults(), context="config", error_cls=MaskClusteringConfigError258 return _load_model(overrides=merged).model_dump()
339 )
340 config = deep_merge_dicts(config, overrides)
341 return normalize_config(config)
Importance #8: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -380,9 +383,9 @@
380 )383 )
381384
382 separation_pdf_relative: str | None = None385 separation_pdf_relative: str | None = None
383 if separation_enabled:386 if separation_enabled:
384 separation_pdf_path = output_dir / str(separation_cfg["pdf_filename"])387 separation_pdf_path = output_dir / separation_model.pdf_filename
385 intensity_separation.write_separation_pdf(separations, separation_pdf_path, separation_cfg)388 intensity_separation.write_separation_pdf(separations, separation_pdf_path, separation_cfg)
386 separation_pdf_relative = separation_pdf_path.relative_to(output_dir).as_posix()389 separation_pdf_relative = separation_pdf_path.relative_to(output_dir).as_posix()
387390
388 versions_path = output_dir / "run6c_versions.json"391 versions_path = output_dir / "run6c_versions.json"
Importance #9: src/iolabs_point_cloud_mask_clustering/cli.py @@ -5,13 +5,13 @@
5import sys5import sys
6from pathlib import Path6from pathlib import Path
7from typing import Any7from typing import Any
88
9from iolabs.common.config_loader import parse_set_overrides as _parse_set_overrides9from iolabs.common import config_loader
10from iolabs.common.run_stats import read_stats10from iolabs.common.run_stats import read_stats
11from iolabs.logstash import get_props_logger11from iolabs.logstash import get_props_logger
1212
13from ._config import MaskClusteringConfig, build_config13from . import _config
14from ._log_props import LOG_PROPS14from ._log_props import LOG_PROPS
15from .pipeline import process_segment15from .pipeline import process_segment
1616
17logger = get_props_logger(__name__, LOG_PROPS)17logger = get_props_logger(__name__, LOG_PROPS)
Importance #10: src/iolabs_point_cloud_mask_clustering/cli.py @@ -71,9 +71,9 @@
71 ValueError: An argument is not ``section.key=value`` (message:71 ValueError: An argument is not ``section.key=value`` (message:
72 ``Invalid --set override '...'. Expected SECTION.KEY=VALUE.``), or72 ``Invalid --set override '...'. Expected SECTION.KEY=VALUE.``), or
73 two arguments disagree about whether a path segment is a section.73 two arguments disagree about whether a path segment is a section.
74 """74 """
75 return _parse_set_overrides(values, nested=True, error_cls=ValueError)75 return config_loader.parse_set_overrides(values, nested=True, error_cls=ValueError)
7676
7777
78def _load_jobs(path: Path) -> list[dict[str, Any]]:78def _load_jobs(path: Path) -> list[dict[str, Any]]:
79 with path.open(encoding="utf-8") as handle:79 with path.open(encoding="utf-8") as handle:
Importance #11: src/iolabs_point_cloud_mask_clustering/cli.py @@ -118,10 +118,10 @@
118 return False118 return False
119119
120120
121def _run_segment(args: argparse.Namespace) -> int:121def _run_segment(args: argparse.Namespace) -> int:
122 config = MaskClusteringConfig.coerce(122 config = _config.MaskClusteringConfig.coerce(
123 build_config(123 _config.build_config(
124 config_path=args.config,124 config_path=args.config,
125 overrides=parse_set_overrides(args.sets),125 overrides=parse_set_overrides(args.sets),
126 )126 )
127 )127 )
Importance #12: src/iolabs_point_cloud_mask_clustering/cli.py @@ -140,10 +140,10 @@
140140
141def _run_batch(args: argparse.Namespace) -> int:141def _run_batch(args: argparse.Namespace) -> int:
142 if args.overwrite and args.skip_existing:142 if args.overwrite and args.skip_existing:
143 raise ValueError("--overwrite and --skip-existing are mutually exclusive")143 raise ValueError("--overwrite and --skip-existing are mutually exclusive")
144 config = MaskClusteringConfig.coerce(144 config = _config.MaskClusteringConfig.coerce(
145 build_config(145 _config.build_config(
146 config_path=args.config,146 config_path=args.config,
147 overrides=parse_set_overrides(args.sets),147 overrides=parse_set_overrides(args.sets),
148 )148 )
149 )149 )
Importance #13: src/iolabs_point_cloud_mask_clustering/overlay.py @@ -18,9 +18,9 @@
1818
19import cv219import cv2
20import numpy as np20import numpy as np
2121
22from ._config import MaskClusteringConfig, load_config22from . import _config
23from .geometry import mask_polygons_px23from .geometry import mask_polygons_px
24from .mask_components import label_components, load_classified_mask24from .mask_components import label_components, load_classified_mask
25from .types import MaskComponent, SegmentType25from .types import MaskComponent, SegmentType
2626
Importance #14: src/iolabs_point_cloud_mask_clustering/overlay.py @@ -131,9 +131,9 @@
131 source_path: str | Path,131 source_path: str | Path,
132 base_path: str | Path,132 base_path: str | Path,
133 *,133 *,
134 base: str = "intensity",134 base: str = "intensity",
135 config: MaskClusteringConfig | dict[str, Any] | None = None,135 config: _config.MaskClusteringConfig | dict[str, Any] | None = None,
136 config_path: str | Path | None = None,136 config_path: str | Path | None = None,
137 draw_mask_fill: bool = True,137 draw_mask_fill: bool = True,
138 mask_fill_alpha: float = 0.4,138 mask_fill_alpha: float = 0.4,
139 border_thickness: int = 2,139 border_thickness: int = 2,
Importance #15: src/iolabs_point_cloud_mask_clustering/overlay.py @@ -147,10 +147,10 @@
147 ``source_path`` is a vectors JSON or a single-channel mask PNG (anything147 ``source_path`` is a vectors JSON or a single-channel mask PNG (anything
148 ``load_classified_mask`` accepts). ``base`` selects the background: ``"intensity"``148 ``load_classified_mask`` accepts). ``base`` selects the background: ``"intensity"``
149 uses the tile at ``base_path``; ``"blank"`` uses a black image of the same size.149 uses the tile at ``base_path``; ``"blank"`` uses a black image of the same size.
150 """150 """
151 mask_cfg = MaskClusteringConfig.coerce(151 mask_cfg = _config.MaskClusteringConfig.coerce(
152 load_config(config_path) if config is None else config152 _config.load_config(config_path) if config is None else config
153 ).mask153 ).mask
154154
155 base_bgr = load_base_image(base_path)155 base_bgr = load_base_image(base_path)
156 shape = (base_bgr.shape[0], base_bgr.shape[1])156 shape = (base_bgr.shape[0], base_bgr.shape[1])
Importance #16: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -194,17 +194,20 @@
194 geometry_dir.mkdir(parents=True, exist_ok=True)194 geometry_dir.mkdir(parents=True, exist_ok=True)
195 cluster_prefix = cfg.output.cluster_prefix195 cluster_prefix = cfg.output.cluster_prefix
196 cluster_cfg = cfg.clusters196 cluster_cfg = cfg.clusters
197 geometry_cfg = cfg.geometry197 geometry_cfg = cfg.geometry
198 separation_cfg = cfg.intensity_separation198 separation_model = cfg.intensity_separation
199 separation_enabled = bool(separation_cfg["enabled"]) and bool(components)199 # ``intensity_separation`` consumes its settings key-by-key, so it takes the
200 apply_filter = bool(separation_cfg["apply_filter"])200 # section as a plain mapping; everything read here goes through the model.
201 separation_cfg = separation_model.model_dump()
202 separation_enabled = separation_model.enabled and bool(components)
203 apply_filter = separation_model.apply_filter
201 # Debugging aid: also write the whole sampling footprint (mask + padding ring) per204 # Debugging aid: also write the whole sampling footprint (mask + padding ring) per
202 # cluster so the paint core, halo and asphalt context can be inspected together in a205 # cluster so the paint core, halo and asphalt context can be inspected together in a
203 # point-cloud viewer. Written to a separate subdir so Step 6b never rasterises them.206 # point-cloud viewer. Written to a separate subdir so Step 6b never rasterises them.
204 save_padded = separation_enabled and bool(separation_cfg.get("save_padded_clusters"))207 save_padded = separation_enabled and separation_model.save_padded_clusters
205 padding_debug_dir = output_dir / "padding_debug"208 padding_debug_dir = output_dir / "padding_debug"
206 dilation_px = int(separation_cfg["dilation_px"])209 dilation_px = separation_model.dilation_px
207 separations: list[intensity_separation.ClusterSeparation] = []210 separations: list[intensity_separation.ClusterSeparation] = []
208 # The asphalt sampling ring lies outside each component's mask, so it needs more211 # The asphalt sampling ring lies outside each component's mask, so it needs more
209 # than the per-component assigned points. Stream-load and project the full segment212 # than the per-component assigned points. Stream-load and project the full segment
210 # once, up front, when separation is enabled. (Cropping to the union of dilated213 # once, up front, when separation is enabled. (Cropping to the union of dilated
Importance #17: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -412,9 +415,9 @@
412 "width": frame.width,415 "width": frame.width,
413 "height": frame.height,416 "height": frame.height,
414 "pixels_per_meter": frame.pixels_per_meter,417 "pixels_per_meter": frame.pixels_per_meter,
415 },418 },
416 "configuration": cfg.raw,419 "configuration": cfg.model_dump(),
417 "counts": {420 "counts": {
418 "foreground_components": len(components),421 "foreground_components": len(components),
419 "solid_components": sum(422 "solid_components": sum(
420 item.segment_type is types.SegmentType.SOLID for item in components423 item.segment_type is types.SegmentType.SOLID for item in components
Importance #18: tests/test_config.py @@ -1,6 +1,10 @@
1import json
2from importlib import resources
3
1import pytest4import pytest
25
6from iolabs_point_cloud_mask_clustering import _config
3from iolabs_point_cloud_mask_clustering._config import (7from iolabs_point_cloud_mask_clustering._config import (
4 MaskClusteringConfigError,8 MaskClusteringConfigError,
5 build_config,9 build_config,
6 load_config,10 load_config,
Importance #19: tests/test_config.py @@ -30,4 +34,67 @@
30)34)
31def test_invalid_configuration_fails(overrides: dict) -> None:35def test_invalid_configuration_fails(overrides: dict) -> None:
32 with pytest.raises(MaskClusteringConfigError):36 with pytest.raises(MaskClusteringConfigError):
33 build_config(overrides=overrides)37 build_config(overrides=overrides)
38
39
40def test_defaults_match_packaged_json() -> None:
41 """The model tree and the packaged JSON must stay in lock-step."""
42 packaged = json.loads(
43 resources.files(_config.PACKAGE)
44 .joinpath(_config.DEFAULT_CONFIG_FILENAME)
45 .read_text(encoding="utf-8")
46 )
47 assert _config.MaskClusteringConfig().model_dump() == packaged
48 assert load_config() == packaged
49
50
51@pytest.mark.parametrize(
52 "overrides",
53 [
54 {"intensity_separation": {"attribute": "reflectance"}},
55 {"intensity_separation": {"device": "gpu"}},
56 {"intensity_separation": {"core_center_fraction": 1.0}},
57 {"intensity_separation": {"search_radius_m": 0.0}},
58 {"intensity_separation": {"overlap_percentile": 50.0}},
59 {"intensity_separation": {"n_anchors": 0}},
60 {"intensity_separation": {"unknown": 1}},
61 ],
62)
63def test_invalid_intensity_separation_fails(overrides: dict) -> None:
64 with pytest.raises(MaskClusteringConfigError):
65 build_config(overrides=overrides)
66
67
68def test_malformed_config_file_fails(tmp_path) -> None:
69 path = tmp_path / "cfg.json"
70 path.write_text("{oops", encoding="utf-8")
71 with pytest.raises(MaskClusteringConfigError):
72 load_config(path)
73
74
75def test_non_object_config_file_fails(tmp_path) -> None:
76 path = tmp_path / "cfg.json"
77 path.write_text("[1, 2]", encoding="utf-8")
78 with pytest.raises(MaskClusteringConfigError):
79 build_config(config_path=path)
80
81
82def test_config_file_and_overrides_merge_onto_defaults(tmp_path) -> None:
83 path = tmp_path / "cfg.json"
84 path.write_text('{"clusters": {"warn_below_points": 500}}', encoding="utf-8")
85 config = build_config(
86 config_path=path, overrides={"clusters": {"min_points_per_cluster": 400}}
87 )
88 assert config["clusters"] == {
89 "min_points_per_cluster": 400,
90 "warn_below_points": 500,
91 }
92 assert config["mask"]["connectivity"] == 8
93
94
95def test_scalar_coercion_and_bool_rejection() -> None:
96 assert build_config(overrides={"mask": {"vector_stroke_px": "6"}})["mask"][
97 "vector_stroke_px"
98 ] == 6
99 with pytest.raises(MaskClusteringConfigError):
100 build_config(overrides={"mask": {"vector_stroke_px": True}})
Importance #20: pyproject.toml @@ -1,7 +1,7 @@
1[project]1[project]
2name = "iolabs-point-cloud-mask-clustering"2name = "iolabs-point-cloud-mask-clustering"
3version = "0.3.1"3version = "0.3.2"
4description = "Convert classified road-marking masks into Step 7-compatible point-cloud clusters"4description = "Convert classified road-marking masks into Step 7-compatible point-cloud clusters"
5requires-python = ">=3.11,<3.13"5requires-python = ">=3.11,<3.13"
6dependencies = [6dependencies = [
7 "numpy>=1.26",7 "numpy>=1.26",
Importance #21: pyproject.toml @@ -9,8 +9,9 @@
9 "scikit-image>=0.22",9 "scikit-image>=0.22",
10 "open3d>=0.19.0",10 "open3d>=0.19.0",
11 "mapbox-earcut>=1.0.3",11 "mapbox-earcut>=1.0.3",
12 "matplotlib>=3.4.0",12 "matplotlib>=3.4.0",
13 "pydantic>=2.7",
13 "iolabs-common>=0.8.0",14 "iolabs-common>=0.8.0",
14 "iolabs-geometry-raster>=0.2.0",15 "iolabs-geometry-raster>=0.2.0",
15 "iolabs-logstash>=0.5.1",16 "iolabs-logstash>=0.5.1",
16]17]
Importance #22: AGENTS.md @@ -8,9 +8,12 @@
8## Conventions8## Conventions
99
10- Library code lives in `src/iolabs_point_cloud_mask_clustering/`.10- Library code lives in `src/iolabs_point_cloud_mask_clustering/`.
11- Runtime wrappers live in `scripts/` and are run from the repository root.11- Runtime wrappers live in `scripts/` and are run from the repository root.
12- Configuration is strict: unknown keys fail.12- Configuration is a pydantic model tree in `_config.py` derived from
13 `iolabs.common.config_loader.ConfigModel`; it is strict, unknown keys fail.
14 A new config key means one field on the model plus the same key in
15 `mask_clustering.default.json`.
13- Preserve Step 3 XYZ unchanged and use `ColorIntensityData` for aligned channels.16- Preserve Step 3 XYZ unchanged and use `ColorIntensityData` for aligned channels.
14- Never modify a segment's existing `clusters/` directory.17- Never modify a segment's existing `clusters/` directory.
15- Use `iolabs.logstash.get_props_logger` with `_log_props.LOG_PROPS`.18- Use `iolabs.logstash.get_props_logger` with `_log_props.LOG_PROPS`.
16- Run `uv run --extra dev pytest -q` and `uv build` before completion.19- Run `uv run --extra dev pytest -q` and `uv build` before completion.
Importance #23: README.md @@ -71,8 +71,20 @@
7171
72Thresholds are configurable, with72Thresholds are configurable, with
73`0 <= min_points_per_cluster <= warn_below_points`.73`0 <= min_points_per_cluster <= warn_below_points`.
7474
75## Configuration
76
77Defaults live in `src/iolabs_point_cloud_mask_clustering/mask_clustering.default.json`
78and are typed by the pydantic model tree in `_config.py`
79(`iolabs.common.config_loader.ConfigModel`). Unknown keys and out-of-range values
80fail loudly; `--config` files and `--set` overrides are deep-merged onto the
81defaults and re-validated.
82
83Adding a config key: add the field (with its type, default and any range
84constraint) to the matching model in `_config.py`, and add the same key to
85`mask_clustering.default.json`. Nothing else.
86
75## CLI87## CLI
7688
77Install development dependencies:89Install development dependencies:
7890
Importance #24: uv.lock @@ -562,9 +562,9 @@
562]562]
563563
564[[package]]564[[package]]
565name = "iolabs-point-cloud-mask-clustering"565name = "iolabs-point-cloud-mask-clustering"
566version = "0.3.1"566version = "0.3.2"
567source = { editable = "." }567source = { editable = "." }
568dependencies = [568dependencies = [
569 { name = "iolabs-common" },569 { name = "iolabs-common" },
570 { name = "iolabs-geometry-raster" },570 { name = "iolabs-geometry-raster" },
Importance #25: uv.lock @@ -574,8 +574,9 @@
574 { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },574 { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
575 { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },575 { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
576 { name = "open3d" },576 { name = "open3d" },
577 { name = "opencv-python-headless" },577 { name = "opencv-python-headless" },
578 { name = "pydantic" },
578 { name = "scikit-image" },579 { name = "scikit-image" },
579]580]
580581
581[package.optional-dependencies]582[package.optional-dependencies]
Importance #26: uv.lock @@ -594,8 +595,9 @@
594 { name = "matplotlib", specifier = ">=3.4.0" },595 { name = "matplotlib", specifier = ">=3.4.0" },
595 { name = "numpy", specifier = ">=1.26" },596 { name = "numpy", specifier = ">=1.26" },
596 { name = "open3d", specifier = ">=0.19.0" },597 { name = "open3d", specifier = ">=0.19.0" },
597 { name = "opencv-python-headless", specifier = ">=4.9" },598 { name = "opencv-python-headless", specifier = ">=4.9" },
599 { name = "pydantic", specifier = ">=2.7" },
598 { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },600 { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
599 { name = "scikit-image", specifier = ">=0.22" },601 { name = "scikit-image", specifier = ">=0.22" },
600]602]
601provides-extras = ["dev"]603provides-extras = ["dev"]
Importance #27: README.md @@ -71,8 +71,20 @@
7171
72Thresholds are configurable, with72Thresholds are configurable, with
73`0 <= min_points_per_cluster <= warn_below_points`.73`0 <= min_points_per_cluster <= warn_below_points`.
7474
75## Configuration
76
77Defaults live in `src/iolabs_point_cloud_mask_clustering/mask_clustering.default.json`
78and are typed by the pydantic model tree in `_config.py`
79(`iolabs.common.config_loader.ConfigModel`). Unknown keys and out-of-range values
80fail loudly; `--config` files and `--set` overrides are deep-merged onto the
81defaults and re-validated.
82
83Adding a config key: add the field (with its type, default and any range
84constraint) to the matching model in `_config.py`, and add the same key to
85`mask_clustering.default.json`. Nothing else.
86
75## CLI87## CLI
7688
77Install development dependencies:89Install development dependencies:
7890
Importance #28: pyproject.toml @@ -1,7 +1,7 @@
1[project]1[project]
2name = "iolabs-point-cloud-mask-clustering"2name = "iolabs-point-cloud-mask-clustering"
3version = "0.3.1"3version = "0.3.2"
4description = "Convert classified road-marking masks into Step 7-compatible point-cloud clusters"4description = "Convert classified road-marking masks into Step 7-compatible point-cloud clusters"
5requires-python = ">=3.11,<3.13"5requires-python = ">=3.11,<3.13"
6dependencies = [6dependencies = [
7 "numpy>=1.26",7 "numpy>=1.26",
Importance #29: pyproject.toml @@ -9,8 +9,9 @@
9 "scikit-image>=0.22",9 "scikit-image>=0.22",
10 "open3d>=0.19.0",10 "open3d>=0.19.0",
11 "mapbox-earcut>=1.0.3",11 "mapbox-earcut>=1.0.3",
12 "matplotlib>=3.4.0",12 "matplotlib>=3.4.0",
13 "pydantic>=2.7",
13 "iolabs-common>=0.8.0",14 "iolabs-common>=0.8.0",
14 "iolabs-geometry-raster>=0.2.0",15 "iolabs-geometry-raster>=0.2.0",
15 "iolabs-logstash>=0.5.1",16 "iolabs-logstash>=0.5.1",
16]17]
Importance #30: src/iolabs_point_cloud_mask_clustering/_config.py @@ -1,29 +1,27 @@
1"""Load, merge, validate and type the mask-clustering configuration.1"""Load, merge, validate and type the mask-clustering configuration.
22
3The packaged JSON default is the schema: unknown keys fail, and every value is3The pydantic model tree below is the schema and mirrors the packaged JSON
4range-checked here rather than at the point of use. Loading and deep-merging are4default exactly: unknown keys fail, and every value is range-checked here rather
5delegated to :mod:`iolabs.common.config_loader`; the coercion and range checks5than at the point of use. Loading, deep-merging and validation are delegated to
6stay local because they encode this step's invariants.6:mod:`iolabs.common.config_loader`.
77
8:func:`load_config` / :func:`build_config` keep returning plain dicts, because8:func:`load_config` / :func:`build_config` keep returning plain dicts, because
9callers pass ``--set``-style overrides around as dicts and the run manifest9callers pass ``--set``-style overrides around as dicts and the run manifest
10embeds the normalized mapping verbatim. :class:`MaskClusteringConfig` is the10embeds the normalized mapping verbatim. :class:`MaskClusteringConfig` is the
11typed view the pipeline and the CLI actually read, so no production code path11typed view the pipeline and the CLI actually read, so no production code path
12indexes nested config dicts by string.12indexes nested config dicts by string.
13
14Adding a config key means adding the field to the model here and the same key to
15``mask_clustering.default.json`` โ€” nothing else.
13"""16"""
1417
15import json18import json
16from dataclasses import dataclass
17from pathlib import Path19from pathlib import Path
18from typing import Any20from typing import Any, Literal
1921
20from iolabs.common.config_loader import (22import pydantic
21 ConfigError,23from iolabs.common import config_loader
22 deep_merge_dicts,
23 load_packaged_json,
24 validate_against_defaults,
25)
26from iolabs.logstash import get_props_logger24from iolabs.logstash import get_props_logger
2725
28from ._log_props import LOG_PROPS26from ._log_props import LOG_PROPS
2927
Importance #31: src/iolabs_point_cloud_mask_clustering/_config.py @@ -32,93 +30,112 @@
32PACKAGE = "iolabs_point_cloud_mask_clustering"30PACKAGE = "iolabs_point_cloud_mask_clustering"
33DEFAULT_CONFIG_FILENAME = "mask_clustering.default.json"31DEFAULT_CONFIG_FILENAME = "mask_clustering.default.json"
3432
3533
36class MaskClusteringConfigError(ConfigError):34class MaskClusteringConfigError(config_loader.ConfigError):
37 """Raised when mask-clustering configuration is invalid."""35 """Raised when mask-clustering configuration is invalid."""
3836
3937
40@dataclass(frozen=True)38class MaskConfig(config_loader.ConfigModel):
41class MaskConfig:
42 """Mask rasterisation and labelling settings."""39 """Mask rasterisation and labelling settings."""
4340
44 background_class: int41 background_class: int = 0
45 solid_class: int42 solid_class: int = 1
46 dashed_class: int43 dashed_class: int = 2
47 connectivity: int44 connectivity: Literal[4, 8] = 8
48 vector_stroke_px: int45 vector_stroke_px: int = pydantic.Field(default=4, ge=1)
4946
5047
51@dataclass(frozen=True)48class ClustersConfig(config_loader.ConfigModel):
52class ClustersConfig:
53 """Sparse-cluster thresholds."""49 """Sparse-cluster thresholds."""
5450
55 min_points_per_cluster: int51 min_points_per_cluster: int = pydantic.Field(default=20, ge=0)
56 warn_below_points: int52 warn_below_points: int = pydantic.Field(default=200, ge=0)
57
5853
59@dataclass(frozen=True)54 @pydantic.model_validator(mode="after")
60class RasterFrameConfig:55 def _check_thresholds(self) -> "ClustersConfig":
56 """Reject a minimum above the warning threshold."""
57 if self.min_points_per_cluster > self.warn_below_points:
58 raise ValueError(
59 "clusters thresholds must satisfy "
60 "0 <= min_points_per_cluster <= warn_below_points"
61 )
62 return self
63
64
65class IntensitySeparationConfig(config_loader.ConfigModel):
66 """Paint/asphalt intensity-separation settings."""
67
68 enabled: bool = True
69 apply_filter: bool = True
70 save_padded_clusters: bool = False
71 attribute: Literal["intensity"] = "intensity"
72 dilation_px: int = pydantic.Field(default=4, ge=0)
73 core_center_fraction: float = pydantic.Field(default=0.85, ge=0.0, lt=1.0)
74 rim_edge_fraction: float = pydantic.Field(default=0.8, ge=0.0, lt=1.0)
75 n_anchors: int = pydantic.Field(default=15, ge=1)
76 search_radius_m: float = pydantic.Field(default=0.04, gt=0.0)
77 min_samples: int = pydantic.Field(default=50, ge=1)
78 min_median_gap_abs: float = pydantic.Field(default=0.0, ge=0.0)
79 min_median_gap_mads: float = pydantic.Field(default=2.0, ge=0.0)
80 overlap_percentile: float = pydantic.Field(default=20.0, gt=0.0, lt=50.0)
81 n_bins: int = pydantic.Field(default=64, ge=0)
82 device: Literal["cpu", "cuda"] = "cpu"
83 seed: int = pydantic.Field(default=0, ge=0)
84 clusters_per_page: int = pydantic.Field(default=3, ge=1)
85 pdf_filename: str = "intensity_separation.pdf"
86
87
88class RasterFrameConfig(config_loader.ConfigModel):
61 """Tolerances used when reconstructing the raster frame."""89 """Tolerances used when reconstructing the raster frame."""
6290
63 margin_pixels: float91 margin_pixels: float = pydantic.Field(default=1.0, ge=0.0)
64 metadata_origin_tolerance_pixels: float92 metadata_origin_tolerance_pixels: float = pydantic.Field(default=0.25, ge=0.0)
6593
6694
67@dataclass(frozen=True)95class GeometryConfig(config_loader.ConfigModel):
68class GeometryConfig:
69 """Which diagnostic geometry artifacts to write."""96 """Which diagnostic geometry artifacts to write."""
7097
71 write_geojson: bool98 write_geojson: bool = True
72 write_ply: bool99 write_ply: bool = True
73 simplify_tolerance_px: float100 simplify_tolerance_px: float = 0.0
74101
75102
76@dataclass(frozen=True)103class OutputConfig(config_loader.ConfigModel):
77class OutputConfig:
78 """Output directory and file-name layout."""104 """Output directory and file-name layout."""
79105
80 cluster_dir: str106 cluster_dir: str = "clusters_mask"
81 cluster_prefix: str107 cluster_prefix: str = "run6_cluster_"
82 geometry_dir: str108 geometry_dir: str = "mask_geometry"
83 manifest_filename: str109 manifest_filename: str = "mask_clustering_manifest.json"
84110
85111
86@dataclass(frozen=True)112class FileNamingConfig(config_loader.ConfigModel):
87class FileNamingConfig:
88 """How Step 3 inputs are discovered inside a segment directory."""113 """How Step 3 inputs are discovered inside a segment directory."""
89114
90 segment_points_suffix: str115 segment_points_suffix: str = "_run3_points.npz"
91
92116
93@dataclass(frozen=True)
94class MaskClusteringConfig:
95 """Typed view over a normalized configuration mapping.
96117
97 ``intensity_separation`` stays a mapping: it is consumed key-by-key deep118class MaskClusteringConfig(config_loader.ConfigModel):
98 inside :mod:`.intensity_separation`, where a mechanical field-by-field119 """Typed, validated mask-clustering configuration.
99 conversion would buy nothing. ``raw`` is the normalized mapping the manifest
100 records verbatim.
101120
102 Attributes:121 Attributes:
103 mask: Mask rasterisation and labelling settings.122 mask: Mask rasterisation and labelling settings.
104 clusters: Sparse-cluster thresholds.123 clusters: Sparse-cluster thresholds.
124 intensity_separation: Intensity-separation settings.
105 raster_frame: Raster-frame reconstruction tolerances.125 raster_frame: Raster-frame reconstruction tolerances.
106 geometry: Diagnostic geometry toggles.126 geometry: Diagnostic geometry toggles.
107 output: Output directory and file-name layout.127 output: Output directory and file-name layout.
108 file_naming: Step 3 input discovery settings.128 file_naming: Step 3 input discovery settings.
109 intensity_separation: Intensity-separation settings, untyped.
110 raw: The normalized configuration mapping.
111 """129 """
112130
113 mask: MaskConfig131 mask: MaskConfig = MaskConfig()
114 clusters: ClustersConfig132 clusters: ClustersConfig = ClustersConfig()
115 raster_frame: RasterFrameConfig133 intensity_separation: IntensitySeparationConfig = IntensitySeparationConfig()
116 geometry: GeometryConfig134 raster_frame: RasterFrameConfig = RasterFrameConfig()
117 output: OutputConfig135 geometry: GeometryConfig = GeometryConfig()
118 file_naming: FileNamingConfig136 output: OutputConfig = OutputConfig()
119 intensity_separation: dict[str, Any]137 file_naming: FileNamingConfig = FileNamingConfig()
120 raw: dict[str, Any]
121138
122 @classmethod139 @classmethod
123 def coerce(cls, config: "MaskClusteringConfig | dict[str, Any]") -> "MaskClusteringConfig":140 def coerce(cls, config: "MaskClusteringConfig | dict[str, Any]") -> "MaskClusteringConfig":
124 """Return *config* as a typed configuration, converting a mapping if needed.141 """Return *config* as a typed configuration, converting a mapping if needed.
Importance #32: src/iolabs_point_cloud_mask_clustering/_config.py @@ -137,9 +154,9 @@
137 return cls.from_mapping(config)154 return cls.from_mapping(config)
138155
139 @classmethod156 @classmethod
140 def from_mapping(cls, config: dict[str, Any]) -> "MaskClusteringConfig":157 def from_mapping(cls, config: dict[str, Any]) -> "MaskClusteringConfig":
141 """Build the typed view, normalizing *config* first if needed.158 """Build the typed view, merging *config* onto the packaged defaults.
142159
143 Args:160 Args:
144 config: A raw or already-normalized configuration mapping.161 config: A raw or already-normalized configuration mapping.
145162
Importance #33: src/iolabs_point_cloud_mask_clustering/_config.py @@ -148,132 +165,43 @@
148165
149 Raises:166 Raises:
150 MaskClusteringConfigError: The mapping is not a valid configuration.167 MaskClusteringConfigError: The mapping is not a valid configuration.
151 """168 """
152 normalized = normalize_config(config)169 return _load_model(overrides=config)
153 mask = normalized["mask"]170
154 clusters = normalized["clusters"]171
155 frame = normalized["raster_frame"]172def _load_model(overrides: dict[str, Any] | None = None) -> MaskClusteringConfig:
156 geometry = normalized["geometry"]173 """Merge *overrides* onto the packaged defaults and validate the result."""
157 output = normalized["output"]174 return config_loader.load_config(
158 naming = normalized["file_naming"]175 MaskClusteringConfig,
159 return cls(176 package=PACKAGE,
160 mask=MaskConfig(177 filename=DEFAULT_CONFIG_FILENAME,
161 background_class=int(mask["background_class"]),178 overrides=overrides,
162 solid_class=int(mask["solid_class"]),179 context="config",
163 dashed_class=int(mask["dashed_class"]),180 error_cls=MaskClusteringConfigError,
164 connectivity=int(mask["connectivity"]),181 )
165 vector_stroke_px=int(mask["vector_stroke_px"]),
166 ),
167 clusters=ClustersConfig(
168 min_points_per_cluster=int(clusters["min_points_per_cluster"]),
169 warn_below_points=int(clusters["warn_below_points"]),
170 ),
171 raster_frame=RasterFrameConfig(
172 margin_pixels=float(frame["margin_pixels"]),
173 metadata_origin_tolerance_pixels=float(
174 frame["metadata_origin_tolerance_pixels"]
175 ),
176 ),
177 geometry=GeometryConfig(
178 write_geojson=bool(geometry["write_geojson"]),
179 write_ply=bool(geometry["write_ply"]),
180 simplify_tolerance_px=float(geometry["simplify_tolerance_px"]),
181 ),
182 output=OutputConfig(
183 cluster_dir=str(output["cluster_dir"]),
184 cluster_prefix=str(output["cluster_prefix"]),
185 geometry_dir=str(output["geometry_dir"]),
186 manifest_filename=str(output["manifest_filename"]),
187 ),
188 file_naming=FileNamingConfig(
189 segment_points_suffix=str(naming["segment_points_suffix"]),
190 ),
191 intensity_separation=dict(normalized["intensity_separation"]),
192 raw=normalized,
193 )
194182
195183
196def _validate_values(config: dict[str, Any]) -> None:184def _read_overrides(config_path: str | Path | None) -> dict[str, Any]:
197 mask = config["mask"]185 """Read a JSON override file, or return an empty mapping when there is none.
198 clusters = config["clusters"]
199 frame = config["raster_frame"]
200 if mask["connectivity"] not in {4, 8}:
201 raise MaskClusteringConfigError("mask.connectivity must be 4 or 8")
202 if int(mask["vector_stroke_px"]) < 1:
203 raise MaskClusteringConfigError("mask.vector_stroke_px must be >= 1")
204 minimum = int(clusters["min_points_per_cluster"])
205 warning = int(clusters["warn_below_points"])
206 if not 0 <= minimum <= warning:
207 raise MaskClusteringConfigError(
208 "clusters thresholds must satisfy 0 <= min_points_per_cluster <= warn_below_points"
209 )
210 if float(frame["margin_pixels"]) < 0:
211 raise MaskClusteringConfigError("raster_frame.margin_pixels must be >= 0")
212 if float(frame["metadata_origin_tolerance_pixels"]) < 0:
213 raise MaskClusteringConfigError(
214 "raster_frame.metadata_origin_tolerance_pixels must be >= 0"
215 )
216 _validate_intensity_separation(config["intensity_separation"])
217186
218187 Raises:
219def _validate_intensity_separation(separation: dict[str, Any]) -> None:188 MaskClusteringConfigError: The file is not valid JSON, or does not hold
220 if separation["attribute"] != "intensity":189 a JSON object.
221 raise MaskClusteringConfigError(190 """
222 "intensity_separation.attribute must be 'intensity'"191 if config_path is None:
223 )192 return {}
224 if separation["device"] not in {"cpu", "cuda"}:193 path = Path(config_path)
225 raise MaskClusteringConfigError(194 try:
226 "intensity_separation.device must be 'cpu' or 'cuda'"195 with path.open(encoding="utf-8") as handle:
227 )196 loaded = json.load(handle)
228 non_negative_ints = (197 except json.JSONDecodeError as exc:
229 "dilation_px",198 raise MaskClusteringConfigError(f"Invalid JSON in config file {path}: {exc}") from exc
230 "n_anchors",199 if not isinstance(loaded, dict):
231 "n_bins",
232 "seed",
233 )
234 for key in non_negative_ints:
235 if int(separation[key]) < 0:
236 raise MaskClusteringConfigError(
237 f"intensity_separation.{key} must be >= 0"
238 )
239 for key in ("core_center_fraction", "rim_edge_fraction"):
240 if not 0.0 <= float(separation[key]) < 1.0:
241 raise MaskClusteringConfigError(
242 f"intensity_separation.{key} must be in [0, 1)"
243 )
244 if int(separation["min_samples"]) < 1:
245 raise MaskClusteringConfigError(
246 "intensity_separation.min_samples must be >= 1"
247 )
248 if int(separation["clusters_per_page"]) < 1:
249 raise MaskClusteringConfigError(
250 "intensity_separation.clusters_per_page must be >= 1"
251 )
252 if int(separation["n_anchors"]) < 1:
253 raise MaskClusteringConfigError(
254 "intensity_separation.n_anchors must be >= 1"
255 )
256 if float(separation["search_radius_m"]) <= 0:
257 raise MaskClusteringConfigError(
258 "intensity_separation.search_radius_m must be > 0"
259 )
260 if float(separation["min_median_gap_abs"]) < 0:
261 raise MaskClusteringConfigError(
262 "intensity_separation.min_median_gap_abs must be >= 0"
263 )
264 if float(separation["min_median_gap_mads"]) < 0:
265 raise MaskClusteringConfigError(
266 "intensity_separation.min_median_gap_mads must be >= 0"
267 )
268 if not 0 < float(separation["overlap_percentile"]) < 50:
269 raise MaskClusteringConfigError(200 raise MaskClusteringConfigError(
270 "intensity_separation.overlap_percentile must be in (0, 50)"201 f"Config file {path} must hold a JSON object, got {type(loaded).__name__}"
271 )202 )
272203 return loaded
273
274def _defaults() -> dict[str, Any]:
275 return load_packaged_json(PACKAGE, DEFAULT_CONFIG_FILENAME)
276204
277205
278def normalize_config(raw: dict[str, Any]) -> dict[str, Any]:206def normalize_config(raw: dict[str, Any]) -> dict[str, Any]:
279 """Merge *raw* onto the packaged defaults and validate the result.207 """Merge *raw* onto the packaged defaults and validate the result.
Importance #34: src/iolabs_point_cloud_mask_clustering/_config.py @@ -286,15 +214,9 @@
286214
287 Raises:215 Raises:
288 MaskClusteringConfigError: An unknown key or an out-of-range value.216 MaskClusteringConfigError: An unknown key or an out-of-range value.
289 """217 """
290 defaults = _defaults()218 return _load_model(overrides=raw).model_dump()
291 validate_against_defaults(
292 raw, defaults, context="config", error_cls=MaskClusteringConfigError
293 )
294 config = deep_merge_dicts(defaults, raw)
295 _validate_values(config)
296 return config
297219
298220
299def load_config(config_path: str | Path | None = None) -> dict[str, Any]:221def load_config(config_path: str | Path | None = None) -> dict[str, Any]:
300 """Load a configuration JSON, or the packaged defaults when *config_path* is None.222 """Load a configuration JSON, or the packaged defaults when *config_path* is None.
Importance #35: src/iolabs_point_cloud_mask_clustering/_config.py @@ -305,15 +227,12 @@
305 Returns:227 Returns:
306 The merged, validated configuration.228 The merged, validated configuration.
307229
308 Raises:230 Raises:
309 MaskClusteringConfigError: An unknown key or an out-of-range value.231 MaskClusteringConfigError: A malformed config file, an unknown key or an
232 out-of-range value.
310 """233 """
311 if config_path is None:234 return _load_model(overrides=_read_overrides(config_path)).model_dump()
312 return normalize_config({})
313 with Path(config_path).open(encoding="utf-8") as handle:
314 raw = json.load(handle)
315 return normalize_config(raw)
316235
317236
318def build_config(237def build_config(
319 *,238 *,
Importance #36: src/iolabs_point_cloud_mask_clustering/_config.py @@ -329,13 +248,11 @@
329 Returns:248 Returns:
330 The merged, validated configuration.249 The merged, validated configuration.
331250
332 Raises:251 Raises:
333 MaskClusteringConfigError: An unknown key or an out-of-range value.252 MaskClusteringConfigError: A malformed config file, an unknown key or an
253 out-of-range value.
334 """254 """
335 config = load_config(config_path)255 merged = config_loader.deep_merge_dicts(
336 if overrides:256 _read_overrides(config_path), dict(overrides or {})
337 validate_against_defaults(257 )
338 overrides, _defaults(), context="config", error_cls=MaskClusteringConfigError258 return _load_model(overrides=merged).model_dump()
339 )
340 config = deep_merge_dicts(config, overrides)
341 return normalize_config(config)
Importance #37: src/iolabs_point_cloud_mask_clustering/cli.py @@ -5,13 +5,13 @@
5import sys5import sys
6from pathlib import Path6from pathlib import Path
7from typing import Any7from typing import Any
88
9from iolabs.common.config_loader import parse_set_overrides as _parse_set_overrides9from iolabs.common import config_loader
10from iolabs.common.run_stats import read_stats10from iolabs.common.run_stats import read_stats
11from iolabs.logstash import get_props_logger11from iolabs.logstash import get_props_logger
1212
13from ._config import MaskClusteringConfig, build_config13from . import _config
14from ._log_props import LOG_PROPS14from ._log_props import LOG_PROPS
15from .pipeline import process_segment15from .pipeline import process_segment
1616
17logger = get_props_logger(__name__, LOG_PROPS)17logger = get_props_logger(__name__, LOG_PROPS)
Importance #38: src/iolabs_point_cloud_mask_clustering/cli.py @@ -71,9 +71,9 @@
71 ValueError: An argument is not ``section.key=value`` (message:71 ValueError: An argument is not ``section.key=value`` (message:
72 ``Invalid --set override '...'. Expected SECTION.KEY=VALUE.``), or72 ``Invalid --set override '...'. Expected SECTION.KEY=VALUE.``), or
73 two arguments disagree about whether a path segment is a section.73 two arguments disagree about whether a path segment is a section.
74 """74 """
75 return _parse_set_overrides(values, nested=True, error_cls=ValueError)75 return config_loader.parse_set_overrides(values, nested=True, error_cls=ValueError)
7676
7777
78def _load_jobs(path: Path) -> list[dict[str, Any]]:78def _load_jobs(path: Path) -> list[dict[str, Any]]:
79 with path.open(encoding="utf-8") as handle:79 with path.open(encoding="utf-8") as handle:
Importance #39: src/iolabs_point_cloud_mask_clustering/cli.py @@ -118,10 +118,10 @@
118 return False118 return False
119119
120120
121def _run_segment(args: argparse.Namespace) -> int:121def _run_segment(args: argparse.Namespace) -> int:
122 config = MaskClusteringConfig.coerce(122 config = _config.MaskClusteringConfig.coerce(
123 build_config(123 _config.build_config(
124 config_path=args.config,124 config_path=args.config,
125 overrides=parse_set_overrides(args.sets),125 overrides=parse_set_overrides(args.sets),
126 )126 )
127 )127 )
Importance #40: src/iolabs_point_cloud_mask_clustering/cli.py @@ -140,10 +140,10 @@
140140
141def _run_batch(args: argparse.Namespace) -> int:141def _run_batch(args: argparse.Namespace) -> int:
142 if args.overwrite and args.skip_existing:142 if args.overwrite and args.skip_existing:
143 raise ValueError("--overwrite and --skip-existing are mutually exclusive")143 raise ValueError("--overwrite and --skip-existing are mutually exclusive")
144 config = MaskClusteringConfig.coerce(144 config = _config.MaskClusteringConfig.coerce(
145 build_config(145 _config.build_config(
146 config_path=args.config,146 config_path=args.config,
147 overrides=parse_set_overrides(args.sets),147 overrides=parse_set_overrides(args.sets),
148 )148 )
149 )149 )
Importance #41: src/iolabs_point_cloud_mask_clustering/overlay.py @@ -18,9 +18,9 @@
1818
19import cv219import cv2
20import numpy as np20import numpy as np
2121
22from ._config import MaskClusteringConfig, load_config22from . import _config
23from .geometry import mask_polygons_px23from .geometry import mask_polygons_px
24from .mask_components import label_components, load_classified_mask24from .mask_components import label_components, load_classified_mask
25from .types import MaskComponent, SegmentType25from .types import MaskComponent, SegmentType
2626
Importance #42: src/iolabs_point_cloud_mask_clustering/overlay.py @@ -131,9 +131,9 @@
131 source_path: str | Path,131 source_path: str | Path,
132 base_path: str | Path,132 base_path: str | Path,
133 *,133 *,
134 base: str = "intensity",134 base: str = "intensity",
135 config: MaskClusteringConfig | dict[str, Any] | None = None,135 config: _config.MaskClusteringConfig | dict[str, Any] | None = None,
136 config_path: str | Path | None = None,136 config_path: str | Path | None = None,
137 draw_mask_fill: bool = True,137 draw_mask_fill: bool = True,
138 mask_fill_alpha: float = 0.4,138 mask_fill_alpha: float = 0.4,
139 border_thickness: int = 2,139 border_thickness: int = 2,
Importance #43: src/iolabs_point_cloud_mask_clustering/overlay.py @@ -147,10 +147,10 @@
147 ``source_path`` is a vectors JSON or a single-channel mask PNG (anything147 ``source_path`` is a vectors JSON or a single-channel mask PNG (anything
148 ``load_classified_mask`` accepts). ``base`` selects the background: ``"intensity"``148 ``load_classified_mask`` accepts). ``base`` selects the background: ``"intensity"``
149 uses the tile at ``base_path``; ``"blank"`` uses a black image of the same size.149 uses the tile at ``base_path``; ``"blank"`` uses a black image of the same size.
150 """150 """
151 mask_cfg = MaskClusteringConfig.coerce(151 mask_cfg = _config.MaskClusteringConfig.coerce(
152 load_config(config_path) if config is None else config152 _config.load_config(config_path) if config is None else config
153 ).mask153 ).mask
154154
155 base_bgr = load_base_image(base_path)155 base_bgr = load_base_image(base_path)
156 shape = (base_bgr.shape[0], base_bgr.shape[1])156 shape = (base_bgr.shape[0], base_bgr.shape[1])
Importance #44: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -194,17 +194,20 @@
194 geometry_dir.mkdir(parents=True, exist_ok=True)194 geometry_dir.mkdir(parents=True, exist_ok=True)
195 cluster_prefix = cfg.output.cluster_prefix195 cluster_prefix = cfg.output.cluster_prefix
196 cluster_cfg = cfg.clusters196 cluster_cfg = cfg.clusters
197 geometry_cfg = cfg.geometry197 geometry_cfg = cfg.geometry
198 separation_cfg = cfg.intensity_separation198 separation_model = cfg.intensity_separation
199 separation_enabled = bool(separation_cfg["enabled"]) and bool(components)199 # ``intensity_separation`` consumes its settings key-by-key, so it takes the
200 apply_filter = bool(separation_cfg["apply_filter"])200 # section as a plain mapping; everything read here goes through the model.
201 separation_cfg = separation_model.model_dump()
202 separation_enabled = separation_model.enabled and bool(components)
203 apply_filter = separation_model.apply_filter
201 # Debugging aid: also write the whole sampling footprint (mask + padding ring) per204 # Debugging aid: also write the whole sampling footprint (mask + padding ring) per
202 # cluster so the paint core, halo and asphalt context can be inspected together in a205 # cluster so the paint core, halo and asphalt context can be inspected together in a
203 # point-cloud viewer. Written to a separate subdir so Step 6b never rasterises them.206 # point-cloud viewer. Written to a separate subdir so Step 6b never rasterises them.
204 save_padded = separation_enabled and bool(separation_cfg.get("save_padded_clusters"))207 save_padded = separation_enabled and separation_model.save_padded_clusters
205 padding_debug_dir = output_dir / "padding_debug"208 padding_debug_dir = output_dir / "padding_debug"
206 dilation_px = int(separation_cfg["dilation_px"])209 dilation_px = separation_model.dilation_px
207 separations: list[intensity_separation.ClusterSeparation] = []210 separations: list[intensity_separation.ClusterSeparation] = []
208 # The asphalt sampling ring lies outside each component's mask, so it needs more211 # The asphalt sampling ring lies outside each component's mask, so it needs more
209 # than the per-component assigned points. Stream-load and project the full segment212 # than the per-component assigned points. Stream-load and project the full segment
210 # once, up front, when separation is enabled. (Cropping to the union of dilated213 # once, up front, when separation is enabled. (Cropping to the union of dilated
Importance #45: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -380,9 +383,9 @@
380 )383 )
381384
382 separation_pdf_relative: str | None = None385 separation_pdf_relative: str | None = None
383 if separation_enabled:386 if separation_enabled:
384 separation_pdf_path = output_dir / str(separation_cfg["pdf_filename"])387 separation_pdf_path = output_dir / separation_model.pdf_filename
385 intensity_separation.write_separation_pdf(separations, separation_pdf_path, separation_cfg)388 intensity_separation.write_separation_pdf(separations, separation_pdf_path, separation_cfg)
386 separation_pdf_relative = separation_pdf_path.relative_to(output_dir).as_posix()389 separation_pdf_relative = separation_pdf_path.relative_to(output_dir).as_posix()
387390
388 versions_path = output_dir / "run6c_versions.json"391 versions_path = output_dir / "run6c_versions.json"
Importance #46: src/iolabs_point_cloud_mask_clustering/pipeline.py @@ -412,9 +415,9 @@
412 "width": frame.width,415 "width": frame.width,
413 "height": frame.height,416 "height": frame.height,
414 "pixels_per_meter": frame.pixels_per_meter,417 "pixels_per_meter": frame.pixels_per_meter,
415 },418 },
416 "configuration": cfg.raw,419 "configuration": cfg.model_dump(),
417 "counts": {420 "counts": {
418 "foreground_components": len(components),421 "foreground_components": len(components),
419 "solid_components": sum(422 "solid_components": sum(
420 item.segment_type is types.SegmentType.SOLID for item in components423 item.segment_type is types.SegmentType.SOLID for item in components
Importance #47: tests/test_config.py @@ -1,6 +1,10 @@
1import json
2from importlib import resources
3
1import pytest4import pytest
25
6from iolabs_point_cloud_mask_clustering import _config
3from iolabs_point_cloud_mask_clustering._config import (7from iolabs_point_cloud_mask_clustering._config import (
4 MaskClusteringConfigError,8 MaskClusteringConfigError,
5 build_config,9 build_config,
6 load_config,10 load_config,
Importance #48: tests/test_config.py @@ -30,4 +34,67 @@
30)34)
31def test_invalid_configuration_fails(overrides: dict) -> None:35def test_invalid_configuration_fails(overrides: dict) -> None:
32 with pytest.raises(MaskClusteringConfigError):36 with pytest.raises(MaskClusteringConfigError):
33 build_config(overrides=overrides)37 build_config(overrides=overrides)
38
39
40def test_defaults_match_packaged_json() -> None:
41 """The model tree and the packaged JSON must stay in lock-step."""
42 packaged = json.loads(
43 resources.files(_config.PACKAGE)
44 .joinpath(_config.DEFAULT_CONFIG_FILENAME)
45 .read_text(encoding="utf-8")
46 )
47 assert _config.MaskClusteringConfig().model_dump() == packaged
48 assert load_config() == packaged
49
50
51@pytest.mark.parametrize(
52 "overrides",
53 [
54 {"intensity_separation": {"attribute": "reflectance"}},
55 {"intensity_separation": {"device": "gpu"}},
56 {"intensity_separation": {"core_center_fraction": 1.0}},
57 {"intensity_separation": {"search_radius_m": 0.0}},
58 {"intensity_separation": {"overlap_percentile": 50.0}},
59 {"intensity_separation": {"n_anchors": 0}},
60 {"intensity_separation": {"unknown": 1}},
61 ],
62)
63def test_invalid_intensity_separation_fails(overrides: dict) -> None:
64 with pytest.raises(MaskClusteringConfigError):
65 build_config(overrides=overrides)
66
67
68def test_malformed_config_file_fails(tmp_path) -> None:
69 path = tmp_path / "cfg.json"
70 path.write_text("{oops", encoding="utf-8")
71 with pytest.raises(MaskClusteringConfigError):
72 load_config(path)
73
74
75def test_non_object_config_file_fails(tmp_path) -> None:
76 path = tmp_path / "cfg.json"
77 path.write_text("[1, 2]", encoding="utf-8")
78 with pytest.raises(MaskClusteringConfigError):
79 build_config(config_path=path)
80
81
82def test_config_file_and_overrides_merge_onto_defaults(tmp_path) -> None:
83 path = tmp_path / "cfg.json"
84 path.write_text('{"clusters": {"warn_below_points": 500}}', encoding="utf-8")
85 config = build_config(
86 config_path=path, overrides={"clusters": {"min_points_per_cluster": 400}}
87 )
88 assert config["clusters"] == {
89 "min_points_per_cluster": 400,
90 "warn_below_points": 500,
91 }
92 assert config["mask"]["connectivity"] == 8
93
94
95def test_scalar_coercion_and_bool_rejection() -> None:
96 assert build_config(overrides={"mask": {"vector_stroke_px": "6"}})["mask"][
97 "vector_stroke_px"
98 ] == 6
99 with pytest.raises(MaskClusteringConfigError):
100 build_config(overrides={"mask": {"vector_stroke_px": True}})
Importance #49: uv.lock @@ -562,9 +562,9 @@
562]562]
563563
564[[package]]564[[package]]
565name = "iolabs-point-cloud-mask-clustering"565name = "iolabs-point-cloud-mask-clustering"
566version = "0.3.1"566version = "0.3.2"
567source = { editable = "." }567source = { editable = "." }
568dependencies = [568dependencies = [
569 { name = "iolabs-common" },569 { name = "iolabs-common" },
570 { name = "iolabs-geometry-raster" },570 { name = "iolabs-geometry-raster" },
Importance #50: uv.lock @@ -574,8 +574,9 @@
574 { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },574 { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
575 { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },575 { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
576 { name = "open3d" },576 { name = "open3d" },
577 { name = "opencv-python-headless" },577 { name = "opencv-python-headless" },
578 { name = "pydantic" },
578 { name = "scikit-image" },579 { name = "scikit-image" },
579]580]
580581
581[package.optional-dependencies]582[package.optional-dependencies]
Importance #51: uv.lock @@ -594,8 +595,9 @@
594 { name = "matplotlib", specifier = ">=3.4.0" },595 { name = "matplotlib", specifier = ">=3.4.0" },
595 { name = "numpy", specifier = ">=1.26" },596 { name = "numpy", specifier = ">=1.26" },
596 { name = "open3d", specifier = ">=0.19.0" },597 { name = "open3d", specifier = ">=0.19.0" },
597 { name = "opencv-python-headless", specifier = ">=4.9" },598 { name = "opencv-python-headless", specifier = ">=4.9" },
599 { name = "pydantic", specifier = ">=2.7" },
598 { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },600 { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
599 { name = "scikit-image", specifier = ">=0.22" },601 { name = "scikit-image", specifier = ">=0.22" },
600]602]
601provides-extras = ["dev"]603provides-extras = ["dev"]