Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:40:09+02:00
Commit #39 ยท 31 snippets
AGENTS.md | 8 +- README.md | 19 +-- src/iolabs_point_cloud_mask_clustering/__init__.py | 12 ++ src/iolabs_point_cloud_mask_clustering/_config.py | 157 +++++++++------------ src/iolabs_point_cloud_mask_clustering/cli.py | 6 +- tests/test_config.py | 111 +++++++++------ 6 files changed, 168 insertions(+), 145 deletions(-)
| 27 | from ._log_props import LOG_PROPS | 26 | from ._log_props import LOG_PROPS |
| 28 | 27 | ||
| 29 | logger = get_props_logger(__name__, LOG_PROPS) | 28 | logger = get_props_logger(__name__, LOG_PROPS) |
| 30 | 29 | ||
| 31 | PACKAGE = "iolabs_point_cloud_mask_clustering" | 30 | _PACKAGE_NAME = "iolabs_point_cloud_mask_clustering" |
| 32 | DEFAULT_CONFIG_FILENAME = "mask_clustering.default.json" | 31 | _DEFAULT_FILENAME = "mask_clustering.default.json" |
| 32 | _CONTEXT = "mask clustering config" | ||
| 33 | 33 | ||
| 34 | 34 | ||
| 35 | class MaskClusteringConfigError(config_loader.ConfigError): | 35 | class MaskClusteringConfigError(config_loader.ConfigError): |
| 36 | """Raised when mask-clustering configuration is invalid.""" | 36 | """Raised when mask clustering config contains unsupported keys or values.""" |
| 37 | 37 | ||
| 38 | 38 | ||
| 39 | class MaskConfig(config_loader.ConfigModel): | 39 | class MaskClusteringMaskConfig(config_loader.ConfigModel): |
| 40 | """Mask rasterisation and labelling settings.""" | 40 | """Mask rasterisation and labelling settings.""" |
| 41 | 41 | ||
| 42 | background_class: int = 0 | 42 | background_class: int = 0 |
| 43 | solid_class: int = 1 | 43 | solid_class: int = 1 |
| 53 | raise ValueError("mask.connectivity must be 4 or 8") | 53 | raise ValueError("mask.connectivity must be 4 or 8") |
| 54 | return value | 54 | return value |
| 55 | 55 | ||
| 56 | 56 | ||
| 57 | class ClustersConfig(config_loader.ConfigModel): | 57 | class MaskClusteringClustersConfig(config_loader.ConfigModel): |
| 58 | """Sparse-cluster thresholds.""" | 58 | """Sparse-cluster thresholds.""" |
| 59 | 59 | ||
| 60 | min_points_per_cluster: int = pydantic.Field(default=20, ge=0) | 60 | min_points_per_cluster: int = pydantic.Field(default=20, ge=0) |
| 61 | warn_below_points: int = pydantic.Field(default=200, ge=0) | 61 | warn_below_points: int = pydantic.Field(default=200, ge=0) |
| 62 | 62 | ||
| 63 | @pydantic.model_validator(mode="after") | 63 | @pydantic.model_validator(mode="after") |
| 64 | def _check_thresholds(self) -> "ClustersConfig": | 64 | def _check_thresholds(self) -> MaskClusteringClustersConfig: |
| 65 | """Reject a minimum above the warning threshold.""" | 65 | """Reject a minimum above the warning threshold.""" |
| 66 | if self.min_points_per_cluster > self.warn_below_points: | 66 | if self.min_points_per_cluster > self.warn_below_points: |
| 67 | raise ValueError( | 67 | raise ValueError( |
| 68 | "clusters thresholds must satisfy " | 68 | "clusters thresholds must satisfy " |
| 70 | ) | 70 | ) |
| 71 | return self | 71 | return self |
| 72 | 72 | ||
| 73 | 73 | ||
| 74 | class IntensitySeparationConfig(config_loader.ConfigModel): | 74 | class MaskClusteringIntensitySeparationConfig(config_loader.ConfigModel): |
| 75 | """Paint/asphalt intensity-separation settings.""" | 75 | """Paint/asphalt intensity-separation settings.""" |
| 76 | 76 | ||
| 77 | enabled: bool = True | 77 | enabled: bool = True |
| 78 | apply_filter: bool = True | 78 | apply_filter: bool = True |
| 93 | clusters_per_page: int = pydantic.Field(default=3, ge=1) | 93 | clusters_per_page: int = pydantic.Field(default=3, ge=1) |
| 94 | pdf_filename: str = "intensity_separation.pdf" | 94 | pdf_filename: str = "intensity_separation.pdf" |
| 95 | 95 | ||
| 96 | 96 | ||
| 97 | class RasterFrameConfig(config_loader.ConfigModel): | 97 | class MaskClusteringRasterFrameConfig(config_loader.ConfigModel): |
| 98 | """Tolerances used when reconstructing the raster frame.""" | 98 | """Tolerances used when reconstructing the raster frame.""" |
| 99 | 99 | ||
| 100 | margin_pixels: float = pydantic.Field(default=1.0, ge=0.0) | 100 | margin_pixels: float = pydantic.Field(default=1.0, ge=0.0) |
| 101 | metadata_origin_tolerance_pixels: float = pydantic.Field(default=0.25, ge=0.0) | 101 | metadata_origin_tolerance_pixels: float = pydantic.Field(default=0.25, ge=0.0) |
| 102 | 102 | ||
| 103 | 103 | ||
| 104 | class GeometryConfig(config_loader.ConfigModel): | 104 | class MaskClusteringGeometryConfig(config_loader.ConfigModel): |
| 105 | """Which diagnostic geometry artifacts to write.""" | 105 | """Which diagnostic geometry artifacts to write.""" |
| 106 | 106 | ||
| 107 | write_geojson: bool = True | 107 | write_geojson: bool = True |
| 108 | write_ply: bool = True | 108 | write_ply: bool = True |
| 109 | simplify_tolerance_px: float = 0.0 | 109 | simplify_tolerance_px: float = 0.0 |
| 110 | 110 | ||
| 111 | 111 | ||
| 112 | class OutputConfig(config_loader.ConfigModel): | 112 | class MaskClusteringOutputConfig(config_loader.ConfigModel): |
| 113 | """Output directory and file-name layout.""" | 113 | """Output directory and file-name layout.""" |
| 114 | 114 | ||
| 115 | cluster_dir: str = "clusters_mask" | 115 | cluster_dir: str = "clusters_mask" |
| 116 | cluster_prefix: str = "run6_cluster_" | 116 | cluster_prefix: str = "run6_cluster_" |
| 117 | geometry_dir: str = "mask_geometry" | 117 | geometry_dir: str = "mask_geometry" |
| 118 | manifest_filename: str = "mask_clustering_manifest.json" | 118 | manifest_filename: str = "mask_clustering_manifest.json" |
| 119 | 119 | ||
| 120 | 120 | ||
| 121 | class FileNamingConfig(config_loader.ConfigModel): | 121 | class MaskClusteringFileNamingConfig(config_loader.ConfigModel): |
| 122 | """How Step 3 inputs are discovered inside a segment directory.""" | 122 | """How Step 3 inputs are discovered inside a segment directory.""" |
| 123 | 123 | ||
| 124 | segment_points_suffix: str = "_run3_points.npz" | 124 | segment_points_suffix: str = "_run3_points.npz" |
| 125 | 125 |
| 174 | 176 | ||
| 175 | Raises: | 177 | Raises: |
| 176 | MaskClusteringConfigError: The mapping is not a valid configuration. | 178 | MaskClusteringConfigError: The mapping is not a valid configuration. |
| 177 | """ | 179 | """ |
| 178 | return _load_model(overrides=_as_mapping(config)) | 180 | return config_loader.validate_config( |
| 179 | 181 | cls, config, context=_CONTEXT, error_cls=MaskClusteringConfigError | |
| 180 | |||
| 181 | def _as_mapping(config: Any) -> dict[str, Any]: | ||
| 182 | """Return *config* as a dict, rejecting values that are not mappings. | ||
| 183 | |||
| 184 | Raises: | ||
| 185 | MaskClusteringConfigError: *config* is not a mapping (``None`` included). | ||
| 186 | """ | ||
| 187 | if not isinstance(config, Mapping): | ||
| 188 | raise MaskClusteringConfigError( | ||
| 189 | f"config must be a mapping, got {type(config).__name__}" | ||
| 190 | ) | 182 | ) |
| 191 | return dict(config) | ||
| 192 | 183 | ||
| 193 | 184 | ||
| 194 | def _load_model(overrides: dict[str, Any] | None = None) -> MaskClusteringConfig: | 185 | def _load_model( |
| 195 | """Merge *overrides* onto the packaged defaults and validate the result.""" | 186 | *, |
| 187 | overrides: Mapping[str, Any] | None = None, | ||
| 188 | config_path: str | Path | None = None, | ||
| 189 | ) -> MaskClusteringConfig: | ||
| 190 | """Merge a config file and *overrides* onto the packaged defaults and validate.""" | ||
| 191 | merged: dict[str, Any] = dict(overrides or {}) | ||
| 192 | if config_path is not None: | ||
| 193 | file_overrides = config_loader.load_json_overrides( | ||
| 194 | config_path, error_cls=MaskClusteringConfigError | ||
| 195 | ) | ||
| 196 | merged = config_loader.deep_merge_dicts(file_overrides, merged) | ||
| 197 | logger.info("Config file applied: %s", config_path) | ||
| 198 | if merged: | ||
| 199 | logger.info("Config overrides applied: %s", ", ".join(sorted(merged))) | ||
| 196 | return config_loader.load_config( | 200 | return config_loader.load_config( |
| 197 | MaskClusteringConfig, | 201 | MaskClusteringConfig, |
| 198 | package=PACKAGE, | 202 | package=_PACKAGE_NAME, |
| 199 | filename=DEFAULT_CONFIG_FILENAME, | 203 | filename=_DEFAULT_FILENAME, |
| 200 | overrides=overrides, | 204 | overrides=merged, |
| 201 | context="config", | 205 | context=_CONTEXT, |
| 202 | error_cls=MaskClusteringConfigError, | 206 | error_cls=MaskClusteringConfigError, |
| 203 | ) | 207 | ) |
| 204 | 208 | ||
| 205 | 209 | ||
| 206 | def _read_overrides(config_path: str | Path | None) -> dict[str, Any]: | 210 | def normalize_config(raw: Mapping[str, Any]) -> dict[str, Any]: |
| 207 | """Read a JSON override file, or return an empty mapping when there is none. | 211 | """Validate *raw* and fill in the model defaults. |
| 208 | |||
| 209 | Raises: | ||
| 210 | MaskClusteringConfigError: The file is not valid JSON, or does not hold | ||
| 211 | a JSON object. | ||
| 212 | """ | ||
| 213 | if config_path is None: | ||
| 214 | return {} | ||
| 215 | path = Path(config_path) | ||
| 216 | try: | ||
| 217 | with path.open(encoding="utf-8") as handle: | ||
| 218 | loaded = json.load(handle) | ||
| 219 | except json.JSONDecodeError as exc: | ||
| 220 | raise MaskClusteringConfigError(f"Invalid JSON in config file {path}: {exc}") from exc | ||
| 221 | if not isinstance(loaded, dict): | ||
| 222 | raise MaskClusteringConfigError( | ||
| 223 | f"Config file {path} must hold a JSON object, got {type(loaded).__name__}" | ||
| 224 | ) | ||
| 225 | return loaded | ||
| 226 | |||
| 227 | |||
| 228 | def normalize_config(raw: dict[str, Any]) -> dict[str, Any]: | ||
| 229 | """Merge *raw* onto the packaged defaults and validate the result. | ||
| 230 | 212 | ||
| 231 | Args: | 213 | Args: |
| 232 | raw: Partial configuration mapping. | 214 | raw: Partial configuration mapping. |
| 233 | 215 | ||
| 234 | Returns: | 216 | Returns: |
| 235 | The merged, validated configuration. | 217 | The validated configuration. |
| 236 | 218 | ||
| 237 | Raises: | 219 | Raises: |
| 238 | MaskClusteringConfigError: An unknown key or an out-of-range value. | 220 | MaskClusteringConfigError: An unknown key or an out-of-range value. |
| 239 | """ | 221 | """ |
| 240 | return _load_model(overrides=_as_mapping(raw)).model_dump() | 222 | return MaskClusteringConfig.from_mapping(raw).model_dump() |
| 241 | 223 | ||
| 242 | 224 | ||
| 243 | def load_config(config_path: str | Path | None = None) -> dict[str, Any]: | 225 | def load_config(config_path: str | Path | None = None) -> dict[str, Any]: |
| 244 | """Load a configuration JSON, or the packaged defaults when *config_path* is None. | 226 | """Load the packaged defaults, merging a config file onto them when given. |
| 245 | 227 | ||
| 246 | Args: | 228 | Args: |
| 247 | config_path: Path to a JSON file holding partial overrides. | 229 | config_path: Path to a JSON file holding partial overrides. It is |
| 230 | merged onto the packaged defaults rather than replacing them. | ||
| 248 | 231 | ||
| 249 | Returns: | 232 | Returns: |
| 250 | The merged, validated configuration. | 233 | The merged, validated configuration. |
| 251 | 234 | ||
| 252 | Raises: | 235 | Raises: |
| 253 | MaskClusteringConfigError: A malformed config file, an unknown key or an | 236 | MaskClusteringConfigError: A malformed config file, an unknown key or an |
| 254 | out-of-range value. | 237 | out-of-range value. |
| 255 | """ | 238 | """ |
| 256 | return _load_model(overrides=_read_overrides(config_path)).model_dump() | 239 | return _load_model(config_path=config_path).model_dump() |
| 257 | 240 | ||
| 258 | 241 | ||
| 259 | def build_config( | 242 | def build_config( |
| 260 | *, | 243 | *, |
| 261 | overrides: dict[str, Any] | None = None, | 244 | overrides: Mapping[str, Any] | None = None, |
| 262 | config_path: str | Path | None = None, | 245 | config_path: str | Path | None = None, |
| 263 | ) -> dict[str, Any]: | 246 | ) -> dict[str, Any]: |
| 264 | """Load a configuration file and apply in-memory overrides on top of it. | 247 | """Load a configuration file and apply in-memory overrides on top of it. |
| 265 | 248 | ||
| 266 | Args: | 249 | Args: |
| 267 | overrides: Nested override mapping, e.g. from CLI ``--set`` flags. | 250 | overrides: Nested override mapping, e.g. from CLI ``--set`` flags. |
| 268 | config_path: Path to a JSON file holding partial overrides. | 251 | config_path: Path to a JSON file holding partial overrides. It is |
| 252 | merged onto the packaged defaults rather than replacing them. | ||
| 269 | 253 | ||
| 270 | Returns: | 254 | Returns: |
| 271 | The merged, validated configuration. | 255 | The merged, validated configuration. |
| 272 | 256 | ||
| 273 | Raises: | 257 | Raises: |
| 274 | MaskClusteringConfigError: A malformed config file, an unknown key or an | 258 | MaskClusteringConfigError: A malformed config file, an unknown key or an |
| 275 | out-of-range value. | 259 | out-of-range value. |
| 276 | """ | 260 | """ |
| 277 | merged = config_loader.deep_merge_dicts( | 261 | return _load_model(overrides=overrides, config_path=config_path).model_dump() |
| 278 | _read_overrides(config_path), dict(overrides or {}) | ||
| 279 | ) | ||
| 280 | return _load_model(overrides=merged).model_dump() |
| 1 | """Load, merge, validate and type the mask-clustering configuration. | 1 | """Mask-clustering configuration: packaged defaults, overrides and validation. |
| 2 | 2 | ||
| 3 | The pydantic model tree below is the schema and mirrors the packaged JSON | 3 | The schema is `MaskClusteringConfig` (a `config_loader.ConfigModel`), mirroring |
| 4 | default exactly: unknown keys fail, and every value is range-checked here rather | 4 | `mask_clustering.default.json` key for key. |
| 5 | than at the point of use. Loading, deep-merging and validation are delegated to | ||
| 6 | :mod:`iolabs.common.config_loader`. | ||
| 7 | 5 | ||
| 8 | :func:`load_config` / :func:`build_config` keep returning plain dicts, because | 6 | Adding a config key means adding the field to the model and the same key to |
| 9 | callers pass ``--set``-style overrides around as dicts and the run manifest | 7 | `mask_clustering.default.json` โ nothing else. Unknown keys are rejected. |
| 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 | ||
| 12 | indexes nested config dicts by string. | ||
| 13 | 8 | ||
| 14 | Adding a config key means adding the field to the model here and the same key to | 9 | `normalize_config`, `load_config` and `build_config` return a plain ``dict``, |
| 15 | ``mask_clustering.default.json`` โ nothing else. | 10 | because callers pass ``--set``-style overrides around as dicts and the run |
| 11 | manifest embeds the normalized mapping verbatim; `MaskClusteringConfig.coerce` | ||
| 12 | is the typed view the pipeline, the CLI and the overlay actually read, so no | ||
| 13 | production code path indexes nested config dicts by string. | ||
| 16 | """ | 14 | """ |
| 17 | 15 | ||
| 18 | import json | 16 | from __future__ import annotations |
| 17 | |||
| 19 | from collections.abc import Mapping | 18 | from collections.abc import Mapping |
| 20 | from pathlib import Path | 19 | from pathlib import Path |
| 21 | from typing import Any, Literal | 20 | from typing import Any, Literal |
| 22 | 21 |
| 136 | output: Output directory and file-name layout. | 136 | output: Output directory and file-name layout. |
| 137 | file_naming: Step 3 input discovery settings. | 137 | file_naming: Step 3 input discovery settings. |
| 138 | """ | 138 | """ |
| 139 | 139 | ||
| 140 | mask: MaskConfig = MaskConfig() | 140 | mask: MaskClusteringMaskConfig = MaskClusteringMaskConfig() |
| 141 | clusters: ClustersConfig = ClustersConfig() | 141 | clusters: MaskClusteringClustersConfig = MaskClusteringClustersConfig() |
| 142 | intensity_separation: IntensitySeparationConfig = IntensitySeparationConfig() | 142 | intensity_separation: MaskClusteringIntensitySeparationConfig = ( |
| 143 | raster_frame: RasterFrameConfig = RasterFrameConfig() | 143 | MaskClusteringIntensitySeparationConfig() |
| 144 | geometry: GeometryConfig = GeometryConfig() | 144 | ) |
| 145 | output: OutputConfig = OutputConfig() | 145 | raster_frame: MaskClusteringRasterFrameConfig = MaskClusteringRasterFrameConfig() |
| 146 | file_naming: FileNamingConfig = FileNamingConfig() | 146 | geometry: MaskClusteringGeometryConfig = MaskClusteringGeometryConfig() |
| 147 | output: MaskClusteringOutputConfig = MaskClusteringOutputConfig() | ||
| 148 | file_naming: MaskClusteringFileNamingConfig = MaskClusteringFileNamingConfig() | ||
| 147 | 149 | ||
| 148 | @classmethod | 150 | @classmethod |
| 149 | def coerce(cls, config: "MaskClusteringConfig | dict[str, Any]") -> "MaskClusteringConfig": | 151 | def coerce(cls, config: MaskClusteringConfig | Mapping[str, Any]) -> MaskClusteringConfig: |
| 150 | """Return *config* as a typed configuration, converting a mapping if needed. | 152 | """Return *config* as a typed configuration, converting a mapping if needed. |
| 151 | 153 | ||
| 152 | Args: | 154 | Args: |
| 153 | config: An already-typed configuration, or a raw/normalized mapping. | 155 | config: An already-typed configuration, or a raw/normalized mapping. |
| 162 | return config | 164 | return config |
| 163 | return cls.from_mapping(config) | 165 | return cls.from_mapping(config) |
| 164 | 166 | ||
| 165 | @classmethod | 167 | @classmethod |
| 166 | def from_mapping(cls, config: dict[str, Any]) -> "MaskClusteringConfig": | 168 | def from_mapping(cls, config: Mapping[str, Any]) -> MaskClusteringConfig: |
| 167 | """Build the typed view, merging *config* onto the packaged defaults. | 169 | """Validate *config* into the typed view, filling in model defaults. |
| 168 | 170 | ||
| 169 | Args: | 171 | Args: |
| 170 | config: A raw or already-normalized configuration mapping. | 172 | config: A raw or already-normalized configuration mapping. |
| 171 | 173 |
| 1 | """Mask-to-point-cloud clustering for road-marking artifacts.""" | 1 | """Mask-to-point-cloud clustering for road-marking artifacts.""" |
| 2 | 2 | ||
| 3 | from importlib.metadata import PackageNotFoundError, version | 3 | from importlib.metadata import PackageNotFoundError, version |
| 4 | 4 | ||
| 5 | from ._config import ( | ||
| 6 | MaskClusteringConfig, | ||
| 7 | MaskClusteringConfigError, | ||
| 8 | build_config, | ||
| 9 | load_config, | ||
| 10 | normalize_config, | ||
| 11 | ) | ||
| 5 | from .pipeline import process_segment | 12 | from .pipeline import process_segment |
| 6 | from .raster_frame import RasterFrame | 13 | from .raster_frame import RasterFrame |
| 7 | from .types import ( | 14 | from .types import ( |
| 8 | ComponentResult, | 15 | ComponentResult, |
| 13 | ) | 20 | ) |
| 14 | 21 | ||
| 15 | __all__ = [ | 22 | __all__ = [ |
| 16 | "ComponentResult", | 23 | "ComponentResult", |
| 24 | "MaskClusteringConfig", | ||
| 25 | "MaskClusteringConfigError", | ||
| 17 | "MaskComponent", | 26 | "MaskComponent", |
| 18 | "PointChannels", | 27 | "PointChannels", |
| 19 | "RasterFrame", | 28 | "RasterFrame", |
| 20 | "SegmentResult", | 29 | "SegmentResult", |
| 21 | "SegmentType", | 30 | "SegmentType", |
| 31 | "build_config", | ||
| 32 | "load_config", | ||
| 33 | "normalize_config", | ||
| 22 | "process_segment", | 34 | "process_segment", |
| 23 | ] | 35 | ] |
| 24 | 36 | ||
| 25 | try: | 37 | try: |
| 67 | Returns: | 67 | Returns: |
| 68 | The nested override mapping. | 68 | The nested override mapping. |
| 69 | 69 | ||
| 70 | Raises: | 70 | Raises: |
| 71 | ValueError: An argument is not ``section.key=value`` (message: | 71 | _config.MaskClusteringConfigError: 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 config_loader.parse_set_overrides(values, nested=True, error_cls=ValueError) | 75 | return config_loader.parse_set_overrides( |
| 76 | values, nested=True, error_cls=_config.MaskClusteringConfigError | ||
| 77 | ) | ||
| 76 | 78 | ||
| 77 | 79 | ||
| 78 | def _load_jobs(path: Path) -> list[dict[str, Any]]: | 80 | def _load_jobs(path: Path) -> list[dict[str, Any]]: |
| 79 | with path.open(encoding="utf-8") as handle: | 81 | with path.open(encoding="utf-8") as handle: |
| 1 | import json | 1 | import json |
| 2 | from importlib import resources | 2 | from importlib import resources |
| 3 | 3 | ||
| 4 | import pytest | 4 | import pytest |
| 5 | from iolabs.common import config_loader | ||
| 5 | 6 | ||
| 7 | import iolabs_point_cloud_mask_clustering as mask_clustering | ||
| 6 | from iolabs_point_cloud_mask_clustering import _config | 8 | from iolabs_point_cloud_mask_clustering import _config |
| 7 | from iolabs_point_cloud_mask_clustering._config import ( | ||
| 8 | MaskClusteringConfigError, | ||
| 9 | build_config, | ||
| 10 | load_config, | ||
| 11 | ) | ||
| 12 | 9 | ||
| 13 | 10 | ||
| 14 | def test_recursive_overrides_work() -> None: | 11 | def _packaged_defaults() -> dict: |
| 15 | defaults = load_config() | 12 | return json.loads( |
| 16 | config = build_config(overrides={"clusters": {"warn_below_points": 250}}) | 13 | resources.files("iolabs_point_cloud_mask_clustering") |
| 14 | .joinpath("mask_clustering.default.json") | ||
| 15 | .read_text(encoding="utf-8") | ||
| 16 | ) | ||
| 17 | |||
| 18 | |||
| 19 | def test_model_defaults_match_packaged_json() -> None: | ||
| 20 | """The model tree and the packaged JSON must stay in lock-step.""" | ||
| 21 | assert _config.MaskClusteringConfig().model_dump() == _packaged_defaults() | ||
| 22 | |||
| 23 | |||
| 24 | def test_load_config_returns_packaged_defaults() -> None: | ||
| 25 | assert mask_clustering.load_config() == _packaged_defaults() | ||
| 26 | |||
| 27 | |||
| 28 | def test_error_class_is_config_error() -> None: | ||
| 29 | assert issubclass(_config.MaskClusteringConfigError, config_loader.ConfigError) | ||
| 30 | assert issubclass(_config.MaskClusteringConfigError, ValueError) | ||
| 31 | |||
| 32 | |||
| 33 | def test_unknown_top_level_key_is_rejected() -> None: | ||
| 34 | with pytest.raises(_config.MaskClusteringConfigError, match="unknown"): | ||
| 35 | mask_clustering.build_config(overrides={"unknown": 1}) | ||
| 36 | |||
| 37 | |||
| 38 | def test_unknown_nested_key_is_rejected() -> None: | ||
| 39 | with pytest.raises(_config.MaskClusteringConfigError, match="unknown"): | ||
| 40 | mask_clustering.build_config(overrides={"mask": {"unknown": 1}}) | ||
| 41 | |||
| 42 | |||
| 43 | def test_overrides_deep_merge_onto_defaults() -> None: | ||
| 44 | defaults = mask_clustering.load_config() | ||
| 45 | config = mask_clustering.build_config(overrides={"clusters": {"warn_below_points": 250}}) | ||
| 17 | assert config["clusters"]["warn_below_points"] == 250 | 46 | assert config["clusters"]["warn_below_points"] == 250 |
| 18 | assert ( | 47 | assert ( |
| 19 | config["clusters"]["min_points_per_cluster"] | 48 | config["clusters"]["min_points_per_cluster"] |
| 20 | == defaults["clusters"]["min_points_per_cluster"] | 49 | == defaults["clusters"]["min_points_per_cluster"] |
| 21 | ) | 50 | ) |
| 22 | 51 | ||
| 23 | 52 | ||
| 53 | def test_set_override_coercion_and_rejection() -> None: | ||
| 54 | overrides = config_loader.parse_set_overrides( | ||
| 55 | ["mask.vector_stroke_px=6", "geometry.write_ply=on"], | ||
| 56 | nested=True, | ||
| 57 | error_cls=_config.MaskClusteringConfigError, | ||
| 58 | ) | ||
| 59 | config = mask_clustering.build_config(overrides=overrides) | ||
| 60 | assert config["mask"]["vector_stroke_px"] == 6 | ||
| 61 | assert config["geometry"]["write_ply"] is True | ||
| 62 | with pytest.raises(_config.MaskClusteringConfigError): | ||
| 63 | mask_clustering.build_config(overrides={"geometry": {"write_ply": "flase"}}) | ||
| 64 | with pytest.raises(_config.MaskClusteringConfigError): | ||
| 65 | mask_clustering.build_config(overrides={"mask": {"vector_stroke_px": True}}) | ||
| 66 | |||
| 67 | |||
| 24 | @pytest.mark.parametrize( | 68 | @pytest.mark.parametrize( |
| 25 | "overrides", | 69 | "overrides", |
| 26 | [ | 70 | [ |
| 27 | {"unknown": 1}, | ||
| 28 | {"mask": {"unknown": 1}}, | ||
| 29 | {"clusters": {"min_points_per_cluster": 201, "warn_below_points": 200}}, | 71 | {"clusters": {"min_points_per_cluster": 201, "warn_below_points": 200}}, |
| 30 | {"mask": {"connectivity": 6}}, | 72 | {"mask": {"connectivity": 6}}, |
| 31 | {"mask": {"vector_stroke_px": 0}}, | 73 | {"mask": {"vector_stroke_px": 0}}, |
| 32 | {"raster_frame": {"metadata_origin_tolerance_pixels": -1}}, | 74 | {"raster_frame": {"metadata_origin_tolerance_pixels": -1}}, |
| 33 | ], | 75 | ], |
| 34 | ) | 76 | ) |
| 35 | def test_invalid_configuration_fails(overrides: dict) -> None: | 77 | def test_invalid_configuration_fails(overrides: dict) -> None: |
| 36 | with pytest.raises(MaskClusteringConfigError): | 78 | with pytest.raises(_config.MaskClusteringConfigError): |
| 37 | build_config(overrides=overrides) | 79 | mask_clustering.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 | 80 | ||
| 50 | 81 | ||
| 51 | @pytest.mark.parametrize( | 82 | @pytest.mark.parametrize( |
| 52 | "overrides", | 83 | "overrides", |
| 60 | {"intensity_separation": {"unknown": 1}}, | 91 | {"intensity_separation": {"unknown": 1}}, |
| 61 | ], | 92 | ], |
| 62 | ) | 93 | ) |
| 63 | def test_invalid_intensity_separation_fails(overrides: dict) -> None: | 94 | def test_invalid_intensity_separation_fails(overrides: dict) -> None: |
| 64 | with pytest.raises(MaskClusteringConfigError): | 95 | with pytest.raises(_config.MaskClusteringConfigError): |
| 65 | build_config(overrides=overrides) | 96 | mask_clustering.build_config(overrides=overrides) |
| 66 | 97 | ||
| 67 | 98 | ||
| 68 | def test_malformed_config_file_fails(tmp_path) -> None: | 99 | def test_malformed_config_file_fails(tmp_path) -> None: |
| 69 | path = tmp_path / "cfg.json" | 100 | path = tmp_path / "cfg.json" |
| 70 | path.write_text("{oops", encoding="utf-8") | 101 | path.write_text("{oops", encoding="utf-8") |
| 71 | with pytest.raises(MaskClusteringConfigError): | 102 | with pytest.raises(_config.MaskClusteringConfigError): |
| 72 | load_config(path) | 103 | mask_clustering.load_config(path) |
| 73 | 104 | ||
| 74 | 105 | ||
| 75 | def test_non_object_config_file_fails(tmp_path) -> None: | 106 | def test_non_object_config_file_fails(tmp_path) -> None: |
| 76 | path = tmp_path / "cfg.json" | 107 | path = tmp_path / "cfg.json" |
| 77 | path.write_text("[1, 2]", encoding="utf-8") | 108 | path.write_text("[1, 2]", encoding="utf-8") |
| 78 | with pytest.raises(MaskClusteringConfigError): | 109 | with pytest.raises(_config.MaskClusteringConfigError): |
| 79 | build_config(config_path=path) | 110 | mask_clustering.build_config(config_path=path) |
| 80 | 111 | ||
| 81 | 112 | ||
| 82 | def test_config_file_and_overrides_merge_onto_defaults(tmp_path) -> None: | 113 | def test_config_file_and_overrides_merge_onto_defaults(tmp_path) -> None: |
| 83 | path = tmp_path / "cfg.json" | 114 | path = tmp_path / "cfg.json" |
| 84 | path.write_text('{"clusters": {"warn_below_points": 500}}', encoding="utf-8") | 115 | path.write_text('{"clusters": {"warn_below_points": 500}}', encoding="utf-8") |
| 85 | config = build_config( | 116 | config = mask_clustering.build_config( |
| 86 | config_path=path, overrides={"clusters": {"min_points_per_cluster": 400}} | 117 | config_path=path, overrides={"clusters": {"min_points_per_cluster": 400}} |
| 87 | ) | 118 | ) |
| 88 | assert config["clusters"] == { | 119 | assert config["clusters"] == { |
| 89 | "min_points_per_cluster": 400, | 120 | "min_points_per_cluster": 400, |
| 91 | } | 122 | } |
| 92 | assert config["mask"]["connectivity"] == 8 | 123 | assert config["mask"]["connectivity"] == 8 |
| 93 | 124 | ||
| 94 | 125 | ||
| 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}}) | ||
| 101 | |||
| 102 | |||
| 103 | @pytest.mark.parametrize("value", [8, 8.0, "8", 4]) | 126 | @pytest.mark.parametrize("value", [8, 8.0, "8", 4]) |
| 104 | def test_connectivity_accepts_legacy_int_spellings(value: object) -> None: | 127 | def test_connectivity_accepts_legacy_int_spellings(value: object) -> None: |
| 105 | """JSON/CLI spellings of an int reach ``mask.connectivity`` as an int.""" | 128 | """JSON/CLI spellings of an int reach ``mask.connectivity`` as an int.""" |
| 106 | config = build_config(overrides={"mask": {"connectivity": value}}) | 129 | config = mask_clustering.build_config(overrides={"mask": {"connectivity": value}}) |
| 107 | assert config["mask"]["connectivity"] == int(value) # type: ignore[arg-type] | 130 | assert config["mask"]["connectivity"] == int(value) # type: ignore[arg-type] |
| 108 | 131 | ||
| 109 | 132 | ||
| 110 | @pytest.mark.parametrize("config", [None, [], "", 0, False]) | 133 | @pytest.mark.parametrize("config", [None, [], "", 0, False]) |
| 111 | def test_non_mapping_config_is_rejected(config: object) -> None: | 134 | def test_non_mapping_config_is_rejected(config: object) -> None: |
| 112 | """A non-mapping is an error, not a silent "use the defaults".""" | 135 | """A non-mapping is an error, not a silent "use the defaults".""" |
| 113 | with pytest.raises(MaskClusteringConfigError): | 136 | with pytest.raises(_config.MaskClusteringConfigError): |
| 114 | _config.MaskClusteringConfig.coerce(config) # type: ignore[arg-type] | 137 | _config.MaskClusteringConfig.coerce(config) # type: ignore[arg-type] |
| 115 | with pytest.raises(MaskClusteringConfigError): | 138 | with pytest.raises(_config.MaskClusteringConfigError): |
| 116 | _config.normalize_config(config) # type: ignore[arg-type] | 139 | mask_clustering.normalize_config(config) # type: ignore[arg-type] |
| 140 | |||
| 141 | |||
| 142 | def test_normalize_config_fills_model_defaults() -> None: | ||
| 143 | assert mask_clustering.normalize_config({}) == _packaged_defaults() |
| 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 a pydantic model tree in `_config.py` derived from | 12 | - Configuration is the `MaskClusteringConfig` model tree in `_config.py` |
| 13 | `iolabs.common.config_loader.ConfigModel`; it is strict, unknown keys fail. | 13 | (`config_loader.ConfigModel`), re-exported from the package root; it is |
| 14 | A new config key means one field on the model plus the same key in | 14 | strict, unknown keys fail. A new config key means one field on the model plus |
| 15 | `mask_clustering.default.json`. | 15 | the same key in `mask_clustering.default.json`. |
| 16 | - Preserve Step 3 XYZ unchanged and use `ColorIntensityData` for aligned channels. | 16 | - Preserve Step 3 XYZ unchanged and use `ColorIntensityData` for aligned channels. |
| 17 | - Never modify a segment's existing `clusters/` directory. | 17 | - Never modify a segment's existing `clusters/` directory. |
| 18 | - Use `iolabs.logstash.get_props_logger` with `_log_props.LOG_PROPS`. | 18 | - Use `iolabs.logstash.get_props_logger` with `_log_props.LOG_PROPS`. |
| 19 | - 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. |
| 73 | `0 <= min_points_per_cluster <= warn_below_points`. | 73 | `0 <= min_points_per_cluster <= warn_below_points`. |
| 74 | 74 | ||
| 75 | ## Configuration | 75 | ## Configuration |
| 76 | 76 | ||
| 77 | Defaults live in `src/iolabs_point_cloud_mask_clustering/mask_clustering.default.json` | 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` | 78 | The schema is `MaskClusteringConfig` in `_config.py` (a |
| 79 | (`iolabs.common.config_loader.ConfigModel`). Unknown keys and out-of-range values | 79 | `config_loader.ConfigModel`); nested JSON sections are nested models and unknown |
| 80 | fail loudly; `--config` files and `--set` overrides are deep-merged onto the | 80 | keys are rejected. **To add a config key: add the field (with its type, default |
| 81 | defaults and re-validated. | 81 | and any `Field` range) to the model and the same key with the same default to the |
| 82 | 82 | JSON โ nothing else.** `normalize_config`, `load_config` and `build_config` | |
| 83 | Adding a config key: add the field (with its type, default and any range | 83 | (re-exported from the package root) return a plain `dict`; |
| 84 | constraint) to the matching model in `_config.py`, and add the same key to | 84 | `MaskClusteringConfig.coerce` is the typed view used inside the package. A |
| 85 | `mask_clustering.default.json`. Nothing else. | 85 | `--config` file is deep-merged onto the packaged defaults, and runtime overrides |
| 86 | come from repeatable `--set KEY=VALUE`, never repo-local JSON. | ||
| 86 | 87 | ||
| 87 | ## CLI | 88 | ## CLI |
| 88 | 89 | ||
| 89 | Install development dependencies: | 90 | Install development dependencies: |
| 73 | `0 <= min_points_per_cluster <= warn_below_points`. | 73 | `0 <= min_points_per_cluster <= warn_below_points`. |
| 74 | 74 | ||
| 75 | ## Configuration | 75 | ## Configuration |
| 76 | 76 | ||
| 77 | Defaults live in `src/iolabs_point_cloud_mask_clustering/mask_clustering.default.json` | 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` | 78 | The schema is `MaskClusteringConfig` in `_config.py` (a |
| 79 | (`iolabs.common.config_loader.ConfigModel`). Unknown keys and out-of-range values | 79 | `config_loader.ConfigModel`); nested JSON sections are nested models and unknown |
| 80 | fail loudly; `--config` files and `--set` overrides are deep-merged onto the | 80 | keys are rejected. **To add a config key: add the field (with its type, default |
| 81 | defaults and re-validated. | 81 | and any `Field` range) to the model and the same key with the same default to the |
| 82 | 82 | JSON โ nothing else.** `normalize_config`, `load_config` and `build_config` | |
| 83 | Adding a config key: add the field (with its type, default and any range | 83 | (re-exported from the package root) return a plain `dict`; |
| 84 | constraint) to the matching model in `_config.py`, and add the same key to | 84 | `MaskClusteringConfig.coerce` is the typed view used inside the package. A |
| 85 | `mask_clustering.default.json`. Nothing else. | 85 | `--config` file is deep-merged onto the packaged defaults, and runtime overrides |
| 86 | come from repeatable `--set KEY=VALUE`, never repo-local JSON. | ||
| 86 | 87 | ||
| 87 | ## CLI | 88 | ## CLI |
| 88 | 89 | ||
| 89 | Install development dependencies: | 90 | Install development dependencies: |
| 1 | """Mask-to-point-cloud clustering for road-marking artifacts.""" | 1 | """Mask-to-point-cloud clustering for road-marking artifacts.""" |
| 2 | 2 | ||
| 3 | from importlib.metadata import PackageNotFoundError, version | 3 | from importlib.metadata import PackageNotFoundError, version |
| 4 | 4 | ||
| 5 | from ._config import ( | ||
| 6 | MaskClusteringConfig, | ||
| 7 | MaskClusteringConfigError, | ||
| 8 | build_config, | ||
| 9 | load_config, | ||
| 10 | normalize_config, | ||
| 11 | ) | ||
| 5 | from .pipeline import process_segment | 12 | from .pipeline import process_segment |
| 6 | from .raster_frame import RasterFrame | 13 | from .raster_frame import RasterFrame |
| 7 | from .types import ( | 14 | from .types import ( |
| 8 | ComponentResult, | 15 | ComponentResult, |
| 13 | ) | 20 | ) |
| 14 | 21 | ||
| 15 | __all__ = [ | 22 | __all__ = [ |
| 16 | "ComponentResult", | 23 | "ComponentResult", |
| 24 | "MaskClusteringConfig", | ||
| 25 | "MaskClusteringConfigError", | ||
| 17 | "MaskComponent", | 26 | "MaskComponent", |
| 18 | "PointChannels", | 27 | "PointChannels", |
| 19 | "RasterFrame", | 28 | "RasterFrame", |
| 20 | "SegmentResult", | 29 | "SegmentResult", |
| 21 | "SegmentType", | 30 | "SegmentType", |
| 31 | "build_config", | ||
| 32 | "load_config", | ||
| 33 | "normalize_config", | ||
| 22 | "process_segment", | 34 | "process_segment", |
| 23 | ] | 35 | ] |
| 24 | 36 | ||
| 25 | try: | 37 | try: |
| 1 | """Load, merge, validate and type the mask-clustering configuration. | 1 | """Mask-clustering configuration: packaged defaults, overrides and validation. |
| 2 | 2 | ||
| 3 | The pydantic model tree below is the schema and mirrors the packaged JSON | 3 | The schema is `MaskClusteringConfig` (a `config_loader.ConfigModel`), mirroring |
| 4 | default exactly: unknown keys fail, and every value is range-checked here rather | 4 | `mask_clustering.default.json` key for key. |
| 5 | than at the point of use. Loading, deep-merging and validation are delegated to | ||
| 6 | :mod:`iolabs.common.config_loader`. | ||
| 7 | 5 | ||
| 8 | :func:`load_config` / :func:`build_config` keep returning plain dicts, because | 6 | Adding a config key means adding the field to the model and the same key to |
| 9 | callers pass ``--set``-style overrides around as dicts and the run manifest | 7 | `mask_clustering.default.json` โ nothing else. Unknown keys are rejected. |
| 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 | ||
| 12 | indexes nested config dicts by string. | ||
| 13 | 8 | ||
| 14 | Adding a config key means adding the field to the model here and the same key to | 9 | `normalize_config`, `load_config` and `build_config` return a plain ``dict``, |
| 15 | ``mask_clustering.default.json`` โ nothing else. | 10 | because callers pass ``--set``-style overrides around as dicts and the run |
| 11 | manifest embeds the normalized mapping verbatim; `MaskClusteringConfig.coerce` | ||
| 12 | is the typed view the pipeline, the CLI and the overlay actually read, so no | ||
| 13 | production code path indexes nested config dicts by string. | ||
| 16 | """ | 14 | """ |
| 17 | 15 | ||
| 18 | import json | 16 | from __future__ import annotations |
| 17 | |||
| 19 | from collections.abc import Mapping | 18 | from collections.abc import Mapping |
| 20 | from pathlib import Path | 19 | from pathlib import Path |
| 21 | from typing import Any, Literal | 20 | from typing import Any, Literal |
| 22 | 21 |
| 27 | from ._log_props import LOG_PROPS | 26 | from ._log_props import LOG_PROPS |
| 28 | 27 | ||
| 29 | logger = get_props_logger(__name__, LOG_PROPS) | 28 | logger = get_props_logger(__name__, LOG_PROPS) |
| 30 | 29 | ||
| 31 | PACKAGE = "iolabs_point_cloud_mask_clustering" | 30 | _PACKAGE_NAME = "iolabs_point_cloud_mask_clustering" |
| 32 | DEFAULT_CONFIG_FILENAME = "mask_clustering.default.json" | 31 | _DEFAULT_FILENAME = "mask_clustering.default.json" |
| 32 | _CONTEXT = "mask clustering config" | ||
| 33 | 33 | ||
| 34 | 34 | ||
| 35 | class MaskClusteringConfigError(config_loader.ConfigError): | 35 | class MaskClusteringConfigError(config_loader.ConfigError): |
| 36 | """Raised when mask-clustering configuration is invalid.""" | 36 | """Raised when mask clustering config contains unsupported keys or values.""" |
| 37 | 37 | ||
| 38 | 38 | ||
| 39 | class MaskConfig(config_loader.ConfigModel): | 39 | class MaskClusteringMaskConfig(config_loader.ConfigModel): |
| 40 | """Mask rasterisation and labelling settings.""" | 40 | """Mask rasterisation and labelling settings.""" |
| 41 | 41 | ||
| 42 | background_class: int = 0 | 42 | background_class: int = 0 |
| 43 | solid_class: int = 1 | 43 | solid_class: int = 1 |
| 53 | raise ValueError("mask.connectivity must be 4 or 8") | 53 | raise ValueError("mask.connectivity must be 4 or 8") |
| 54 | return value | 54 | return value |
| 55 | 55 | ||
| 56 | 56 | ||
| 57 | class ClustersConfig(config_loader.ConfigModel): | 57 | class MaskClusteringClustersConfig(config_loader.ConfigModel): |
| 58 | """Sparse-cluster thresholds.""" | 58 | """Sparse-cluster thresholds.""" |
| 59 | 59 | ||
| 60 | min_points_per_cluster: int = pydantic.Field(default=20, ge=0) | 60 | min_points_per_cluster: int = pydantic.Field(default=20, ge=0) |
| 61 | warn_below_points: int = pydantic.Field(default=200, ge=0) | 61 | warn_below_points: int = pydantic.Field(default=200, ge=0) |
| 62 | 62 | ||
| 63 | @pydantic.model_validator(mode="after") | 63 | @pydantic.model_validator(mode="after") |
| 64 | def _check_thresholds(self) -> "ClustersConfig": | 64 | def _check_thresholds(self) -> MaskClusteringClustersConfig: |
| 65 | """Reject a minimum above the warning threshold.""" | 65 | """Reject a minimum above the warning threshold.""" |
| 66 | if self.min_points_per_cluster > self.warn_below_points: | 66 | if self.min_points_per_cluster > self.warn_below_points: |
| 67 | raise ValueError( | 67 | raise ValueError( |
| 68 | "clusters thresholds must satisfy " | 68 | "clusters thresholds must satisfy " |
| 70 | ) | 70 | ) |
| 71 | return self | 71 | return self |
| 72 | 72 | ||
| 73 | 73 | ||
| 74 | class IntensitySeparationConfig(config_loader.ConfigModel): | 74 | class MaskClusteringIntensitySeparationConfig(config_loader.ConfigModel): |
| 75 | """Paint/asphalt intensity-separation settings.""" | 75 | """Paint/asphalt intensity-separation settings.""" |
| 76 | 76 | ||
| 77 | enabled: bool = True | 77 | enabled: bool = True |
| 78 | apply_filter: bool = True | 78 | apply_filter: bool = True |
| 93 | clusters_per_page: int = pydantic.Field(default=3, ge=1) | 93 | clusters_per_page: int = pydantic.Field(default=3, ge=1) |
| 94 | pdf_filename: str = "intensity_separation.pdf" | 94 | pdf_filename: str = "intensity_separation.pdf" |
| 95 | 95 | ||
| 96 | 96 | ||
| 97 | class RasterFrameConfig(config_loader.ConfigModel): | 97 | class MaskClusteringRasterFrameConfig(config_loader.ConfigModel): |
| 98 | """Tolerances used when reconstructing the raster frame.""" | 98 | """Tolerances used when reconstructing the raster frame.""" |
| 99 | 99 | ||
| 100 | margin_pixels: float = pydantic.Field(default=1.0, ge=0.0) | 100 | margin_pixels: float = pydantic.Field(default=1.0, ge=0.0) |
| 101 | metadata_origin_tolerance_pixels: float = pydantic.Field(default=0.25, ge=0.0) | 101 | metadata_origin_tolerance_pixels: float = pydantic.Field(default=0.25, ge=0.0) |
| 102 | 102 | ||
| 103 | 103 | ||
| 104 | class GeometryConfig(config_loader.ConfigModel): | 104 | class MaskClusteringGeometryConfig(config_loader.ConfigModel): |
| 105 | """Which diagnostic geometry artifacts to write.""" | 105 | """Which diagnostic geometry artifacts to write.""" |
| 106 | 106 | ||
| 107 | write_geojson: bool = True | 107 | write_geojson: bool = True |
| 108 | write_ply: bool = True | 108 | write_ply: bool = True |
| 109 | simplify_tolerance_px: float = 0.0 | 109 | simplify_tolerance_px: float = 0.0 |
| 110 | 110 | ||
| 111 | 111 | ||
| 112 | class OutputConfig(config_loader.ConfigModel): | 112 | class MaskClusteringOutputConfig(config_loader.ConfigModel): |
| 113 | """Output directory and file-name layout.""" | 113 | """Output directory and file-name layout.""" |
| 114 | 114 | ||
| 115 | cluster_dir: str = "clusters_mask" | 115 | cluster_dir: str = "clusters_mask" |
| 116 | cluster_prefix: str = "run6_cluster_" | 116 | cluster_prefix: str = "run6_cluster_" |
| 117 | geometry_dir: str = "mask_geometry" | 117 | geometry_dir: str = "mask_geometry" |
| 118 | manifest_filename: str = "mask_clustering_manifest.json" | 118 | manifest_filename: str = "mask_clustering_manifest.json" |
| 119 | 119 | ||
| 120 | 120 | ||
| 121 | class FileNamingConfig(config_loader.ConfigModel): | 121 | class MaskClusteringFileNamingConfig(config_loader.ConfigModel): |
| 122 | """How Step 3 inputs are discovered inside a segment directory.""" | 122 | """How Step 3 inputs are discovered inside a segment directory.""" |
| 123 | 123 | ||
| 124 | segment_points_suffix: str = "_run3_points.npz" | 124 | segment_points_suffix: str = "_run3_points.npz" |
| 125 | 125 |
| 136 | output: Output directory and file-name layout. | 136 | output: Output directory and file-name layout. |
| 137 | file_naming: Step 3 input discovery settings. | 137 | file_naming: Step 3 input discovery settings. |
| 138 | """ | 138 | """ |
| 139 | 139 | ||
| 140 | mask: MaskConfig = MaskConfig() | 140 | mask: MaskClusteringMaskConfig = MaskClusteringMaskConfig() |
| 141 | clusters: ClustersConfig = ClustersConfig() | 141 | clusters: MaskClusteringClustersConfig = MaskClusteringClustersConfig() |
| 142 | intensity_separation: IntensitySeparationConfig = IntensitySeparationConfig() | 142 | intensity_separation: MaskClusteringIntensitySeparationConfig = ( |
| 143 | raster_frame: RasterFrameConfig = RasterFrameConfig() | 143 | MaskClusteringIntensitySeparationConfig() |
| 144 | geometry: GeometryConfig = GeometryConfig() | 144 | ) |
| 145 | output: OutputConfig = OutputConfig() | 145 | raster_frame: MaskClusteringRasterFrameConfig = MaskClusteringRasterFrameConfig() |
| 146 | file_naming: FileNamingConfig = FileNamingConfig() | 146 | geometry: MaskClusteringGeometryConfig = MaskClusteringGeometryConfig() |
| 147 | output: MaskClusteringOutputConfig = MaskClusteringOutputConfig() | ||
| 148 | file_naming: MaskClusteringFileNamingConfig = MaskClusteringFileNamingConfig() | ||
| 147 | 149 | ||
| 148 | @classmethod | 150 | @classmethod |
| 149 | def coerce(cls, config: "MaskClusteringConfig | dict[str, Any]") -> "MaskClusteringConfig": | 151 | def coerce(cls, config: MaskClusteringConfig | Mapping[str, Any]) -> MaskClusteringConfig: |
| 150 | """Return *config* as a typed configuration, converting a mapping if needed. | 152 | """Return *config* as a typed configuration, converting a mapping if needed. |
| 151 | 153 | ||
| 152 | Args: | 154 | Args: |
| 153 | config: An already-typed configuration, or a raw/normalized mapping. | 155 | config: An already-typed configuration, or a raw/normalized mapping. |
| 162 | return config | 164 | return config |
| 163 | return cls.from_mapping(config) | 165 | return cls.from_mapping(config) |
| 164 | 166 | ||
| 165 | @classmethod | 167 | @classmethod |
| 166 | def from_mapping(cls, config: dict[str, Any]) -> "MaskClusteringConfig": | 168 | def from_mapping(cls, config: Mapping[str, Any]) -> MaskClusteringConfig: |
| 167 | """Build the typed view, merging *config* onto the packaged defaults. | 169 | """Validate *config* into the typed view, filling in model defaults. |
| 168 | 170 | ||
| 169 | Args: | 171 | Args: |
| 170 | config: A raw or already-normalized configuration mapping. | 172 | config: A raw or already-normalized configuration mapping. |
| 171 | 173 |
| 174 | 176 | ||
| 175 | Raises: | 177 | Raises: |
| 176 | MaskClusteringConfigError: The mapping is not a valid configuration. | 178 | MaskClusteringConfigError: The mapping is not a valid configuration. |
| 177 | """ | 179 | """ |
| 178 | return _load_model(overrides=_as_mapping(config)) | 180 | return config_loader.validate_config( |
| 179 | 181 | cls, config, context=_CONTEXT, error_cls=MaskClusteringConfigError | |
| 180 | |||
| 181 | def _as_mapping(config: Any) -> dict[str, Any]: | ||
| 182 | """Return *config* as a dict, rejecting values that are not mappings. | ||
| 183 | |||
| 184 | Raises: | ||
| 185 | MaskClusteringConfigError: *config* is not a mapping (``None`` included). | ||
| 186 | """ | ||
| 187 | if not isinstance(config, Mapping): | ||
| 188 | raise MaskClusteringConfigError( | ||
| 189 | f"config must be a mapping, got {type(config).__name__}" | ||
| 190 | ) | 182 | ) |
| 191 | return dict(config) | ||
| 192 | 183 | ||
| 193 | 184 | ||
| 194 | def _load_model(overrides: dict[str, Any] | None = None) -> MaskClusteringConfig: | 185 | def _load_model( |
| 195 | """Merge *overrides* onto the packaged defaults and validate the result.""" | 186 | *, |
| 187 | overrides: Mapping[str, Any] | None = None, | ||
| 188 | config_path: str | Path | None = None, | ||
| 189 | ) -> MaskClusteringConfig: | ||
| 190 | """Merge a config file and *overrides* onto the packaged defaults and validate.""" | ||
| 191 | merged: dict[str, Any] = dict(overrides or {}) | ||
| 192 | if config_path is not None: | ||
| 193 | file_overrides = config_loader.load_json_overrides( | ||
| 194 | config_path, error_cls=MaskClusteringConfigError | ||
| 195 | ) | ||
| 196 | merged = config_loader.deep_merge_dicts(file_overrides, merged) | ||
| 197 | logger.info("Config file applied: %s", config_path) | ||
| 198 | if merged: | ||
| 199 | logger.info("Config overrides applied: %s", ", ".join(sorted(merged))) | ||
| 196 | return config_loader.load_config( | 200 | return config_loader.load_config( |
| 197 | MaskClusteringConfig, | 201 | MaskClusteringConfig, |
| 198 | package=PACKAGE, | 202 | package=_PACKAGE_NAME, |
| 199 | filename=DEFAULT_CONFIG_FILENAME, | 203 | filename=_DEFAULT_FILENAME, |
| 200 | overrides=overrides, | 204 | overrides=merged, |
| 201 | context="config", | 205 | context=_CONTEXT, |
| 202 | error_cls=MaskClusteringConfigError, | 206 | error_cls=MaskClusteringConfigError, |
| 203 | ) | 207 | ) |
| 204 | 208 | ||
| 205 | 209 | ||
| 206 | def _read_overrides(config_path: str | Path | None) -> dict[str, Any]: | 210 | def normalize_config(raw: Mapping[str, Any]) -> dict[str, Any]: |
| 207 | """Read a JSON override file, or return an empty mapping when there is none. | 211 | """Validate *raw* and fill in the model defaults. |
| 208 | |||
| 209 | Raises: | ||
| 210 | MaskClusteringConfigError: The file is not valid JSON, or does not hold | ||
| 211 | a JSON object. | ||
| 212 | """ | ||
| 213 | if config_path is None: | ||
| 214 | return {} | ||
| 215 | path = Path(config_path) | ||
| 216 | try: | ||
| 217 | with path.open(encoding="utf-8") as handle: | ||
| 218 | loaded = json.load(handle) | ||
| 219 | except json.JSONDecodeError as exc: | ||
| 220 | raise MaskClusteringConfigError(f"Invalid JSON in config file {path}: {exc}") from exc | ||
| 221 | if not isinstance(loaded, dict): | ||
| 222 | raise MaskClusteringConfigError( | ||
| 223 | f"Config file {path} must hold a JSON object, got {type(loaded).__name__}" | ||
| 224 | ) | ||
| 225 | return loaded | ||
| 226 | |||
| 227 | |||
| 228 | def normalize_config(raw: dict[str, Any]) -> dict[str, Any]: | ||
| 229 | """Merge *raw* onto the packaged defaults and validate the result. | ||
| 230 | 212 | ||
| 231 | Args: | 213 | Args: |
| 232 | raw: Partial configuration mapping. | 214 | raw: Partial configuration mapping. |
| 233 | 215 | ||
| 234 | Returns: | 216 | Returns: |
| 235 | The merged, validated configuration. | 217 | The validated configuration. |
| 236 | 218 | ||
| 237 | Raises: | 219 | Raises: |
| 238 | MaskClusteringConfigError: An unknown key or an out-of-range value. | 220 | MaskClusteringConfigError: An unknown key or an out-of-range value. |
| 239 | """ | 221 | """ |
| 240 | return _load_model(overrides=_as_mapping(raw)).model_dump() | 222 | return MaskClusteringConfig.from_mapping(raw).model_dump() |
| 241 | 223 | ||
| 242 | 224 | ||
| 243 | def load_config(config_path: str | Path | None = None) -> dict[str, Any]: | 225 | def load_config(config_path: str | Path | None = None) -> dict[str, Any]: |
| 244 | """Load a configuration JSON, or the packaged defaults when *config_path* is None. | 226 | """Load the packaged defaults, merging a config file onto them when given. |
| 245 | 227 | ||
| 246 | Args: | 228 | Args: |
| 247 | config_path: Path to a JSON file holding partial overrides. | 229 | config_path: Path to a JSON file holding partial overrides. It is |
| 230 | merged onto the packaged defaults rather than replacing them. | ||
| 248 | 231 | ||
| 249 | Returns: | 232 | Returns: |
| 250 | The merged, validated configuration. | 233 | The merged, validated configuration. |
| 251 | 234 | ||
| 252 | Raises: | 235 | Raises: |
| 253 | MaskClusteringConfigError: A malformed config file, an unknown key or an | 236 | MaskClusteringConfigError: A malformed config file, an unknown key or an |
| 254 | out-of-range value. | 237 | out-of-range value. |
| 255 | """ | 238 | """ |
| 256 | return _load_model(overrides=_read_overrides(config_path)).model_dump() | 239 | return _load_model(config_path=config_path).model_dump() |
| 257 | 240 | ||
| 258 | 241 | ||
| 259 | def build_config( | 242 | def build_config( |
| 260 | *, | 243 | *, |
| 261 | overrides: dict[str, Any] | None = None, | 244 | overrides: Mapping[str, Any] | None = None, |
| 262 | config_path: str | Path | None = None, | 245 | config_path: str | Path | None = None, |
| 263 | ) -> dict[str, Any]: | 246 | ) -> dict[str, Any]: |
| 264 | """Load a configuration file and apply in-memory overrides on top of it. | 247 | """Load a configuration file and apply in-memory overrides on top of it. |
| 265 | 248 | ||
| 266 | Args: | 249 | Args: |
| 267 | overrides: Nested override mapping, e.g. from CLI ``--set`` flags. | 250 | overrides: Nested override mapping, e.g. from CLI ``--set`` flags. |
| 268 | config_path: Path to a JSON file holding partial overrides. | 251 | config_path: Path to a JSON file holding partial overrides. It is |
| 252 | merged onto the packaged defaults rather than replacing them. | ||
| 269 | 253 | ||
| 270 | Returns: | 254 | Returns: |
| 271 | The merged, validated configuration. | 255 | The merged, validated configuration. |
| 272 | 256 | ||
| 273 | Raises: | 257 | Raises: |
| 274 | MaskClusteringConfigError: A malformed config file, an unknown key or an | 258 | MaskClusteringConfigError: A malformed config file, an unknown key or an |
| 275 | out-of-range value. | 259 | out-of-range value. |
| 276 | """ | 260 | """ |
| 277 | merged = config_loader.deep_merge_dicts( | 261 | return _load_model(overrides=overrides, config_path=config_path).model_dump() |
| 278 | _read_overrides(config_path), dict(overrides or {}) | ||
| 279 | ) | ||
| 280 | return _load_model(overrides=merged).model_dump() |
| 67 | Returns: | 67 | Returns: |
| 68 | The nested override mapping. | 68 | The nested override mapping. |
| 69 | 69 | ||
| 70 | Raises: | 70 | Raises: |
| 71 | ValueError: An argument is not ``section.key=value`` (message: | 71 | _config.MaskClusteringConfigError: 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 config_loader.parse_set_overrides(values, nested=True, error_cls=ValueError) | 75 | return config_loader.parse_set_overrides( |
| 76 | values, nested=True, error_cls=_config.MaskClusteringConfigError | ||
| 77 | ) | ||
| 76 | 78 | ||
| 77 | 79 | ||
| 78 | def _load_jobs(path: Path) -> list[dict[str, Any]]: | 80 | def _load_jobs(path: Path) -> list[dict[str, Any]]: |
| 79 | with path.open(encoding="utf-8") as handle: | 81 | with path.open(encoding="utf-8") as handle: |
| 1 | import json | 1 | import json |
| 2 | from importlib import resources | 2 | from importlib import resources |
| 3 | 3 | ||
| 4 | import pytest | 4 | import pytest |
| 5 | from iolabs.common import config_loader | ||
| 5 | 6 | ||
| 7 | import iolabs_point_cloud_mask_clustering as mask_clustering | ||
| 6 | from iolabs_point_cloud_mask_clustering import _config | 8 | from iolabs_point_cloud_mask_clustering import _config |
| 7 | from iolabs_point_cloud_mask_clustering._config import ( | ||
| 8 | MaskClusteringConfigError, | ||
| 9 | build_config, | ||
| 10 | load_config, | ||
| 11 | ) | ||
| 12 | 9 | ||
| 13 | 10 | ||
| 14 | def test_recursive_overrides_work() -> None: | 11 | def _packaged_defaults() -> dict: |
| 15 | defaults = load_config() | 12 | return json.loads( |
| 16 | config = build_config(overrides={"clusters": {"warn_below_points": 250}}) | 13 | resources.files("iolabs_point_cloud_mask_clustering") |
| 14 | .joinpath("mask_clustering.default.json") | ||
| 15 | .read_text(encoding="utf-8") | ||
| 16 | ) | ||
| 17 | |||
| 18 | |||
| 19 | def test_model_defaults_match_packaged_json() -> None: | ||
| 20 | """The model tree and the packaged JSON must stay in lock-step.""" | ||
| 21 | assert _config.MaskClusteringConfig().model_dump() == _packaged_defaults() | ||
| 22 | |||
| 23 | |||
| 24 | def test_load_config_returns_packaged_defaults() -> None: | ||
| 25 | assert mask_clustering.load_config() == _packaged_defaults() | ||
| 26 | |||
| 27 | |||
| 28 | def test_error_class_is_config_error() -> None: | ||
| 29 | assert issubclass(_config.MaskClusteringConfigError, config_loader.ConfigError) | ||
| 30 | assert issubclass(_config.MaskClusteringConfigError, ValueError) | ||
| 31 | |||
| 32 | |||
| 33 | def test_unknown_top_level_key_is_rejected() -> None: | ||
| 34 | with pytest.raises(_config.MaskClusteringConfigError, match="unknown"): | ||
| 35 | mask_clustering.build_config(overrides={"unknown": 1}) | ||
| 36 | |||
| 37 | |||
| 38 | def test_unknown_nested_key_is_rejected() -> None: | ||
| 39 | with pytest.raises(_config.MaskClusteringConfigError, match="unknown"): | ||
| 40 | mask_clustering.build_config(overrides={"mask": {"unknown": 1}}) | ||
| 41 | |||
| 42 | |||
| 43 | def test_overrides_deep_merge_onto_defaults() -> None: | ||
| 44 | defaults = mask_clustering.load_config() | ||
| 45 | config = mask_clustering.build_config(overrides={"clusters": {"warn_below_points": 250}}) | ||
| 17 | assert config["clusters"]["warn_below_points"] == 250 | 46 | assert config["clusters"]["warn_below_points"] == 250 |
| 18 | assert ( | 47 | assert ( |
| 19 | config["clusters"]["min_points_per_cluster"] | 48 | config["clusters"]["min_points_per_cluster"] |
| 20 | == defaults["clusters"]["min_points_per_cluster"] | 49 | == defaults["clusters"]["min_points_per_cluster"] |
| 21 | ) | 50 | ) |
| 22 | 51 | ||
| 23 | 52 | ||
| 53 | def test_set_override_coercion_and_rejection() -> None: | ||
| 54 | overrides = config_loader.parse_set_overrides( | ||
| 55 | ["mask.vector_stroke_px=6", "geometry.write_ply=on"], | ||
| 56 | nested=True, | ||
| 57 | error_cls=_config.MaskClusteringConfigError, | ||
| 58 | ) | ||
| 59 | config = mask_clustering.build_config(overrides=overrides) | ||
| 60 | assert config["mask"]["vector_stroke_px"] == 6 | ||
| 61 | assert config["geometry"]["write_ply"] is True | ||
| 62 | with pytest.raises(_config.MaskClusteringConfigError): | ||
| 63 | mask_clustering.build_config(overrides={"geometry": {"write_ply": "flase"}}) | ||
| 64 | with pytest.raises(_config.MaskClusteringConfigError): | ||
| 65 | mask_clustering.build_config(overrides={"mask": {"vector_stroke_px": True}}) | ||
| 66 | |||
| 67 | |||
| 24 | @pytest.mark.parametrize( | 68 | @pytest.mark.parametrize( |
| 25 | "overrides", | 69 | "overrides", |
| 26 | [ | 70 | [ |
| 27 | {"unknown": 1}, | ||
| 28 | {"mask": {"unknown": 1}}, | ||
| 29 | {"clusters": {"min_points_per_cluster": 201, "warn_below_points": 200}}, | 71 | {"clusters": {"min_points_per_cluster": 201, "warn_below_points": 200}}, |
| 30 | {"mask": {"connectivity": 6}}, | 72 | {"mask": {"connectivity": 6}}, |
| 31 | {"mask": {"vector_stroke_px": 0}}, | 73 | {"mask": {"vector_stroke_px": 0}}, |
| 32 | {"raster_frame": {"metadata_origin_tolerance_pixels": -1}}, | 74 | {"raster_frame": {"metadata_origin_tolerance_pixels": -1}}, |
| 33 | ], | 75 | ], |
| 34 | ) | 76 | ) |
| 35 | def test_invalid_configuration_fails(overrides: dict) -> None: | 77 | def test_invalid_configuration_fails(overrides: dict) -> None: |
| 36 | with pytest.raises(MaskClusteringConfigError): | 78 | with pytest.raises(_config.MaskClusteringConfigError): |
| 37 | build_config(overrides=overrides) | 79 | mask_clustering.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 | 80 | ||
| 50 | 81 | ||
| 51 | @pytest.mark.parametrize( | 82 | @pytest.mark.parametrize( |
| 52 | "overrides", | 83 | "overrides", |
| 60 | {"intensity_separation": {"unknown": 1}}, | 91 | {"intensity_separation": {"unknown": 1}}, |
| 61 | ], | 92 | ], |
| 62 | ) | 93 | ) |
| 63 | def test_invalid_intensity_separation_fails(overrides: dict) -> None: | 94 | def test_invalid_intensity_separation_fails(overrides: dict) -> None: |
| 64 | with pytest.raises(MaskClusteringConfigError): | 95 | with pytest.raises(_config.MaskClusteringConfigError): |
| 65 | build_config(overrides=overrides) | 96 | mask_clustering.build_config(overrides=overrides) |
| 66 | 97 | ||
| 67 | 98 | ||
| 68 | def test_malformed_config_file_fails(tmp_path) -> None: | 99 | def test_malformed_config_file_fails(tmp_path) -> None: |
| 69 | path = tmp_path / "cfg.json" | 100 | path = tmp_path / "cfg.json" |
| 70 | path.write_text("{oops", encoding="utf-8") | 101 | path.write_text("{oops", encoding="utf-8") |
| 71 | with pytest.raises(MaskClusteringConfigError): | 102 | with pytest.raises(_config.MaskClusteringConfigError): |
| 72 | load_config(path) | 103 | mask_clustering.load_config(path) |
| 73 | 104 | ||
| 74 | 105 | ||
| 75 | def test_non_object_config_file_fails(tmp_path) -> None: | 106 | def test_non_object_config_file_fails(tmp_path) -> None: |
| 76 | path = tmp_path / "cfg.json" | 107 | path = tmp_path / "cfg.json" |
| 77 | path.write_text("[1, 2]", encoding="utf-8") | 108 | path.write_text("[1, 2]", encoding="utf-8") |
| 78 | with pytest.raises(MaskClusteringConfigError): | 109 | with pytest.raises(_config.MaskClusteringConfigError): |
| 79 | build_config(config_path=path) | 110 | mask_clustering.build_config(config_path=path) |
| 80 | 111 | ||
| 81 | 112 | ||
| 82 | def test_config_file_and_overrides_merge_onto_defaults(tmp_path) -> None: | 113 | def test_config_file_and_overrides_merge_onto_defaults(tmp_path) -> None: |
| 83 | path = tmp_path / "cfg.json" | 114 | path = tmp_path / "cfg.json" |
| 84 | path.write_text('{"clusters": {"warn_below_points": 500}}', encoding="utf-8") | 115 | path.write_text('{"clusters": {"warn_below_points": 500}}', encoding="utf-8") |
| 85 | config = build_config( | 116 | config = mask_clustering.build_config( |
| 86 | config_path=path, overrides={"clusters": {"min_points_per_cluster": 400}} | 117 | config_path=path, overrides={"clusters": {"min_points_per_cluster": 400}} |
| 87 | ) | 118 | ) |
| 88 | assert config["clusters"] == { | 119 | assert config["clusters"] == { |
| 89 | "min_points_per_cluster": 400, | 120 | "min_points_per_cluster": 400, |
| 91 | } | 122 | } |
| 92 | assert config["mask"]["connectivity"] == 8 | 123 | assert config["mask"]["connectivity"] == 8 |
| 93 | 124 | ||
| 94 | 125 | ||
| 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}}) | ||
| 101 | |||
| 102 | |||
| 103 | @pytest.mark.parametrize("value", [8, 8.0, "8", 4]) | 126 | @pytest.mark.parametrize("value", [8, 8.0, "8", 4]) |
| 104 | def test_connectivity_accepts_legacy_int_spellings(value: object) -> None: | 127 | def test_connectivity_accepts_legacy_int_spellings(value: object) -> None: |
| 105 | """JSON/CLI spellings of an int reach ``mask.connectivity`` as an int.""" | 128 | """JSON/CLI spellings of an int reach ``mask.connectivity`` as an int.""" |
| 106 | config = build_config(overrides={"mask": {"connectivity": value}}) | 129 | config = mask_clustering.build_config(overrides={"mask": {"connectivity": value}}) |
| 107 | assert config["mask"]["connectivity"] == int(value) # type: ignore[arg-type] | 130 | assert config["mask"]["connectivity"] == int(value) # type: ignore[arg-type] |
| 108 | 131 | ||
| 109 | 132 | ||
| 110 | @pytest.mark.parametrize("config", [None, [], "", 0, False]) | 133 | @pytest.mark.parametrize("config", [None, [], "", 0, False]) |
| 111 | def test_non_mapping_config_is_rejected(config: object) -> None: | 134 | def test_non_mapping_config_is_rejected(config: object) -> None: |
| 112 | """A non-mapping is an error, not a silent "use the defaults".""" | 135 | """A non-mapping is an error, not a silent "use the defaults".""" |
| 113 | with pytest.raises(MaskClusteringConfigError): | 136 | with pytest.raises(_config.MaskClusteringConfigError): |
| 114 | _config.MaskClusteringConfig.coerce(config) # type: ignore[arg-type] | 137 | _config.MaskClusteringConfig.coerce(config) # type: ignore[arg-type] |
| 115 | with pytest.raises(MaskClusteringConfigError): | 138 | with pytest.raises(_config.MaskClusteringConfigError): |
| 116 | _config.normalize_config(config) # type: ignore[arg-type] | 139 | mask_clustering.normalize_config(config) # type: ignore[arg-type] |
| 140 | |||
| 141 | |||
| 142 | def test_normalize_config_fills_model_defaults() -> None: | ||
| 143 | assert mask_clustering.normalize_config({}) == _packaged_defaults() |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.