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(-)
| 32 | PACKAGE = "iolabs_point_cloud_mask_clustering" | 30 | PACKAGE = "iolabs_point_cloud_mask_clustering" |
| 33 | DEFAULT_CONFIG_FILENAME = "mask_clustering.default.json" | 31 | DEFAULT_CONFIG_FILENAME = "mask_clustering.default.json" |
| 34 | 32 | ||
| 35 | 33 | ||
| 36 | class MaskClusteringConfigError(ConfigError): | 34 | class MaskClusteringConfigError(config_loader.ConfigError): |
| 37 | """Raised when mask-clustering configuration is invalid.""" | 35 | """Raised when mask-clustering configuration is invalid.""" |
| 38 | 36 | ||
| 39 | 37 | ||
| 40 | @dataclass(frozen=True) | 38 | class MaskConfig(config_loader.ConfigModel): |
| 41 | class MaskConfig: | ||
| 42 | """Mask rasterisation and labelling settings.""" | 39 | """Mask rasterisation and labelling settings.""" |
| 43 | 40 | ||
| 44 | background_class: int | 41 | background_class: int = 0 |
| 45 | solid_class: int | 42 | solid_class: int = 1 |
| 46 | dashed_class: int | 43 | dashed_class: int = 2 |
| 47 | connectivity: int | 44 | connectivity: Literal[4, 8] = 8 |
| 48 | vector_stroke_px: int | 45 | vector_stroke_px: int = pydantic.Field(default=4, ge=1) |
| 49 | 46 | ||
| 50 | 47 | ||
| 51 | @dataclass(frozen=True) | 48 | class ClustersConfig(config_loader.ConfigModel): |
| 52 | class ClustersConfig: | ||
| 53 | """Sparse-cluster thresholds.""" | 49 | """Sparse-cluster thresholds.""" |
| 54 | 50 | ||
| 55 | min_points_per_cluster: int | 51 | min_points_per_cluster: int = pydantic.Field(default=20, ge=0) |
| 56 | warn_below_points: int | 52 | warn_below_points: int = pydantic.Field(default=200, ge=0) |
| 57 | |||
| 58 | 53 | ||
| 59 | @dataclass(frozen=True) | 54 | @pydantic.model_validator(mode="after") |
| 60 | class 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 | |||
| 65 | class 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 | |||
| 88 | class RasterFrameConfig(config_loader.ConfigModel): | ||
| 61 | """Tolerances used when reconstructing the raster frame.""" | 89 | """Tolerances used when reconstructing the raster frame.""" |
| 62 | 90 | ||
| 63 | margin_pixels: float | 91 | margin_pixels: float = pydantic.Field(default=1.0, ge=0.0) |
| 64 | metadata_origin_tolerance_pixels: float | 92 | metadata_origin_tolerance_pixels: float = pydantic.Field(default=0.25, ge=0.0) |
| 65 | 93 | ||
| 66 | 94 | ||
| 67 | @dataclass(frozen=True) | 95 | class GeometryConfig(config_loader.ConfigModel): |
| 68 | class GeometryConfig: | ||
| 69 | """Which diagnostic geometry artifacts to write.""" | 96 | """Which diagnostic geometry artifacts to write.""" |
| 70 | 97 | ||
| 71 | write_geojson: bool | 98 | write_geojson: bool = True |
| 72 | write_ply: bool | 99 | write_ply: bool = True |
| 73 | simplify_tolerance_px: float | 100 | simplify_tolerance_px: float = 0.0 |
| 74 | 101 | ||
| 75 | 102 | ||
| 76 | @dataclass(frozen=True) | 103 | class OutputConfig(config_loader.ConfigModel): |
| 77 | class OutputConfig: | ||
| 78 | """Output directory and file-name layout.""" | 104 | """Output directory and file-name layout.""" |
| 79 | 105 | ||
| 80 | cluster_dir: str | 106 | cluster_dir: str = "clusters_mask" |
| 81 | cluster_prefix: str | 107 | cluster_prefix: str = "run6_cluster_" |
| 82 | geometry_dir: str | 108 | geometry_dir: str = "mask_geometry" |
| 83 | manifest_filename: str | 109 | manifest_filename: str = "mask_clustering_manifest.json" |
| 84 | 110 | ||
| 85 | 111 | ||
| 86 | @dataclass(frozen=True) | 112 | class FileNamingConfig(config_loader.ConfigModel): |
| 87 | class FileNamingConfig: | ||
| 88 | """How Step 3 inputs are discovered inside a segment directory.""" | 113 | """How Step 3 inputs are discovered inside a segment directory.""" |
| 89 | 114 | ||
| 90 | segment_points_suffix: str | 115 | segment_points_suffix: str = "_run3_points.npz" |
| 91 | |||
| 92 | 116 | ||
| 93 | @dataclass(frozen=True) | ||
| 94 | class MaskClusteringConfig: | ||
| 95 | """Typed view over a normalized configuration mapping. | ||
| 96 | 117 | ||
| 97 | ``intensity_separation`` stays a mapping: it is consumed key-by-key deep | 118 | class MaskClusteringConfig(config_loader.ConfigModel): |
| 98 | inside :mod:`.intensity_separation`, where a mechanical field-by-field | 119 | """Typed, validated mask-clustering configuration. |
| 99 | conversion would buy nothing. ``raw`` is the normalized mapping the manifest | ||
| 100 | records verbatim. | ||
| 101 | 120 | ||
| 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 | """ |
| 112 | 130 | ||
| 113 | mask: MaskConfig | 131 | mask: MaskConfig = MaskConfig() |
| 114 | clusters: ClustersConfig | 132 | clusters: ClustersConfig = ClustersConfig() |
| 115 | raster_frame: RasterFrameConfig | 133 | intensity_separation: IntensitySeparationConfig = IntensitySeparationConfig() |
| 116 | geometry: GeometryConfig | 134 | raster_frame: RasterFrameConfig = RasterFrameConfig() |
| 117 | output: OutputConfig | 135 | geometry: GeometryConfig = GeometryConfig() |
| 118 | file_naming: FileNamingConfig | 136 | output: OutputConfig = OutputConfig() |
| 119 | intensity_separation: dict[str, Any] | 137 | file_naming: FileNamingConfig = FileNamingConfig() |
| 120 | raw: dict[str, Any] | ||
| 121 | 138 | ||
| 122 | @classmethod | 139 | @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. |
| 286 | 214 | ||
| 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 | ||
| 297 | 219 | ||
| 298 | 220 | ||
| 299 | def load_config(config_path: str | Path | None = None) -> dict[str, Any]: | 221 | def 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. |
| 305 | Returns: | 227 | Returns: |
| 306 | The merged, validated configuration. | 228 | The merged, validated configuration. |
| 307 | 229 | ||
| 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) | ||
| 316 | 235 | ||
| 317 | 236 | ||
| 318 | def build_config( | 237 | def build_config( |
| 319 | *, | 238 | *, |
| 1 | """Load, merge, validate and type the mask-clustering configuration. | 1 | """Load, merge, validate and type the mask-clustering configuration. |
| 2 | 2 | ||
| 3 | The packaged JSON default is the schema: unknown keys fail, and every value is | 3 | The pydantic model tree below is the schema and mirrors the packaged JSON |
| 4 | range-checked here rather than at the point of use. Loading and deep-merging are | 4 | default exactly: unknown keys fail, and every value is range-checked here rather |
| 5 | delegated to :mod:`iolabs.common.config_loader`; the coercion and range checks | 5 | than at the point of use. Loading, deep-merging and validation are delegated to |
| 6 | stay local because they encode this step's invariants. | 6 | :mod:`iolabs.common.config_loader`. |
| 7 | 7 | ||
| 8 | :func:`load_config` / :func:`build_config` keep returning plain dicts, because | 8 | :func:`load_config` / :func:`build_config` keep returning plain dicts, because |
| 9 | callers pass ``--set``-style overrides around as dicts and the run manifest | 9 | callers pass ``--set``-style overrides around as dicts and the run manifest |
| 10 | embeds the normalized mapping verbatim. :class:`MaskClusteringConfig` is the | 10 | embeds the normalized mapping verbatim. :class:`MaskClusteringConfig` is the |
| 11 | typed view the pipeline and the CLI actually read, so no production code path | 11 | typed view the pipeline and the CLI actually read, so no production code path |
| 12 | indexes nested config dicts by string. | 12 | indexes nested config dicts by string. |
| 13 | |||
| 14 | Adding 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 | """ |
| 14 | 17 | ||
| 15 | import json | 18 | import json |
| 16 | from dataclasses import dataclass | ||
| 17 | from pathlib import Path | 19 | from pathlib import Path |
| 18 | from typing import Any | 20 | from typing import Any, Literal |
| 19 | 21 | ||
| 20 | from iolabs.common.config_loader import ( | 22 | import pydantic |
| 21 | ConfigError, | 23 | from iolabs.common import config_loader |
| 22 | deep_merge_dicts, | ||
| 23 | load_packaged_json, | ||
| 24 | validate_against_defaults, | ||
| 25 | ) | ||
| 26 | from iolabs.logstash import get_props_logger | 24 | from iolabs.logstash import get_props_logger |
| 27 | 25 | ||
| 28 | from ._log_props import LOG_PROPS | 26 | from ._log_props import LOG_PROPS |
| 29 | 27 |
| 137 | return cls.from_mapping(config) | 154 | return cls.from_mapping(config) |
| 138 | 155 | ||
| 139 | @classmethod | 156 | @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. |
| 142 | 159 | ||
| 143 | Args: | 160 | Args: |
| 144 | config: A raw or already-normalized configuration mapping. | 161 | config: A raw or already-normalized configuration mapping. |
| 145 | 162 |
| 148 | 165 | ||
| 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"] | 172 | def _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 | ) | ||
| 194 | 182 | ||
| 195 | 183 | ||
| 196 | def _validate_values(config: dict[str, Any]) -> None: | 184 | def _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"]) | ||
| 217 | 186 | ||
| 218 | 187 | Raises: | |
| 219 | def _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 | ) |
| 272 | 203 | return loaded | |
| 273 | |||
| 274 | def _defaults() -> dict[str, Any]: | ||
| 275 | return load_packaged_json(PACKAGE, DEFAULT_CONFIG_FILENAME) | ||
| 276 | 204 | ||
| 277 | 205 | ||
| 278 | def normalize_config(raw: dict[str, Any]) -> dict[str, Any]: | 206 | def 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. |
| 329 | Returns: | 248 | Returns: |
| 330 | The merged, validated configuration. | 249 | The merged, validated configuration. |
| 331 | 250 | ||
| 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=MaskClusteringConfigError | 258 | return _load_model(overrides=merged).model_dump() |
| 339 | ) | ||
| 340 | config = deep_merge_dicts(config, overrides) | ||
| 341 | return normalize_config(config) |
| 380 | ) | 383 | ) |
| 381 | 384 | ||
| 382 | separation_pdf_relative: str | None = None | 385 | 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() |
| 387 | 390 | ||
| 388 | versions_path = output_dir / "run6c_versions.json" | 391 | versions_path = output_dir / "run6c_versions.json" |
| 5 | import sys | 5 | import sys |
| 6 | from pathlib import Path | 6 | from pathlib import Path |
| 7 | from typing import Any | 7 | from typing import Any |
| 8 | 8 | ||
| 9 | from iolabs.common.config_loader import parse_set_overrides as _parse_set_overrides | 9 | from iolabs.common import config_loader |
| 10 | from iolabs.common.run_stats import read_stats | 10 | from iolabs.common.run_stats import read_stats |
| 11 | from iolabs.logstash import get_props_logger | 11 | from iolabs.logstash import get_props_logger |
| 12 | 12 | ||
| 13 | from ._config import MaskClusteringConfig, build_config | 13 | from . import _config |
| 14 | from ._log_props import LOG_PROPS | 14 | from ._log_props import LOG_PROPS |
| 15 | from .pipeline import process_segment | 15 | from .pipeline import process_segment |
| 16 | 16 | ||
| 17 | logger = get_props_logger(__name__, LOG_PROPS) | 17 | logger = get_props_logger(__name__, LOG_PROPS) |
| 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.``), or | 72 | ``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) |
| 76 | 76 | ||
| 77 | 77 | ||
| 78 | def _load_jobs(path: Path) -> list[dict[str, Any]]: | 78 | def _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: |
| 118 | return False | 118 | return False |
| 119 | 119 | ||
| 120 | 120 | ||
| 121 | def _run_segment(args: argparse.Namespace) -> int: | 121 | def _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 | ) |
| 140 | 140 | ||
| 141 | def _run_batch(args: argparse.Namespace) -> int: | 141 | def _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 | ) |
| 18 | 18 | ||
| 19 | import cv2 | 19 | import cv2 |
| 20 | import numpy as np | 20 | import numpy as np |
| 21 | 21 | ||
| 22 | from ._config import MaskClusteringConfig, load_config | 22 | from . import _config |
| 23 | from .geometry import mask_polygons_px | 23 | from .geometry import mask_polygons_px |
| 24 | from .mask_components import label_components, load_classified_mask | 24 | from .mask_components import label_components, load_classified_mask |
| 25 | from .types import MaskComponent, SegmentType | 25 | from .types import MaskComponent, SegmentType |
| 26 | 26 |
| 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, |
| 147 | ``source_path`` is a vectors JSON or a single-channel mask PNG (anything | 147 | ``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 config | 152 | _config.load_config(config_path) if config is None else config |
| 153 | ).mask | 153 | ).mask |
| 154 | 154 | ||
| 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]) |
| 194 | geometry_dir.mkdir(parents=True, exist_ok=True) | 194 | geometry_dir.mkdir(parents=True, exist_ok=True) |
| 195 | cluster_prefix = cfg.output.cluster_prefix | 195 | cluster_prefix = cfg.output.cluster_prefix |
| 196 | cluster_cfg = cfg.clusters | 196 | cluster_cfg = cfg.clusters |
| 197 | geometry_cfg = cfg.geometry | 197 | geometry_cfg = cfg.geometry |
| 198 | separation_cfg = cfg.intensity_separation | 198 | 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) per | 204 | # 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 a | 205 | # 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 more | 211 | # 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 segment | 212 | # 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 dilated | 213 | # once, up front, when separation is enabled. (Cropping to the union of dilated |
| 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 components | 423 | item.segment_type is types.SegmentType.SOLID for item in components |
| 1 | import json | ||
| 2 | from importlib import resources | ||
| 3 | |||
| 1 | import pytest | 4 | import pytest |
| 2 | 5 | ||
| 6 | from iolabs_point_cloud_mask_clustering import _config | ||
| 3 | from iolabs_point_cloud_mask_clustering._config import ( | 7 | from iolabs_point_cloud_mask_clustering._config import ( |
| 4 | MaskClusteringConfigError, | 8 | MaskClusteringConfigError, |
| 5 | build_config, | 9 | build_config, |
| 6 | load_config, | 10 | load_config, |
| 30 | ) | 34 | ) |
| 31 | def test_invalid_configuration_fails(overrides: dict) -> None: | 35 | def 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 | |||
| 40 | def 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 | ) | ||
| 63 | def test_invalid_intensity_separation_fails(overrides: dict) -> None: | ||
| 64 | with pytest.raises(MaskClusteringConfigError): | ||
| 65 | build_config(overrides=overrides) | ||
| 66 | |||
| 67 | |||
| 68 | def 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 | |||
| 75 | def 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 | |||
| 82 | def 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 | |||
| 95 | def 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}}) |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-point-cloud-mask-clustering" | 2 | name = "iolabs-point-cloud-mask-clustering" |
| 3 | version = "0.3.1" | 3 | version = "0.3.2" |
| 4 | description = "Convert classified road-marking masks into Step 7-compatible point-cloud clusters" | 4 | description = "Convert classified road-marking masks into Step 7-compatible point-cloud clusters" |
| 5 | requires-python = ">=3.11,<3.13" | 5 | requires-python = ">=3.11,<3.13" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 7 | "numpy>=1.26", | 7 | "numpy>=1.26", |
| 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 | ] |
| 8 | ## Conventions | 8 | ## Conventions |
| 9 | 9 | ||
| 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. |
| 71 | 71 | ||
| 72 | Thresholds are configurable, with | 72 | Thresholds are configurable, with |
| 73 | `0 <= min_points_per_cluster <= warn_below_points`. | 73 | `0 <= min_points_per_cluster <= warn_below_points`. |
| 74 | 74 | ||
| 75 | ## Configuration | ||
| 76 | |||
| 77 | Defaults live in `src/iolabs_point_cloud_mask_clustering/mask_clustering.default.json` | ||
| 78 | and are typed by the pydantic model tree in `_config.py` | ||
| 79 | (`iolabs.common.config_loader.ConfigModel`). Unknown keys and out-of-range values | ||
| 80 | fail loudly; `--config` files and `--set` overrides are deep-merged onto the | ||
| 81 | defaults and re-validated. | ||
| 82 | |||
| 83 | Adding a config key: add the field (with its type, default and any range | ||
| 84 | constraint) to the matching model in `_config.py`, and add the same key to | ||
| 85 | `mask_clustering.default.json`. Nothing else. | ||
| 86 | |||
| 75 | ## CLI | 87 | ## CLI |
| 76 | 88 | ||
| 77 | Install development dependencies: | 89 | Install development dependencies: |
| 78 | 90 |
| 562 | ] | 562 | ] |
| 563 | 563 | ||
| 564 | [[package]] | 564 | [[package]] |
| 565 | name = "iolabs-point-cloud-mask-clustering" | 565 | name = "iolabs-point-cloud-mask-clustering" |
| 566 | version = "0.3.1" | 566 | version = "0.3.2" |
| 567 | source = { editable = "." } | 567 | source = { editable = "." } |
| 568 | dependencies = [ | 568 | dependencies = [ |
| 569 | { name = "iolabs-common" }, | 569 | { name = "iolabs-common" }, |
| 570 | { name = "iolabs-geometry-raster" }, | 570 | { name = "iolabs-geometry-raster" }, |
| 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 | ] |
| 580 | 581 | ||
| 581 | [package.optional-dependencies] | 582 | [package.optional-dependencies] |
| 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 | ] |
| 601 | provides-extras = ["dev"] | 603 | provides-extras = ["dev"] |
| 71 | 71 | ||
| 72 | Thresholds are configurable, with | 72 | Thresholds are configurable, with |
| 73 | `0 <= min_points_per_cluster <= warn_below_points`. | 73 | `0 <= min_points_per_cluster <= warn_below_points`. |
| 74 | 74 | ||
| 75 | ## Configuration | ||
| 76 | |||
| 77 | Defaults live in `src/iolabs_point_cloud_mask_clustering/mask_clustering.default.json` | ||
| 78 | and are typed by the pydantic model tree in `_config.py` | ||
| 79 | (`iolabs.common.config_loader.ConfigModel`). Unknown keys and out-of-range values | ||
| 80 | fail loudly; `--config` files and `--set` overrides are deep-merged onto the | ||
| 81 | defaults and re-validated. | ||
| 82 | |||
| 83 | Adding a config key: add the field (with its type, default and any range | ||
| 84 | constraint) to the matching model in `_config.py`, and add the same key to | ||
| 85 | `mask_clustering.default.json`. Nothing else. | ||
| 86 | |||
| 75 | ## CLI | 87 | ## CLI |
| 76 | 88 | ||
| 77 | Install development dependencies: | 89 | Install development dependencies: |
| 78 | 90 |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-point-cloud-mask-clustering" | 2 | name = "iolabs-point-cloud-mask-clustering" |
| 3 | version = "0.3.1" | 3 | version = "0.3.2" |
| 4 | description = "Convert classified road-marking masks into Step 7-compatible point-cloud clusters" | 4 | description = "Convert classified road-marking masks into Step 7-compatible point-cloud clusters" |
| 5 | requires-python = ">=3.11,<3.13" | 5 | requires-python = ">=3.11,<3.13" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 7 | "numpy>=1.26", | 7 | "numpy>=1.26", |
| 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 | ] |
| 1 | """Load, merge, validate and type the mask-clustering configuration. | 1 | """Load, merge, validate and type the mask-clustering configuration. |
| 2 | 2 | ||
| 3 | The packaged JSON default is the schema: unknown keys fail, and every value is | 3 | The pydantic model tree below is the schema and mirrors the packaged JSON |
| 4 | range-checked here rather than at the point of use. Loading and deep-merging are | 4 | default exactly: unknown keys fail, and every value is range-checked here rather |
| 5 | delegated to :mod:`iolabs.common.config_loader`; the coercion and range checks | 5 | than at the point of use. Loading, deep-merging and validation are delegated to |
| 6 | stay local because they encode this step's invariants. | 6 | :mod:`iolabs.common.config_loader`. |
| 7 | 7 | ||
| 8 | :func:`load_config` / :func:`build_config` keep returning plain dicts, because | 8 | :func:`load_config` / :func:`build_config` keep returning plain dicts, because |
| 9 | callers pass ``--set``-style overrides around as dicts and the run manifest | 9 | callers pass ``--set``-style overrides around as dicts and the run manifest |
| 10 | embeds the normalized mapping verbatim. :class:`MaskClusteringConfig` is the | 10 | embeds the normalized mapping verbatim. :class:`MaskClusteringConfig` is the |
| 11 | typed view the pipeline and the CLI actually read, so no production code path | 11 | typed view the pipeline and the CLI actually read, so no production code path |
| 12 | indexes nested config dicts by string. | 12 | indexes nested config dicts by string. |
| 13 | |||
| 14 | Adding 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 | """ |
| 14 | 17 | ||
| 15 | import json | 18 | import json |
| 16 | from dataclasses import dataclass | ||
| 17 | from pathlib import Path | 19 | from pathlib import Path |
| 18 | from typing import Any | 20 | from typing import Any, Literal |
| 19 | 21 | ||
| 20 | from iolabs.common.config_loader import ( | 22 | import pydantic |
| 21 | ConfigError, | 23 | from iolabs.common import config_loader |
| 22 | deep_merge_dicts, | ||
| 23 | load_packaged_json, | ||
| 24 | validate_against_defaults, | ||
| 25 | ) | ||
| 26 | from iolabs.logstash import get_props_logger | 24 | from iolabs.logstash import get_props_logger |
| 27 | 25 | ||
| 28 | from ._log_props import LOG_PROPS | 26 | from ._log_props import LOG_PROPS |
| 29 | 27 |
| 32 | PACKAGE = "iolabs_point_cloud_mask_clustering" | 30 | PACKAGE = "iolabs_point_cloud_mask_clustering" |
| 33 | DEFAULT_CONFIG_FILENAME = "mask_clustering.default.json" | 31 | DEFAULT_CONFIG_FILENAME = "mask_clustering.default.json" |
| 34 | 32 | ||
| 35 | 33 | ||
| 36 | class MaskClusteringConfigError(ConfigError): | 34 | class MaskClusteringConfigError(config_loader.ConfigError): |
| 37 | """Raised when mask-clustering configuration is invalid.""" | 35 | """Raised when mask-clustering configuration is invalid.""" |
| 38 | 36 | ||
| 39 | 37 | ||
| 40 | @dataclass(frozen=True) | 38 | class MaskConfig(config_loader.ConfigModel): |
| 41 | class MaskConfig: | ||
| 42 | """Mask rasterisation and labelling settings.""" | 39 | """Mask rasterisation and labelling settings.""" |
| 43 | 40 | ||
| 44 | background_class: int | 41 | background_class: int = 0 |
| 45 | solid_class: int | 42 | solid_class: int = 1 |
| 46 | dashed_class: int | 43 | dashed_class: int = 2 |
| 47 | connectivity: int | 44 | connectivity: Literal[4, 8] = 8 |
| 48 | vector_stroke_px: int | 45 | vector_stroke_px: int = pydantic.Field(default=4, ge=1) |
| 49 | 46 | ||
| 50 | 47 | ||
| 51 | @dataclass(frozen=True) | 48 | class ClustersConfig(config_loader.ConfigModel): |
| 52 | class ClustersConfig: | ||
| 53 | """Sparse-cluster thresholds.""" | 49 | """Sparse-cluster thresholds.""" |
| 54 | 50 | ||
| 55 | min_points_per_cluster: int | 51 | min_points_per_cluster: int = pydantic.Field(default=20, ge=0) |
| 56 | warn_below_points: int | 52 | warn_below_points: int = pydantic.Field(default=200, ge=0) |
| 57 | |||
| 58 | 53 | ||
| 59 | @dataclass(frozen=True) | 54 | @pydantic.model_validator(mode="after") |
| 60 | class 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 | |||
| 65 | class 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 | |||
| 88 | class RasterFrameConfig(config_loader.ConfigModel): | ||
| 61 | """Tolerances used when reconstructing the raster frame.""" | 89 | """Tolerances used when reconstructing the raster frame.""" |
| 62 | 90 | ||
| 63 | margin_pixels: float | 91 | margin_pixels: float = pydantic.Field(default=1.0, ge=0.0) |
| 64 | metadata_origin_tolerance_pixels: float | 92 | metadata_origin_tolerance_pixels: float = pydantic.Field(default=0.25, ge=0.0) |
| 65 | 93 | ||
| 66 | 94 | ||
| 67 | @dataclass(frozen=True) | 95 | class GeometryConfig(config_loader.ConfigModel): |
| 68 | class GeometryConfig: | ||
| 69 | """Which diagnostic geometry artifacts to write.""" | 96 | """Which diagnostic geometry artifacts to write.""" |
| 70 | 97 | ||
| 71 | write_geojson: bool | 98 | write_geojson: bool = True |
| 72 | write_ply: bool | 99 | write_ply: bool = True |
| 73 | simplify_tolerance_px: float | 100 | simplify_tolerance_px: float = 0.0 |
| 74 | 101 | ||
| 75 | 102 | ||
| 76 | @dataclass(frozen=True) | 103 | class OutputConfig(config_loader.ConfigModel): |
| 77 | class OutputConfig: | ||
| 78 | """Output directory and file-name layout.""" | 104 | """Output directory and file-name layout.""" |
| 79 | 105 | ||
| 80 | cluster_dir: str | 106 | cluster_dir: str = "clusters_mask" |
| 81 | cluster_prefix: str | 107 | cluster_prefix: str = "run6_cluster_" |
| 82 | geometry_dir: str | 108 | geometry_dir: str = "mask_geometry" |
| 83 | manifest_filename: str | 109 | manifest_filename: str = "mask_clustering_manifest.json" |
| 84 | 110 | ||
| 85 | 111 | ||
| 86 | @dataclass(frozen=True) | 112 | class FileNamingConfig(config_loader.ConfigModel): |
| 87 | class FileNamingConfig: | ||
| 88 | """How Step 3 inputs are discovered inside a segment directory.""" | 113 | """How Step 3 inputs are discovered inside a segment directory.""" |
| 89 | 114 | ||
| 90 | segment_points_suffix: str | 115 | segment_points_suffix: str = "_run3_points.npz" |
| 91 | |||
| 92 | 116 | ||
| 93 | @dataclass(frozen=True) | ||
| 94 | class MaskClusteringConfig: | ||
| 95 | """Typed view over a normalized configuration mapping. | ||
| 96 | 117 | ||
| 97 | ``intensity_separation`` stays a mapping: it is consumed key-by-key deep | 118 | class MaskClusteringConfig(config_loader.ConfigModel): |
| 98 | inside :mod:`.intensity_separation`, where a mechanical field-by-field | 119 | """Typed, validated mask-clustering configuration. |
| 99 | conversion would buy nothing. ``raw`` is the normalized mapping the manifest | ||
| 100 | records verbatim. | ||
| 101 | 120 | ||
| 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 | """ |
| 112 | 130 | ||
| 113 | mask: MaskConfig | 131 | mask: MaskConfig = MaskConfig() |
| 114 | clusters: ClustersConfig | 132 | clusters: ClustersConfig = ClustersConfig() |
| 115 | raster_frame: RasterFrameConfig | 133 | intensity_separation: IntensitySeparationConfig = IntensitySeparationConfig() |
| 116 | geometry: GeometryConfig | 134 | raster_frame: RasterFrameConfig = RasterFrameConfig() |
| 117 | output: OutputConfig | 135 | geometry: GeometryConfig = GeometryConfig() |
| 118 | file_naming: FileNamingConfig | 136 | output: OutputConfig = OutputConfig() |
| 119 | intensity_separation: dict[str, Any] | 137 | file_naming: FileNamingConfig = FileNamingConfig() |
| 120 | raw: dict[str, Any] | ||
| 121 | 138 | ||
| 122 | @classmethod | 139 | @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. |
| 137 | return cls.from_mapping(config) | 154 | return cls.from_mapping(config) |
| 138 | 155 | ||
| 139 | @classmethod | 156 | @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. |
| 142 | 159 | ||
| 143 | Args: | 160 | Args: |
| 144 | config: A raw or already-normalized configuration mapping. | 161 | config: A raw or already-normalized configuration mapping. |
| 145 | 162 |
| 148 | 165 | ||
| 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"] | 172 | def _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 | ) | ||
| 194 | 182 | ||
| 195 | 183 | ||
| 196 | def _validate_values(config: dict[str, Any]) -> None: | 184 | def _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"]) | ||
| 217 | 186 | ||
| 218 | 187 | Raises: | |
| 219 | def _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 | ) |
| 272 | 203 | return loaded | |
| 273 | |||
| 274 | def _defaults() -> dict[str, Any]: | ||
| 275 | return load_packaged_json(PACKAGE, DEFAULT_CONFIG_FILENAME) | ||
| 276 | 204 | ||
| 277 | 205 | ||
| 278 | def normalize_config(raw: dict[str, Any]) -> dict[str, Any]: | 206 | def 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. |
| 286 | 214 | ||
| 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 | ||
| 297 | 219 | ||
| 298 | 220 | ||
| 299 | def load_config(config_path: str | Path | None = None) -> dict[str, Any]: | 221 | def 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. |
| 305 | Returns: | 227 | Returns: |
| 306 | The merged, validated configuration. | 228 | The merged, validated configuration. |
| 307 | 229 | ||
| 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) | ||
| 316 | 235 | ||
| 317 | 236 | ||
| 318 | def build_config( | 237 | def build_config( |
| 319 | *, | 238 | *, |
| 329 | Returns: | 248 | Returns: |
| 330 | The merged, validated configuration. | 249 | The merged, validated configuration. |
| 331 | 250 | ||
| 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=MaskClusteringConfigError | 258 | return _load_model(overrides=merged).model_dump() |
| 339 | ) | ||
| 340 | config = deep_merge_dicts(config, overrides) | ||
| 341 | return normalize_config(config) |
| 5 | import sys | 5 | import sys |
| 6 | from pathlib import Path | 6 | from pathlib import Path |
| 7 | from typing import Any | 7 | from typing import Any |
| 8 | 8 | ||
| 9 | from iolabs.common.config_loader import parse_set_overrides as _parse_set_overrides | 9 | from iolabs.common import config_loader |
| 10 | from iolabs.common.run_stats import read_stats | 10 | from iolabs.common.run_stats import read_stats |
| 11 | from iolabs.logstash import get_props_logger | 11 | from iolabs.logstash import get_props_logger |
| 12 | 12 | ||
| 13 | from ._config import MaskClusteringConfig, build_config | 13 | from . import _config |
| 14 | from ._log_props import LOG_PROPS | 14 | from ._log_props import LOG_PROPS |
| 15 | from .pipeline import process_segment | 15 | from .pipeline import process_segment |
| 16 | 16 | ||
| 17 | logger = get_props_logger(__name__, LOG_PROPS) | 17 | logger = get_props_logger(__name__, LOG_PROPS) |
| 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.``), or | 72 | ``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) |
| 76 | 76 | ||
| 77 | 77 | ||
| 78 | def _load_jobs(path: Path) -> list[dict[str, Any]]: | 78 | def _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: |
| 118 | return False | 118 | return False |
| 119 | 119 | ||
| 120 | 120 | ||
| 121 | def _run_segment(args: argparse.Namespace) -> int: | 121 | def _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 | ) |
| 140 | 140 | ||
| 141 | def _run_batch(args: argparse.Namespace) -> int: | 141 | def _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 | ) |
| 18 | 18 | ||
| 19 | import cv2 | 19 | import cv2 |
| 20 | import numpy as np | 20 | import numpy as np |
| 21 | 21 | ||
| 22 | from ._config import MaskClusteringConfig, load_config | 22 | from . import _config |
| 23 | from .geometry import mask_polygons_px | 23 | from .geometry import mask_polygons_px |
| 24 | from .mask_components import label_components, load_classified_mask | 24 | from .mask_components import label_components, load_classified_mask |
| 25 | from .types import MaskComponent, SegmentType | 25 | from .types import MaskComponent, SegmentType |
| 26 | 26 |
| 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, |
| 147 | ``source_path`` is a vectors JSON or a single-channel mask PNG (anything | 147 | ``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 config | 152 | _config.load_config(config_path) if config is None else config |
| 153 | ).mask | 153 | ).mask |
| 154 | 154 | ||
| 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]) |
| 194 | geometry_dir.mkdir(parents=True, exist_ok=True) | 194 | geometry_dir.mkdir(parents=True, exist_ok=True) |
| 195 | cluster_prefix = cfg.output.cluster_prefix | 195 | cluster_prefix = cfg.output.cluster_prefix |
| 196 | cluster_cfg = cfg.clusters | 196 | cluster_cfg = cfg.clusters |
| 197 | geometry_cfg = cfg.geometry | 197 | geometry_cfg = cfg.geometry |
| 198 | separation_cfg = cfg.intensity_separation | 198 | 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) per | 204 | # 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 a | 205 | # 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 more | 211 | # 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 segment | 212 | # 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 dilated | 213 | # once, up front, when separation is enabled. (Cropping to the union of dilated |
| 380 | ) | 383 | ) |
| 381 | 384 | ||
| 382 | separation_pdf_relative: str | None = None | 385 | 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() |
| 387 | 390 | ||
| 388 | versions_path = output_dir / "run6c_versions.json" | 391 | versions_path = output_dir / "run6c_versions.json" |
| 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 components | 423 | item.segment_type is types.SegmentType.SOLID for item in components |
| 1 | import json | ||
| 2 | from importlib import resources | ||
| 3 | |||
| 1 | import pytest | 4 | import pytest |
| 2 | 5 | ||
| 6 | from iolabs_point_cloud_mask_clustering import _config | ||
| 3 | from iolabs_point_cloud_mask_clustering._config import ( | 7 | from iolabs_point_cloud_mask_clustering._config import ( |
| 4 | MaskClusteringConfigError, | 8 | MaskClusteringConfigError, |
| 5 | build_config, | 9 | build_config, |
| 6 | load_config, | 10 | load_config, |
| 30 | ) | 34 | ) |
| 31 | def test_invalid_configuration_fails(overrides: dict) -> None: | 35 | def 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 | |||
| 40 | def 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 | ) | ||
| 63 | def test_invalid_intensity_separation_fails(overrides: dict) -> None: | ||
| 64 | with pytest.raises(MaskClusteringConfigError): | ||
| 65 | build_config(overrides=overrides) | ||
| 66 | |||
| 67 | |||
| 68 | def 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 | |||
| 75 | def 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 | |||
| 82 | def 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 | |||
| 95 | def 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}}) |
| 562 | ] | 562 | ] |
| 563 | 563 | ||
| 564 | [[package]] | 564 | [[package]] |
| 565 | name = "iolabs-point-cloud-mask-clustering" | 565 | name = "iolabs-point-cloud-mask-clustering" |
| 566 | version = "0.3.1" | 566 | version = "0.3.2" |
| 567 | source = { editable = "." } | 567 | source = { editable = "." } |
| 568 | dependencies = [ | 568 | dependencies = [ |
| 569 | { name = "iolabs-common" }, | 569 | { name = "iolabs-common" }, |
| 570 | { name = "iolabs-geometry-raster" }, | 570 | { name = "iolabs-geometry-raster" }, |
| 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 | ] |
| 580 | 581 | ||
| 581 | [package.optional-dependencies] | 582 | [package.optional-dependencies] |
| 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 | ] |
| 601 | provides-extras = ["dev"] | 603 | provides-extras = ["dev"] |
ConfigModel: nested section models mirror the packaged*.default.jsonkey for key; whitelist sets and hand-rolled coercion deleted; loader built onconfig_loader.load_config. Public entry-point names and return types unchanged so lanefinder wrappers keep working.pydantic>=2.7dependency.