Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:39:41+02:00
Commit #23 ยท 59 snippets
README.md | 21 +++--- .../_config_model.py | 36 ++++++---- src/iolabs_point_cloud_segmentation_3d/config.py | 71 ++++++++++--------- tests/test_config.py | 79 +++++++++++++--------- 4 files changed, 119 insertions(+), 88 deletions(-)
| 32 | LasRgbMode = Literal["sensor", "class"] | 35 | LasRgbMode = Literal["sensor", "class"] |
| 33 | LasSplitMode = Literal["none", "class", "instance"] | 36 | LasSplitMode = Literal["none", "class", "instance"] |
| 34 | 37 | ||
| 35 | 38 | ||
| 36 | class ConfigError(config_loader.ConfigError): | 39 | class Seg3dConfigError(config_loader.ConfigError): |
| 37 | """Raised when the seg3d config is unreadable, unknown or out of range. | 40 | """Raised when seg3d config contains unsupported keys or values. |
| 38 | 41 | ||
| 39 | Covers malformed config JSON, unknown keys, values that are not valid | 42 | Covers malformed config JSON, unknown keys, values that are not valid |
| 40 | for their field type and values outside the declared `pydantic.Field` | 43 | for their field type and values outside the declared `pydantic.Field` |
| 41 | bounds or rejected by a `Seg3dConfig` model validator. | 44 | bounds or rejected by a `Seg3dConfig` model validator. |
| 42 | """ | 45 | """ |
| 43 | 46 | ||
| 44 | 47 | ||
| 48 | #: Deprecated alias kept for callers that import the pre-rename spelling | ||
| 49 | #: (`scripts/veg_sweep.py`); use `Seg3dConfigError`. | ||
| 50 | ConfigError = Seg3dConfigError | ||
| 51 | |||
| 52 | |||
| 45 | class Seg3dConfig(config_loader.ConfigModel): | 53 | class Seg3dConfig(config_loader.ConfigModel): |
| 46 | """Numeric thresholds for the fusion pipeline (metres unless stated). | 54 | """Numeric thresholds for the fusion pipeline (metres unless stated). |
| 47 | 55 | ||
| 48 | The per-field comments below carry the rationale; this section is the | 56 | The per-field comments below carry the rationale; this section is the |
| 36 | 43 | ||
| 37 | logger = logging.getLogger(__name__) | 44 | logger = logging.getLogger(__name__) |
| 38 | 45 | ||
| 39 | _PACKAGE_NAME = "iolabs_point_cloud_segmentation_3d" | 46 | _PACKAGE_NAME = "iolabs_point_cloud_segmentation_3d" |
| 40 | _DEFAULT_CONFIG_NAME = "seg3d.default.json" | 47 | _DEFAULT_FILENAME = "seg3d.default.json" |
| 41 | _CONFIG_CONTEXT = "seg3d config" | 48 | _CONTEXT = "seg3d config" |
| 42 | 49 | ||
| 43 | 50 | ||
| 44 | def load_default_config_dict() -> dict[str, Any]: | 51 | def load_default_config_dict() -> dict[str, Any]: |
| 45 | """Returns the package-owned default config as a plain dict.""" | 52 | """Return the package-owned default config as a plain dict.""" |
| 46 | return config_loader.load_packaged_json( | 53 | return config_loader.load_packaged_json( |
| 47 | __package__ or _PACKAGE_NAME, _DEFAULT_CONFIG_NAME | 54 | _PACKAGE_NAME, _DEFAULT_FILENAME |
| 48 | ) | 55 | ) |
| 49 | 56 | ||
| 50 | 57 | ||
| 51 | def config_from_dict(raw: dict[str, Any]) -> Seg3dConfig: | 58 | def config_from_dict(raw: dict[str, Any]) -> Seg3dConfig: |
| 52 | """Builds a validated `Seg3dConfig` from a raw mapping. | 59 | """Build a validated `Seg3dConfig` from a raw mapping. |
| 53 | 60 | ||
| 54 | Unknown keys are rejected and each raw value is coerced to its field's | 61 | Unknown keys are rejected and each raw value is coerced to its field's |
| 55 | declared type by `iolabs.common.config_loader.ConfigModel`, which is | 62 | declared type by `iolabs.common.config_loader.ConfigModel`, which is |
| 56 | strict: a bool typo (`"flase"`), a bool given as an int other than 0/1 | 63 | strict: a bool typo (`"flase"`), a bool given as an int other than 0/1 |
| 65 | Returns: | 72 | Returns: |
| 66 | The validated `Seg3dConfig`. | 73 | The validated `Seg3dConfig`. |
| 67 | 74 | ||
| 68 | Raises: | 75 | Raises: |
| 69 | ConfigError: `raw` contains an unknown key, or a value that is not | 76 | Seg3dConfigError: `raw` contains an unknown key, or a value that is not |
| 70 | valid for its field's declared type or outside its declared | 77 | valid for its field's declared type or outside its declared |
| 71 | range (see the `pydantic.Field` bounds and the model | 78 | range (see the `pydantic.Field` bounds and the model |
| 72 | validators on `Seg3dConfig`, including the naming knobs). | 79 | validators on `Seg3dConfig`, including the naming knobs). |
| 73 | """ | 80 | """ |
| 74 | return config_loader.validate_config( | 81 | return config_loader.validate_config( |
| 75 | Seg3dConfig, raw, context=_CONFIG_CONTEXT, error_cls=ConfigError | 82 | Seg3dConfig, raw, context=_CONTEXT, error_cls=Seg3dConfigError |
| 76 | ) | 83 | ) |
| 77 | 84 | ||
| 78 | 85 | ||
| 79 | def load_config( | 86 | def load_config( |
| 80 | config_path: Path | None = None, | 87 | config_path: Path | None = None, |
| 81 | overrides: dict[str, Any] | None = None, | 88 | overrides: dict[str, Any] | None = None, |
| 82 | ) -> Seg3dConfig: | 89 | ) -> Seg3dConfig: |
| 83 | """Loads the packaged default with file and `--set` overrides applied. | 90 | """Load the packaged default with file and `--set` overrides applied. |
| 84 | 91 | ||
| 85 | Args: | 92 | Args: |
| 86 | config_path: JSON file read instead of the packaged default, or | 93 | config_path: JSON file read instead of the packaged default, or |
| 87 | `None`. It may be partial: keys it omits fall back to the | 94 | `None`. It may be partial: keys it omits fall back to the |
| 1 | """The `Seg3dConfig` schema: every knob, its default and its range. | 1 | """The `Seg3dConfig` schema: every knob, its default and its range. |
| 2 | 2 | ||
| 3 | The model mirrors `seg3d.default.json` key for key -- adding a knob is a | 3 | The schema is `Seg3dConfig` (a `config_loader.ConfigModel`), mirroring |
| 4 | field here plus the same key with the same default there. Unknown keys, | 4 | `seg3d.default.json` key for key; ranges are `pydantic.Field` bounds and |
| 5 | value coercion and the error messages come from | 5 | cross-field rules are model validators. `config.py` is the public entry |
| 6 | `iolabs.common.config_loader.ConfigModel`; ranges are `pydantic.Field` | 6 | point (loading, merging, `--set` parsing) and re-exports both names. |
| 7 | bounds, cross-field rules are model validators. `config.py` is the public | 7 | |
| 8 | entry point (loading, merging, `--set` parsing) and re-exports both names. | 8 | Adding a config key means adding the field to the model and the same key to |
| 9 | `seg3d.default.json` -- nothing else. Unknown keys are rejected. | ||
| 9 | 10 | ||
| 10 | Distances are in metres unless the field name says otherwise. | 11 | Distances are in metres unless the field name says otherwise. |
| 11 | """ | 12 | """ |
| 12 | 13 | ||
| 14 | from __future__ import annotations | ||
| 15 | |||
| 13 | import logging | 16 | import logging |
| 14 | from typing import Literal | 17 | from typing import Literal |
| 15 | 18 | ||
| 16 | import pydantic | 19 | import pydantic |
| 368 | branch_tag: str = "" | 376 | branch_tag: str = "" |
| 369 | date_tag: str = "" | 377 | date_tag: str = "" |
| 370 | 378 | ||
| 371 | @pydantic.model_validator(mode="after") | 379 | @pydantic.model_validator(mode="after") |
| 372 | def _check_band_bounds(self) -> "Seg3dConfig": | 380 | def _check_band_bounds(self) -> Seg3dConfig: |
| 373 | """Rejects a low band that ends above the medium band.""" | 381 | """Reject a low band that ends above the medium band.""" |
| 374 | if self.vegetation_low_max_m > self.vegetation_medium_max_m: | 382 | if self.vegetation_low_max_m > self.vegetation_medium_max_m: |
| 375 | raise ValueError( | 383 | raise ValueError( |
| 376 | f"vegetation_low_max_m={self.vegetation_low_max_m!r} is not " | 384 | f"vegetation_low_max_m={self.vegetation_low_max_m!r} is not " |
| 377 | f"supported: it must not exceed " | 385 | f"supported: it must not exceed " |
| 381 | ) | 389 | ) |
| 382 | return self | 390 | return self |
| 383 | 391 | ||
| 384 | @pydantic.model_validator(mode="after") | 392 | @pydantic.model_validator(mode="after") |
| 385 | def _check_naming(self) -> "Seg3dConfig": | 393 | def _check_naming(self) -> Seg3dConfig: |
| 386 | """Resolves the naming knobs so a typo fails at load, not mid-run.""" | 394 | """Resolve the naming knobs so a typo fails at load, not mid-run.""" |
| 387 | try: | 395 | try: |
| 388 | naming.validate_naming_config(self) | 396 | naming.validate_naming_config(self) |
| 389 | except naming.NamingError as exc: | 397 | except naming.NamingError as exc: |
| 390 | # Surfaced as a config value error so `--set dataset_tag=...` | 398 | # Surfaced as a config value error so `--set dataset_tag=...` |
| 393 | raise ValueError(str(exc)) from exc | 401 | raise ValueError(str(exc)) from exc |
| 394 | return self | 402 | return self |
| 395 | 403 | ||
| 396 | @pydantic.model_validator(mode="after") | 404 | @pydantic.model_validator(mode="after") |
| 397 | def _warn_non_increasing_tiers(self) -> "Seg3dConfig": | 405 | def _warn_non_increasing_tiers(self) -> Seg3dConfig: |
| 398 | """Warns when the priority tiers are not strictly increasing. | 406 | """Warn when the priority tiers are not strictly increasing. |
| 399 | 407 | ||
| 400 | A warning, not an error: single-tier boosts are legitimate. The | 408 | A warning, not an error: single-tier boosts are legitimate. The |
| 401 | sharp edge is pre-ground overlay configs that pin the old numbers | 409 | sharp edge is pre-ground overlay configs that pin the old numbers |
| 402 | (asphalt=1, line=2, detector=3): the new `priority_ground=1` | 410 | (asphalt=1, line=2, detector=3): the new `priority_ground=1` |
| 1 | """Package-owned algorithm configuration. | 1 | """Package-owned algorithm configuration for the seg3d fusion CLI. |
| 2 | 2 | ||
| 3 | Mirrors the config convention used by the sibling iolabs point-cloud | 3 | The schema is `Seg3dConfig` (a `config_loader.ConfigModel`, declared in |
| 4 | packages (`guardrails` / `verticalsigns`): the package owns a | 4 | `_config_model.py`), mirroring `seg3d.default.json` key for key. Runtime |
| 5 | `seg3d.default.json` algorithm config, and a frozen typed params object | 5 | overrides are applied through repeatable `--set KEY=VALUE` flags or a |
| 6 | (`Seg3dConfig`, the pydantic model in `_config_model.py`) is loaded from it | 6 | `--config` JSON file, never repo-local edits to the packaged default. |
| 7 | at CLI start. Every model field default is kept identical to | 7 | |
| 8 | `seg3d.default.json` (guarded by `tests/test_config.py`), so `Seg3dConfig()` | 8 | Adding a config key means adding the field to the model (with its range |
| 9 | and `load_config` agree. Runtime overrides are applied through repeatable | ||
| 10 | `--set KEY=VALUE` flags or a `--config` JSON file, never repo-local edits to | ||
| 11 | the packaged default. | ||
| 12 | |||
| 13 | Adding a knob is two edits: a field on `Seg3dConfig` (with its range | ||
| 14 | expressed as `pydantic.Field(...)` bounds or a `model_validator`) and the | 9 | expressed as `pydantic.Field(...)` bounds or a `model_validator`) and the |
| 15 | same key with the same default in `seg3d.default.json`. | 10 | same key to `seg3d.default.json` -- nothing else. Unknown keys are rejected. |
| 11 | |||
| 12 | `config_from_dict` and `load_config` return the frozen `Seg3dConfig`. | ||
| 16 | """ | 13 | """ |
| 17 | 14 | ||
| 15 | from __future__ import annotations | ||
| 16 | |||
| 18 | import logging | 17 | import logging |
| 19 | from pathlib import Path | 18 | from pathlib import Path |
| 20 | from typing import Any | 19 | from typing import Any |
| 21 | 20 | ||
| 22 | from iolabs.common import config_loader | 21 | from iolabs.common import config_loader |
| 23 | 22 | ||
| 24 | from ._config_model import ConfigError, LasRgbMode, LasSplitMode, Seg3dConfig | 23 | from ._config_model import ( |
| 24 | ConfigError, | ||
| 25 | LasRgbMode, | ||
| 26 | LasSplitMode, | ||
| 27 | Seg3dConfig, | ||
| 28 | Seg3dConfigError, | ||
| 29 | ) | ||
| 25 | 30 | ||
| 26 | __all__ = [ | 31 | __all__ = [ |
| 32 | # Deprecated alias of `Seg3dConfigError`, kept for existing importers. | ||
| 27 | "ConfigError", | 33 | "ConfigError", |
| 28 | "LasRgbMode", | 34 | "LasRgbMode", |
| 29 | "LasSplitMode", | 35 | "LasSplitMode", |
| 30 | "Seg3dConfig", | 36 | "Seg3dConfig", |
| 37 | "Seg3dConfigError", | ||
| 31 | "config_from_dict", | 38 | "config_from_dict", |
| 32 | "load_config", | 39 | "load_config", |
| 33 | "load_default_config_dict", | 40 | "load_default_config_dict", |
| 34 | "parse_set_overrides", | 41 | "parse_set_overrides", |
| 93 | Returns: | 100 | Returns: |
| 94 | The validated `Seg3dConfig`. | 101 | The validated `Seg3dConfig`. |
| 95 | 102 | ||
| 96 | Raises: | 103 | Raises: |
| 97 | ConfigError: An override key or value is not valid. | 104 | Seg3dConfigError: An override key or value is not valid. |
| 98 | """ | 105 | """ |
| 99 | config = config_loader.load_config( | 106 | config = config_loader.load_config( |
| 100 | Seg3dConfig, | 107 | Seg3dConfig, |
| 101 | package=__package__ or _PACKAGE_NAME, | 108 | package=_PACKAGE_NAME, |
| 102 | filename=_DEFAULT_CONFIG_NAME, | 109 | filename=_DEFAULT_FILENAME, |
| 103 | overrides=overrides, | 110 | overrides=overrides, |
| 104 | config_path=config_path, | 111 | config_path=config_path, |
| 105 | context=_CONFIG_CONTEXT, | 112 | context=_CONTEXT, |
| 106 | error_cls=ConfigError, | 113 | error_cls=Seg3dConfigError, |
| 107 | ) | 114 | ) |
| 108 | if config_path is not None: | 115 | if config_path is not None: |
| 109 | logger.info("Config file applied: %s", config_path) | 116 | logger.info("Config file applied: %s", config_path) |
| 110 | if overrides: | 117 | if overrides: |
| 114 | return config | 121 | return config |
| 115 | 122 | ||
| 116 | 123 | ||
| 117 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: | 124 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: |
| 118 | """Parses repeated `--set KEY=VALUE` strings, JSON-decoding each value. | 125 | """Parse repeated `--set KEY=VALUE` strings, JSON-decoding each value. |
| 119 | 126 | ||
| 120 | Thin seg3d spelling of `iolabs.common.config_loader.parse_set_overrides`: | 127 | Thin seg3d spelling of `iolabs.common.config_loader.parse_set_overrides`: |
| 121 | flat keys (the seg3d config has no sections) and seg3d's own | 128 | flat keys (the seg3d config has no sections) and seg3d's own |
| 122 | `ConfigError`. | 129 | `Seg3dConfigError`. |
| 123 | 130 | ||
| 124 | Args: | 131 | Args: |
| 125 | raw_overrides: The raw `KEY=VALUE` strings, or `None`. | 132 | raw_overrides: The raw `KEY=VALUE` strings, or `None`. |
| 126 | 133 |
| 128 | A flat mapping of key to decoded value (raw text when the value is | 135 | A flat mapping of key to decoded value (raw text when the value is |
| 129 | not valid JSON). | 136 | not valid JSON). |
| 130 | 137 | ||
| 131 | Raises: | 138 | Raises: |
| 132 | ConfigError: An override is missing its `=`. | 139 | Seg3dConfigError: An override is missing its `=`. |
| 133 | """ | 140 | """ |
| 134 | return config_loader.parse_set_overrides( | 141 | return config_loader.parse_set_overrides( |
| 135 | raw_overrides, error_cls=ConfigError | 142 | raw_overrides, error_cls=Seg3dConfigError |
| 136 | ) | 143 | ) |
| 6 | 6 | ||
| 7 | import typing | 7 | import typing |
| 8 | 8 | ||
| 9 | import pytest | 9 | import pytest |
| 10 | from iolabs.common import config_loader | ||
| 10 | 11 | ||
| 11 | from iolabs_point_cloud_segmentation_3d import classes, las_modes | 12 | from iolabs_point_cloud_segmentation_3d import classes, las_modes |
| 12 | from iolabs_point_cloud_segmentation_3d.config import ( | 13 | from iolabs_point_cloud_segmentation_3d.config import ( |
| 13 | ConfigError, | 14 | ConfigError, |
| 14 | LasRgbMode, | 15 | LasRgbMode, |
| 15 | LasSplitMode, | 16 | LasSplitMode, |
| 16 | Seg3dConfig, | 17 | Seg3dConfig, |
| 18 | Seg3dConfigError, | ||
| 17 | config_from_dict, | 19 | config_from_dict, |
| 18 | load_config, | 20 | load_config, |
| 19 | load_default_config_dict, | 21 | load_default_config_dict, |
| 20 | parse_set_overrides, | 22 | parse_set_overrides, |
| 21 | ) | 23 | ) |
| 22 | 24 | ||
| 23 | 25 | ||
| 24 | def test_model_defaults_match_the_packaged_json(): | 26 | def test_model_defaults_match_packaged_json(): |
| 25 | assert Seg3dConfig().model_dump() == config_from_dict( | 27 | assert Seg3dConfig().model_dump() == config_from_dict( |
| 26 | load_default_config_dict() | 28 | load_default_config_dict() |
| 27 | ).model_dump() | 29 | ).model_dump() |
| 28 | assert set(load_default_config_dict()) == set(Seg3dConfig.model_fields) | 30 | assert set(load_default_config_dict()) == set(Seg3dConfig.model_fields) |
| 29 | 31 | ||
| 30 | 32 | ||
| 33 | def test_error_class_is_config_error(): | ||
| 34 | assert issubclass(Seg3dConfigError, config_loader.ConfigError) | ||
| 35 | assert issubclass(Seg3dConfigError, ValueError) | ||
| 36 | # The pre-rename spelling stays importable for existing callers. | ||
| 37 | assert ConfigError is Seg3dConfigError | ||
| 38 | |||
| 39 | |||
| 40 | def test_load_config_returns_packaged_defaults(): | ||
| 41 | assert load_config().model_dump() == load_default_config_dict() | ||
| 42 | |||
| 43 | |||
| 31 | def test_las_mode_literals_match_las_modes(): | 44 | def test_las_mode_literals_match_las_modes(): |
| 32 | # The CLI offers `las_modes` as argparse choices and the writer | 45 | # The CLI offers `las_modes` as argparse choices and the writer |
| 33 | # re-checks them; the config model validates its own Literals. | 46 | # re-checks them; the config model validates its own Literals. |
| 34 | assert typing.get_args(LasRgbMode) == las_modes.LAS_RGB_MODES | 47 | assert typing.get_args(LasRgbMode) == las_modes.LAS_RGB_MODES |
| 72 | 85 | ||
| 73 | def test_hash_rounding_must_be_positive(): | 86 | def test_hash_rounding_must_be_positive(): |
| 74 | assert config_from_dict({"hash_round_units_per_m": 500.0}) \ | 87 | assert config_from_dict({"hash_round_units_per_m": 500.0}) \ |
| 75 | .hash_round_units_per_m == 500.0 | 88 | .hash_round_units_per_m == 500.0 |
| 76 | with pytest.raises(ConfigError, match="hash_round_units_per_m"): | 89 | with pytest.raises(Seg3dConfigError, match="hash_round_units_per_m"): |
| 77 | config_from_dict({"hash_round_units_per_m": 0}) | 90 | config_from_dict({"hash_round_units_per_m": 0}) |
| 78 | 91 | ||
| 79 | 92 | ||
| 80 | def test_set_overrides_and_types(): | 93 | def test_set_overrides_and_types(): |
| 106 | assert isinstance(cfg.signs_json_paint_radius_max_m, float) | 119 | assert isinstance(cfg.signs_json_paint_radius_max_m, float) |
| 107 | assert cfg.signs_json_paint_enabled is False | 120 | assert cfg.signs_json_paint_enabled is False |
| 108 | 121 | ||
| 109 | 122 | ||
| 110 | def test_unknown_key_rejected(): | 123 | def test_unknown_top_level_key_is_rejected(): |
| 111 | with pytest.raises(ConfigError): | 124 | with pytest.raises(Seg3dConfigError, match="nope"): |
| 112 | config_from_dict({"nope": 1}) | 125 | config_from_dict({"nope": 1}) |
| 113 | 126 | ||
| 114 | 127 | ||
| 115 | def test_las_rgb_mode_validated(): | 128 | def test_las_rgb_mode_validated(): |
| 116 | assert config_from_dict({"las_rgb_mode": "class"}).las_rgb_mode == "class" | 129 | assert config_from_dict({"las_rgb_mode": "class"}).las_rgb_mode == "class" |
| 117 | with pytest.raises(ConfigError, match="las_rgb_mode"): | 130 | with pytest.raises(Seg3dConfigError, match="las_rgb_mode"): |
| 118 | config_from_dict({"las_rgb_mode": "palette"}) | 131 | config_from_dict({"las_rgb_mode": "palette"}) |
| 119 | 132 | ||
| 120 | 133 | ||
| 121 | def test_las_split_validated(): | 134 | def test_las_split_validated(): |
| 122 | assert config_from_dict({"las_split": "instance"}).las_split == "instance" | 135 | assert config_from_dict({"las_split": "instance"}).las_split == "instance" |
| 123 | assert config_from_dict({"las_split": "class"}).las_split == "class" | 136 | assert config_from_dict({"las_split": "class"}).las_split == "class" |
| 124 | with pytest.raises(ConfigError, match="las_split"): | 137 | with pytest.raises(Seg3dConfigError, match="las_split"): |
| 125 | config_from_dict({"las_split": "per_object"}) | 138 | config_from_dict({"las_split": "per_object"}) |
| 126 | 139 | ||
| 127 | 140 | ||
| 128 | def test_las_crs_epsg_validated(): | 141 | def test_las_crs_epsg_validated(): |
| 129 | # 0 disables the VLR; a negative code is rejected. | 142 | # 0 disables the VLR; a negative code is rejected. |
| 130 | assert config_from_dict({"las_crs_epsg": 0}).las_crs_epsg == 0 | 143 | assert config_from_dict({"las_crs_epsg": 0}).las_crs_epsg == 0 |
| 131 | with pytest.raises(ConfigError, match="las_crs_epsg"): | 144 | with pytest.raises(Seg3dConfigError, match="las_crs_epsg"): |
| 132 | config_from_dict({"las_crs_epsg": -1}) | 145 | config_from_dict({"las_crs_epsg": -1}) |
| 133 | 146 | ||
| 134 | 147 | ||
| 135 | def test_las_georeference_can_be_disabled(): | 148 | def test_las_georeference_can_be_disabled(): |
| 153 | assert config_from_dict({"write_ply": "false"}).write_ply is False | 166 | assert config_from_dict({"write_ply": "false"}).write_ply is False |
| 154 | 167 | ||
| 155 | 168 | ||
| 156 | def test_bool_typo_is_rejected_not_read_as_false(): | 169 | def test_bool_typo_is_rejected_not_read_as_false(): |
| 157 | with pytest.raises(ConfigError, match="write_ply"): | 170 | with pytest.raises(Seg3dConfigError, match="write_ply"): |
| 158 | config_from_dict({"write_ply": "flase"}) | 171 | config_from_dict({"write_ply": "flase"}) |
| 159 | with pytest.raises(ConfigError, match="write_ply"): | 172 | with pytest.raises(Seg3dConfigError, match="write_ply"): |
| 160 | config_from_dict({"write_ply": 2}) | 173 | config_from_dict({"write_ply": 2}) |
| 161 | 174 | ||
| 162 | 175 | ||
| 163 | def test_non_integral_value_for_an_int_field_is_rejected(): | 176 | def test_non_integral_value_for_an_int_field_is_rejected(): |
| 164 | with pytest.raises(ConfigError, match="signs_json_paint_min_points"): | 177 | with pytest.raises(Seg3dConfigError, match="signs_json_paint_min_points"): |
| 165 | config_from_dict({"signs_json_paint_min_points": 3.7}) | 178 | config_from_dict({"signs_json_paint_min_points": 3.7}) |
| 166 | with pytest.raises(ConfigError, match="las_crs_epsg"): | 179 | with pytest.raises(Seg3dConfigError, match="las_crs_epsg"): |
| 167 | config_from_dict({"las_crs_epsg": "not-a-number"}) | 180 | config_from_dict({"las_crs_epsg": "not-a-number"}) |
| 168 | 181 | ||
| 169 | 182 | ||
| 170 | def test_vegetation_enums_validated(): | 183 | def test_vegetation_enums_validated(): |
| 178 | {"vegetation_asphalt_rule": "corridor"} | 191 | {"vegetation_asphalt_rule": "corridor"} |
| 179 | ).vegetation_asphalt_rule == "corridor" | 192 | ).vegetation_asphalt_rule == "corridor" |
| 180 | # A typo must die at load time, not silently take the other branch on a | 193 | # A typo must die at load time, not silently take the other branch on a |
| 181 | # 3.5 min fusion run. | 194 | # 3.5 min fusion run. |
| 182 | with pytest.raises(ConfigError, match="vegetation_tall_class"): | 195 | with pytest.raises(Seg3dConfigError, match="vegetation_tall_class"): |
| 183 | config_from_dict({"vegetation_tall_class": "hedge"}) | 196 | config_from_dict({"vegetation_tall_class": "hedge"}) |
| 184 | with pytest.raises(ConfigError, match="vegetation_band_mode"): | 197 | with pytest.raises(Seg3dConfigError, match="vegetation_band_mode"): |
| 185 | config_from_dict({"vegetation_band_mode": "colum"}) | 198 | config_from_dict({"vegetation_band_mode": "colum"}) |
| 186 | with pytest.raises(ConfigError, match="vegetation_asphalt_rule"): | 199 | with pytest.raises(Seg3dConfigError, match="vegetation_asphalt_rule"): |
| 187 | config_from_dict({"vegetation_asphalt_rule": "polygon"}) | 200 | config_from_dict({"vegetation_asphalt_rule": "polygon"}) |
| 188 | 201 | ||
| 189 | 202 | ||
| 190 | def test_vegetation_limiter_ranges_validated(): | 203 | def test_vegetation_limiter_ranges_validated(): |
| 195 | {"vegetation_asphalt_dilate_cells": 0} | 208 | {"vegetation_asphalt_dilate_cells": 0} |
| 196 | ).vegetation_asphalt_dilate_cells == 0 | 209 | ).vegetation_asphalt_dilate_cells == 0 |
| 197 | # A zero cell size divides by zero deep in the rasteriser and a | 210 | # A zero cell size divides by zero deep in the rasteriser and a |
| 198 | # negative count silently means "no floor": both must fail at load. | 211 | # negative count silently means "no floor": both must fail at load. |
| 199 | with pytest.raises(ConfigError, match="vegetation_green_rg_ratio"): | 212 | with pytest.raises(Seg3dConfigError, match="vegetation_green_rg_ratio"): |
| 200 | config_from_dict({"vegetation_green_rg_ratio": 0.0}) | 213 | config_from_dict({"vegetation_green_rg_ratio": 0.0}) |
| 201 | with pytest.raises(ConfigError, match="vegetation_asphalt_cell_m"): | 214 | with pytest.raises(Seg3dConfigError, match="vegetation_asphalt_cell_m"): |
| 202 | config_from_dict({"vegetation_asphalt_cell_m": 0.0}) | 215 | config_from_dict({"vegetation_asphalt_cell_m": 0.0}) |
| 203 | with pytest.raises(ConfigError, match="vegetation_asphalt_cell_m"): | 216 | with pytest.raises(Seg3dConfigError, match="vegetation_asphalt_cell_m"): |
| 204 | config_from_dict({"vegetation_asphalt_cell_m": -0.25}) | 217 | config_from_dict({"vegetation_asphalt_cell_m": -0.25}) |
| 205 | with pytest.raises(ConfigError, match="vegetation_asphalt_dilate_cells"): | 218 | with pytest.raises(Seg3dConfigError, match="vegetation_asphalt_dilate_cells"): |
| 206 | config_from_dict({"vegetation_asphalt_dilate_cells": -1}) | 219 | config_from_dict({"vegetation_asphalt_dilate_cells": -1}) |
| 207 | with pytest.raises(ConfigError, match="vegetation_asphalt_min_points"): | 220 | with pytest.raises(Seg3dConfigError, match="vegetation_asphalt_min_points"): |
| 208 | config_from_dict({"vegetation_asphalt_min_points": -2}) | 221 | config_from_dict({"vegetation_asphalt_min_points": -2}) |
| 209 | with pytest.raises(ConfigError, match="vegetation_min_cell_points"): | 222 | with pytest.raises(Seg3dConfigError, match="vegetation_min_cell_points"): |
| 210 | config_from_dict({"vegetation_min_cell_points": -1}) | 223 | config_from_dict({"vegetation_min_cell_points": -1}) |
| 211 | 224 | ||
| 212 | 225 | ||
| 213 | def test_vegetation_set_overrides_coerce(): | 226 | def test_vegetation_set_overrides_coerce(): |
| 271 | {"vegetation_corridor_rail_m": 0.0} | 284 | {"vegetation_corridor_rail_m": 0.0} |
| 272 | ).vegetation_corridor_rail_m == 0.0 | 285 | ).vegetation_corridor_rail_m == 0.0 |
| 273 | 286 | ||
| 274 | for bad in (-1.0, float("nan"), float("inf")): | 287 | for bad in (-1.0, float("nan"), float("inf")): |
| 275 | with pytest.raises(ConfigError, match="vegetation_corridor_rail_m"): | 288 | with pytest.raises(Seg3dConfigError, match="vegetation_corridor_rail_m"): |
| 276 | config_from_dict({"vegetation_corridor_rail_m": bad}) | 289 | config_from_dict({"vegetation_corridor_rail_m": bad}) |
| 277 | 290 | ||
| 278 | 291 | ||
| 279 | def test_vegetation_corridor_max_height_validated(): | 292 | def test_vegetation_corridor_max_height_validated(): |
| 284 | ).vegetation_corridor_max_height_m == 0.0 | 297 | ).vegetation_corridor_max_height_m == 0.0 |
| 285 | 298 | ||
| 286 | for bad in (-1.0, float("nan"), float("inf")): | 299 | for bad in (-1.0, float("nan"), float("inf")): |
| 287 | with pytest.raises( | 300 | with pytest.raises( |
| 288 | ConfigError, match="vegetation_corridor_max_height_m" | 301 | Seg3dConfigError, match="vegetation_corridor_max_height_m" |
| 289 | ): | 302 | ): |
| 290 | config_from_dict({"vegetation_corridor_max_height_m": bad}) | 303 | config_from_dict({"vegetation_corridor_max_height_m": bad}) |
| 291 | 304 | ||
| 292 | 305 |
| 295 | # every one of these has to fail at load time instead. | 308 | # every one of these has to fail at load time instead. |
| 296 | for name in ("vegetation_ground_cell_m", "vegetation_band_cell_m"): | 309 | for name in ("vegetation_ground_cell_m", "vegetation_band_cell_m"): |
| 297 | assert getattr(config_from_dict({name: 2.0}), name) == 2.0 | 310 | assert getattr(config_from_dict({name: 2.0}), name) == 2.0 |
| 298 | for bad in (0.0, -0.5, float("nan"), float("inf")): | 311 | for bad in (0.0, -0.5, float("nan"), float("inf")): |
| 299 | with pytest.raises(ConfigError, match=name): | 312 | with pytest.raises(Seg3dConfigError, match=name): |
| 300 | config_from_dict({name: bad}) | 313 | config_from_dict({name: bad}) |
| 301 | 314 | ||
| 302 | 315 | ||
| 303 | def test_vegetation_min_ground_points_validated(): | 316 | def test_vegetation_min_ground_points_validated(): |
| 305 | {"vegetation_min_ground_points": 1} | 318 | {"vegetation_min_ground_points": 1} |
| 306 | ).vegetation_min_ground_points == 1 | 319 | ).vegetation_min_ground_points == 1 |
| 307 | # 0 reached numpy as a zero-size reduction. | 320 | # 0 reached numpy as a zero-size reduction. |
| 308 | for bad in (0, -5): | 321 | for bad in (0, -5): |
| 309 | with pytest.raises(ConfigError, match="vegetation_min_ground_points"): | 322 | with pytest.raises(Seg3dConfigError, match="vegetation_min_ground_points"): |
| 310 | config_from_dict({"vegetation_min_ground_points": bad}) | 323 | config_from_dict({"vegetation_min_ground_points": bad}) |
| 311 | 324 | ||
| 312 | 325 | ||
| 313 | def test_vegetation_percentiles_validated(): | 326 | def test_vegetation_percentiles_validated(): |
| 316 | ): | 329 | ): |
| 317 | for good in (0.0, 50.0, 100.0): | 330 | for good in (0.0, 50.0, 100.0): |
| 318 | assert getattr(config_from_dict({name: good}), name) == good | 331 | assert getattr(config_from_dict({name: good}), name) == good |
| 319 | for bad in (-1.0, 100.1, float("nan")): | 332 | for bad in (-1.0, 100.1, float("nan")): |
| 320 | with pytest.raises(ConfigError, match=name): | 333 | with pytest.raises(Seg3dConfigError, match=name): |
| 321 | config_from_dict({name: bad}) | 334 | config_from_dict({name: bad}) |
| 322 | 335 | ||
| 323 | 336 | ||
| 324 | def test_vegetation_min_height_must_be_finite(): | 337 | def test_vegetation_min_height_must_be_finite(): |
| 327 | assert config_from_dict( | 340 | assert config_from_dict( |
| 328 | {"vegetation_min_height_m": -100.0} | 341 | {"vegetation_min_height_m": -100.0} |
| 329 | ).vegetation_min_height_m == -100.0 | 342 | ).vegetation_min_height_m == -100.0 |
| 330 | for bad in (float("nan"), float("inf"), float("-inf")): | 343 | for bad in (float("nan"), float("inf"), float("-inf")): |
| 331 | with pytest.raises(ConfigError, match="vegetation_min_height_m"): | 344 | with pytest.raises(Seg3dConfigError, match="vegetation_min_height_m"): |
| 332 | config_from_dict({"vegetation_min_height_m": bad}) | 345 | config_from_dict({"vegetation_min_height_m": bad}) |
| 333 | 346 | ||
| 334 | 347 | ||
| 335 | def test_vegetation_green_and_tree_knobs_must_be_finite(): | 348 | def test_vegetation_green_and_tree_knobs_must_be_finite(): |
| 352 | "vegetation_green_min_brightness", | 365 | "vegetation_green_min_brightness", |
| 353 | "vegetation_tree_min_height_m", | 366 | "vegetation_tree_min_height_m", |
| 354 | ): | 367 | ): |
| 355 | for bad in (float("nan"), float("inf"), float("-inf")): | 368 | for bad in (float("nan"), float("inf"), float("-inf")): |
| 356 | with pytest.raises(ConfigError, match=name): | 369 | with pytest.raises(Seg3dConfigError, match=name): |
| 357 | config_from_dict({name: bad}) | 370 | config_from_dict({name: bad}) |
| 358 | 371 | ||
| 359 | 372 | ||
| 360 | def test_vegetation_asphalt_dilate_cells_has_an_upper_bound(): | 373 | def test_vegetation_asphalt_dilate_cells_has_an_upper_bound(): |
| 364 | {"vegetation_asphalt_dilate_cells": 64} | 377 | {"vegetation_asphalt_dilate_cells": 64} |
| 365 | ).vegetation_asphalt_dilate_cells == 64 | 378 | ).vegetation_asphalt_dilate_cells == 64 |
| 366 | for bad in (65, 500): | 379 | for bad in (65, 500): |
| 367 | with pytest.raises( | 380 | with pytest.raises( |
| 368 | ConfigError, match="vegetation_asphalt_dilate_cells" | 381 | Seg3dConfigError, match="vegetation_asphalt_dilate_cells" |
| 369 | ): | 382 | ): |
| 370 | config_from_dict({"vegetation_asphalt_dilate_cells": bad}) | 383 | config_from_dict({"vegetation_asphalt_dilate_cells": bad}) |
| 371 | 384 | ||
| 372 | 385 |
| 377 | assert ok.vegetation_low_max_m == ok.vegetation_medium_max_m == 1.0 | 390 | assert ok.vegetation_low_max_m == ok.vegetation_medium_max_m == 1.0 |
| 378 | 391 | ||
| 379 | for name in ("vegetation_low_max_m", "vegetation_medium_max_m"): | 392 | for name in ("vegetation_low_max_m", "vegetation_medium_max_m"): |
| 380 | for bad in (0.0, -1.0, float("nan"), float("inf")): | 393 | for bad in (0.0, -1.0, float("nan"), float("inf")): |
| 381 | with pytest.raises(ConfigError, match=name): | 394 | with pytest.raises(Seg3dConfigError, match=name): |
| 382 | config_from_dict({name: bad}) | 395 | config_from_dict({name: bad}) |
| 383 | # The low band cannot end above where the medium band ends. | 396 | # The low band cannot end above where the medium band ends. |
| 384 | with pytest.raises(ConfigError, match="vegetation_low_max_m"): | 397 | with pytest.raises(Seg3dConfigError, match="vegetation_low_max_m"): |
| 385 | config_from_dict( | 398 | config_from_dict( |
| 386 | {"vegetation_low_max_m": 3.0, "vegetation_medium_max_m": 2.0} | 399 | {"vegetation_low_max_m": 3.0, "vegetation_medium_max_m": 2.0} |
| 387 | ) | 400 | ) |
| 388 | 401 |
| 391 | # A cross-field rule is a whole-model validator, so it carries no field | 404 | # A cross-field rule is a whole-model validator, so it carries no field |
| 392 | # location; the message must stay the rule's own text and not grow a | 405 | # location; the message must stay the rule's own text and not grow a |
| 393 | # dump of every config key (which is what an unlocated value error | 406 | # dump of every config key (which is what an unlocated value error |
| 394 | # would otherwise echo back). | 407 | # would otherwise echo back). |
| 395 | with pytest.raises(ConfigError) as excinfo: | 408 | with pytest.raises(Seg3dConfigError) as excinfo: |
| 396 | config_from_dict( | 409 | config_from_dict( |
| 397 | {**load_default_config_dict(), "vegetation_low_max_m": 3.0} | 410 | {**load_default_config_dict(), "vegetation_low_max_m": 3.0} |
| 398 | ) | 411 | ) |
| 399 | assert str(excinfo.value) == ( | 412 | assert str(excinfo.value) == ( |
| 403 | ) | 416 | ) |
| 404 | 417 | ||
| 405 | 418 | ||
| 406 | def test_naming_rule_reports_only_its_own_message(): | 419 | def test_naming_rule_reports_only_its_own_message(): |
| 407 | with pytest.raises(ConfigError) as excinfo: | 420 | with pytest.raises(Seg3dConfigError) as excinfo: |
| 408 | config_from_dict( | 421 | config_from_dict( |
| 409 | {**load_default_config_dict(), "date_tag": "notadate"} | 422 | {**load_default_config_dict(), "date_tag": "notadate"} |
| 410 | ) | 423 | ) |
| 411 | assert str(excinfo.value) == ( | 424 | assert str(excinfo.value) == ( |
| 430 | "vegetation_green_min_brightness=inf", | 443 | "vegetation_green_min_brightness=inf", |
| 431 | "vegetation_tree_min_height_m=nan", | 444 | "vegetation_tree_min_height_m=nan", |
| 432 | "vegetation_asphalt_dilate_cells=500", | 445 | "vegetation_asphalt_dilate_cells=500", |
| 433 | ): | 446 | ): |
| 434 | with pytest.raises(ConfigError, match="vegetation_"): | 447 | with pytest.raises(Seg3dConfigError, match="vegetation_"): |
| 435 | load_config(overrides=parse_set_overrides([override])) | 448 | load_config(overrides=parse_set_overrides([override])) |
| 141 | ``` | 141 | ``` |
| 142 | 142 | ||
| 143 | ## Config | 143 | ## Config |
| 144 | 144 | ||
| 145 | All numeric thresholds live in the package-owned `seg3d.default.json`, loaded | 145 | Defaults live in `src/iolabs_point_cloud_segmentation_3d/seg3d.default.json`. |
| 146 | into a frozen `Seg3dConfig` pydantic model (`_config_model.py`, derived from | 146 | The schema is `Seg3dConfig` in `_config_model.py` (a |
| 147 | `iolabs.common.config_loader.ConfigModel`; `config.py` is the loading entry | 147 | `config_loader.ConfigModel`); unknown keys are rejected. **To add a config key: |
| 148 | point). Every model field default is kept identical to the JSON, asserted by | 148 | add the field (with its type, default and any `Field` range or model validator) |
| 149 | `tests/test_config.py`, so `Seg3dConfig()` and `load_config()` always agree. | 149 | to the model and the same key with the same default to the JSON โ nothing |
| 150 | Adding a knob is two edits: the field on `Seg3dConfig` (its range expressed as | 150 | else.** `load_default_config_dict` returns a plain `dict`; `config_from_dict` |
| 151 | `pydantic.Field(...)` bounds or a model validator) and the same key with the | 151 | and `load_config` (in `config.py`, the loading entry point) return the frozen |
| 152 | same default in `seg3d.default.json` -- unknown-key rejection, value coercion | 152 | `Seg3dConfig`, and its errors are `Seg3dConfigError`. Every model field default |
| 153 | and the error messages come from the shared layer. | 153 | is kept identical to the JSON, asserted by `tests/test_config.py`, so |
| 154 | `Seg3dConfig()` and `load_config()` always agree. Runtime overrides come from | ||
| 155 | repeatable `--set KEY=VALUE` or a `--config` JSON file, never repo-local edits | ||
| 156 | to the packaged default. | ||
| 154 | 157 | ||
| 155 | Override without editing the packaged default: | 158 | Override without editing the packaged default: |
| 156 | 159 | ||
| 157 | ```bash | 160 | ```bash |
| 1 | """The `Seg3dConfig` schema: every knob, its default and its range. | 1 | """The `Seg3dConfig` schema: every knob, its default and its range. |
| 2 | 2 | ||
| 3 | The model mirrors `seg3d.default.json` key for key -- adding a knob is a | 3 | The schema is `Seg3dConfig` (a `config_loader.ConfigModel`), mirroring |
| 4 | field here plus the same key with the same default there. Unknown keys, | 4 | `seg3d.default.json` key for key; ranges are `pydantic.Field` bounds and |
| 5 | value coercion and the error messages come from | 5 | cross-field rules are model validators. `config.py` is the public entry |
| 6 | `iolabs.common.config_loader.ConfigModel`; ranges are `pydantic.Field` | 6 | point (loading, merging, `--set` parsing) and re-exports both names. |
| 7 | bounds, cross-field rules are model validators. `config.py` is the public | 7 | |
| 8 | entry point (loading, merging, `--set` parsing) and re-exports both names. | 8 | Adding a config key means adding the field to the model and the same key to |
| 9 | `seg3d.default.json` -- nothing else. Unknown keys are rejected. | ||
| 9 | 10 | ||
| 10 | Distances are in metres unless the field name says otherwise. | 11 | Distances are in metres unless the field name says otherwise. |
| 11 | """ | 12 | """ |
| 12 | 13 | ||
| 14 | from __future__ import annotations | ||
| 15 | |||
| 13 | import logging | 16 | import logging |
| 14 | from typing import Literal | 17 | from typing import Literal |
| 15 | 18 | ||
| 16 | import pydantic | 19 | import pydantic |
| 32 | LasRgbMode = Literal["sensor", "class"] | 35 | LasRgbMode = Literal["sensor", "class"] |
| 33 | LasSplitMode = Literal["none", "class", "instance"] | 36 | LasSplitMode = Literal["none", "class", "instance"] |
| 34 | 37 | ||
| 35 | 38 | ||
| 36 | class ConfigError(config_loader.ConfigError): | 39 | class Seg3dConfigError(config_loader.ConfigError): |
| 37 | """Raised when the seg3d config is unreadable, unknown or out of range. | 40 | """Raised when seg3d config contains unsupported keys or values. |
| 38 | 41 | ||
| 39 | Covers malformed config JSON, unknown keys, values that are not valid | 42 | Covers malformed config JSON, unknown keys, values that are not valid |
| 40 | for their field type and values outside the declared `pydantic.Field` | 43 | for their field type and values outside the declared `pydantic.Field` |
| 41 | bounds or rejected by a `Seg3dConfig` model validator. | 44 | bounds or rejected by a `Seg3dConfig` model validator. |
| 42 | """ | 45 | """ |
| 43 | 46 | ||
| 44 | 47 | ||
| 48 | #: Deprecated alias kept for callers that import the pre-rename spelling | ||
| 49 | #: (`scripts/veg_sweep.py`); use `Seg3dConfigError`. | ||
| 50 | ConfigError = Seg3dConfigError | ||
| 51 | |||
| 52 | |||
| 45 | class Seg3dConfig(config_loader.ConfigModel): | 53 | class Seg3dConfig(config_loader.ConfigModel): |
| 46 | """Numeric thresholds for the fusion pipeline (metres unless stated). | 54 | """Numeric thresholds for the fusion pipeline (metres unless stated). |
| 47 | 55 | ||
| 48 | The per-field comments below carry the rationale; this section is the | 56 | The per-field comments below carry the rationale; this section is the |
| 368 | branch_tag: str = "" | 376 | branch_tag: str = "" |
| 369 | date_tag: str = "" | 377 | date_tag: str = "" |
| 370 | 378 | ||
| 371 | @pydantic.model_validator(mode="after") | 379 | @pydantic.model_validator(mode="after") |
| 372 | def _check_band_bounds(self) -> "Seg3dConfig": | 380 | def _check_band_bounds(self) -> Seg3dConfig: |
| 373 | """Rejects a low band that ends above the medium band.""" | 381 | """Reject a low band that ends above the medium band.""" |
| 374 | if self.vegetation_low_max_m > self.vegetation_medium_max_m: | 382 | if self.vegetation_low_max_m > self.vegetation_medium_max_m: |
| 375 | raise ValueError( | 383 | raise ValueError( |
| 376 | f"vegetation_low_max_m={self.vegetation_low_max_m!r} is not " | 384 | f"vegetation_low_max_m={self.vegetation_low_max_m!r} is not " |
| 377 | f"supported: it must not exceed " | 385 | f"supported: it must not exceed " |
| 381 | ) | 389 | ) |
| 382 | return self | 390 | return self |
| 383 | 391 | ||
| 384 | @pydantic.model_validator(mode="after") | 392 | @pydantic.model_validator(mode="after") |
| 385 | def _check_naming(self) -> "Seg3dConfig": | 393 | def _check_naming(self) -> Seg3dConfig: |
| 386 | """Resolves the naming knobs so a typo fails at load, not mid-run.""" | 394 | """Resolve the naming knobs so a typo fails at load, not mid-run.""" |
| 387 | try: | 395 | try: |
| 388 | naming.validate_naming_config(self) | 396 | naming.validate_naming_config(self) |
| 389 | except naming.NamingError as exc: | 397 | except naming.NamingError as exc: |
| 390 | # Surfaced as a config value error so `--set dataset_tag=...` | 398 | # Surfaced as a config value error so `--set dataset_tag=...` |
| 393 | raise ValueError(str(exc)) from exc | 401 | raise ValueError(str(exc)) from exc |
| 394 | return self | 402 | return self |
| 395 | 403 | ||
| 396 | @pydantic.model_validator(mode="after") | 404 | @pydantic.model_validator(mode="after") |
| 397 | def _warn_non_increasing_tiers(self) -> "Seg3dConfig": | 405 | def _warn_non_increasing_tiers(self) -> Seg3dConfig: |
| 398 | """Warns when the priority tiers are not strictly increasing. | 406 | """Warn when the priority tiers are not strictly increasing. |
| 399 | 407 | ||
| 400 | A warning, not an error: single-tier boosts are legitimate. The | 408 | A warning, not an error: single-tier boosts are legitimate. The |
| 401 | sharp edge is pre-ground overlay configs that pin the old numbers | 409 | sharp edge is pre-ground overlay configs that pin the old numbers |
| 402 | (asphalt=1, line=2, detector=3): the new `priority_ground=1` | 410 | (asphalt=1, line=2, detector=3): the new `priority_ground=1` |
| 1 | """Package-owned algorithm configuration. | 1 | """Package-owned algorithm configuration for the seg3d fusion CLI. |
| 2 | 2 | ||
| 3 | Mirrors the config convention used by the sibling iolabs point-cloud | 3 | The schema is `Seg3dConfig` (a `config_loader.ConfigModel`, declared in |
| 4 | packages (`guardrails` / `verticalsigns`): the package owns a | 4 | `_config_model.py`), mirroring `seg3d.default.json` key for key. Runtime |
| 5 | `seg3d.default.json` algorithm config, and a frozen typed params object | 5 | overrides are applied through repeatable `--set KEY=VALUE` flags or a |
| 6 | (`Seg3dConfig`, the pydantic model in `_config_model.py`) is loaded from it | 6 | `--config` JSON file, never repo-local edits to the packaged default. |
| 7 | at CLI start. Every model field default is kept identical to | 7 | |
| 8 | `seg3d.default.json` (guarded by `tests/test_config.py`), so `Seg3dConfig()` | 8 | Adding a config key means adding the field to the model (with its range |
| 9 | and `load_config` agree. Runtime overrides are applied through repeatable | ||
| 10 | `--set KEY=VALUE` flags or a `--config` JSON file, never repo-local edits to | ||
| 11 | the packaged default. | ||
| 12 | |||
| 13 | Adding a knob is two edits: a field on `Seg3dConfig` (with its range | ||
| 14 | expressed as `pydantic.Field(...)` bounds or a `model_validator`) and the | 9 | expressed as `pydantic.Field(...)` bounds or a `model_validator`) and the |
| 15 | same key with the same default in `seg3d.default.json`. | 10 | same key to `seg3d.default.json` -- nothing else. Unknown keys are rejected. |
| 11 | |||
| 12 | `config_from_dict` and `load_config` return the frozen `Seg3dConfig`. | ||
| 16 | """ | 13 | """ |
| 17 | 14 | ||
| 15 | from __future__ import annotations | ||
| 16 | |||
| 18 | import logging | 17 | import logging |
| 19 | from pathlib import Path | 18 | from pathlib import Path |
| 20 | from typing import Any | 19 | from typing import Any |
| 21 | 20 | ||
| 22 | from iolabs.common import config_loader | 21 | from iolabs.common import config_loader |
| 23 | 22 | ||
| 24 | from ._config_model import ConfigError, LasRgbMode, LasSplitMode, Seg3dConfig | 23 | from ._config_model import ( |
| 24 | ConfigError, | ||
| 25 | LasRgbMode, | ||
| 26 | LasSplitMode, | ||
| 27 | Seg3dConfig, | ||
| 28 | Seg3dConfigError, | ||
| 29 | ) | ||
| 25 | 30 | ||
| 26 | __all__ = [ | 31 | __all__ = [ |
| 32 | # Deprecated alias of `Seg3dConfigError`, kept for existing importers. | ||
| 27 | "ConfigError", | 33 | "ConfigError", |
| 28 | "LasRgbMode", | 34 | "LasRgbMode", |
| 29 | "LasSplitMode", | 35 | "LasSplitMode", |
| 30 | "Seg3dConfig", | 36 | "Seg3dConfig", |
| 37 | "Seg3dConfigError", | ||
| 31 | "config_from_dict", | 38 | "config_from_dict", |
| 32 | "load_config", | 39 | "load_config", |
| 33 | "load_default_config_dict", | 40 | "load_default_config_dict", |
| 34 | "parse_set_overrides", | 41 | "parse_set_overrides", |
| 36 | 43 | ||
| 37 | logger = logging.getLogger(__name__) | 44 | logger = logging.getLogger(__name__) |
| 38 | 45 | ||
| 39 | _PACKAGE_NAME = "iolabs_point_cloud_segmentation_3d" | 46 | _PACKAGE_NAME = "iolabs_point_cloud_segmentation_3d" |
| 40 | _DEFAULT_CONFIG_NAME = "seg3d.default.json" | 47 | _DEFAULT_FILENAME = "seg3d.default.json" |
| 41 | _CONFIG_CONTEXT = "seg3d config" | 48 | _CONTEXT = "seg3d config" |
| 42 | 49 | ||
| 43 | 50 | ||
| 44 | def load_default_config_dict() -> dict[str, Any]: | 51 | def load_default_config_dict() -> dict[str, Any]: |
| 45 | """Returns the package-owned default config as a plain dict.""" | 52 | """Return the package-owned default config as a plain dict.""" |
| 46 | return config_loader.load_packaged_json( | 53 | return config_loader.load_packaged_json( |
| 47 | __package__ or _PACKAGE_NAME, _DEFAULT_CONFIG_NAME | 54 | _PACKAGE_NAME, _DEFAULT_FILENAME |
| 48 | ) | 55 | ) |
| 49 | 56 | ||
| 50 | 57 | ||
| 51 | def config_from_dict(raw: dict[str, Any]) -> Seg3dConfig: | 58 | def config_from_dict(raw: dict[str, Any]) -> Seg3dConfig: |
| 52 | """Builds a validated `Seg3dConfig` from a raw mapping. | 59 | """Build a validated `Seg3dConfig` from a raw mapping. |
| 53 | 60 | ||
| 54 | Unknown keys are rejected and each raw value is coerced to its field's | 61 | Unknown keys are rejected and each raw value is coerced to its field's |
| 55 | declared type by `iolabs.common.config_loader.ConfigModel`, which is | 62 | declared type by `iolabs.common.config_loader.ConfigModel`, which is |
| 56 | strict: a bool typo (`"flase"`), a bool given as an int other than 0/1 | 63 | strict: a bool typo (`"flase"`), a bool given as an int other than 0/1 |
| 65 | Returns: | 72 | Returns: |
| 66 | The validated `Seg3dConfig`. | 73 | The validated `Seg3dConfig`. |
| 67 | 74 | ||
| 68 | Raises: | 75 | Raises: |
| 69 | ConfigError: `raw` contains an unknown key, or a value that is not | 76 | Seg3dConfigError: `raw` contains an unknown key, or a value that is not |
| 70 | valid for its field's declared type or outside its declared | 77 | valid for its field's declared type or outside its declared |
| 71 | range (see the `pydantic.Field` bounds and the model | 78 | range (see the `pydantic.Field` bounds and the model |
| 72 | validators on `Seg3dConfig`, including the naming knobs). | 79 | validators on `Seg3dConfig`, including the naming knobs). |
| 73 | """ | 80 | """ |
| 74 | return config_loader.validate_config( | 81 | return config_loader.validate_config( |
| 75 | Seg3dConfig, raw, context=_CONFIG_CONTEXT, error_cls=ConfigError | 82 | Seg3dConfig, raw, context=_CONTEXT, error_cls=Seg3dConfigError |
| 76 | ) | 83 | ) |
| 77 | 84 | ||
| 78 | 85 | ||
| 79 | def load_config( | 86 | def load_config( |
| 80 | config_path: Path | None = None, | 87 | config_path: Path | None = None, |
| 81 | overrides: dict[str, Any] | None = None, | 88 | overrides: dict[str, Any] | None = None, |
| 82 | ) -> Seg3dConfig: | 89 | ) -> Seg3dConfig: |
| 83 | """Loads the packaged default with file and `--set` overrides applied. | 90 | """Load the packaged default with file and `--set` overrides applied. |
| 84 | 91 | ||
| 85 | Args: | 92 | Args: |
| 86 | config_path: JSON file read instead of the packaged default, or | 93 | config_path: JSON file read instead of the packaged default, or |
| 87 | `None`. It may be partial: keys it omits fall back to the | 94 | `None`. It may be partial: keys it omits fall back to the |
| 93 | Returns: | 100 | Returns: |
| 94 | The validated `Seg3dConfig`. | 101 | The validated `Seg3dConfig`. |
| 95 | 102 | ||
| 96 | Raises: | 103 | Raises: |
| 97 | ConfigError: An override key or value is not valid. | 104 | Seg3dConfigError: An override key or value is not valid. |
| 98 | """ | 105 | """ |
| 99 | config = config_loader.load_config( | 106 | config = config_loader.load_config( |
| 100 | Seg3dConfig, | 107 | Seg3dConfig, |
| 101 | package=__package__ or _PACKAGE_NAME, | 108 | package=_PACKAGE_NAME, |
| 102 | filename=_DEFAULT_CONFIG_NAME, | 109 | filename=_DEFAULT_FILENAME, |
| 103 | overrides=overrides, | 110 | overrides=overrides, |
| 104 | config_path=config_path, | 111 | config_path=config_path, |
| 105 | context=_CONFIG_CONTEXT, | 112 | context=_CONTEXT, |
| 106 | error_cls=ConfigError, | 113 | error_cls=Seg3dConfigError, |
| 107 | ) | 114 | ) |
| 108 | if config_path is not None: | 115 | if config_path is not None: |
| 109 | logger.info("Config file applied: %s", config_path) | 116 | logger.info("Config file applied: %s", config_path) |
| 110 | if overrides: | 117 | if overrides: |
| 114 | return config | 121 | return config |
| 115 | 122 | ||
| 116 | 123 | ||
| 117 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: | 124 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: |
| 118 | """Parses repeated `--set KEY=VALUE` strings, JSON-decoding each value. | 125 | """Parse repeated `--set KEY=VALUE` strings, JSON-decoding each value. |
| 119 | 126 | ||
| 120 | Thin seg3d spelling of `iolabs.common.config_loader.parse_set_overrides`: | 127 | Thin seg3d spelling of `iolabs.common.config_loader.parse_set_overrides`: |
| 121 | flat keys (the seg3d config has no sections) and seg3d's own | 128 | flat keys (the seg3d config has no sections) and seg3d's own |
| 122 | `ConfigError`. | 129 | `Seg3dConfigError`. |
| 123 | 130 | ||
| 124 | Args: | 131 | Args: |
| 125 | raw_overrides: The raw `KEY=VALUE` strings, or `None`. | 132 | raw_overrides: The raw `KEY=VALUE` strings, or `None`. |
| 126 | 133 |
| 128 | A flat mapping of key to decoded value (raw text when the value is | 135 | A flat mapping of key to decoded value (raw text when the value is |
| 129 | not valid JSON). | 136 | not valid JSON). |
| 130 | 137 | ||
| 131 | Raises: | 138 | Raises: |
| 132 | ConfigError: An override is missing its `=`. | 139 | Seg3dConfigError: An override is missing its `=`. |
| 133 | """ | 140 | """ |
| 134 | return config_loader.parse_set_overrides( | 141 | return config_loader.parse_set_overrides( |
| 135 | raw_overrides, error_cls=ConfigError | 142 | raw_overrides, error_cls=Seg3dConfigError |
| 136 | ) | 143 | ) |
| 6 | 6 | ||
| 7 | import typing | 7 | import typing |
| 8 | 8 | ||
| 9 | import pytest | 9 | import pytest |
| 10 | from iolabs.common import config_loader | ||
| 10 | 11 | ||
| 11 | from iolabs_point_cloud_segmentation_3d import classes, las_modes | 12 | from iolabs_point_cloud_segmentation_3d import classes, las_modes |
| 12 | from iolabs_point_cloud_segmentation_3d.config import ( | 13 | from iolabs_point_cloud_segmentation_3d.config import ( |
| 13 | ConfigError, | 14 | ConfigError, |
| 14 | LasRgbMode, | 15 | LasRgbMode, |
| 15 | LasSplitMode, | 16 | LasSplitMode, |
| 16 | Seg3dConfig, | 17 | Seg3dConfig, |
| 18 | Seg3dConfigError, | ||
| 17 | config_from_dict, | 19 | config_from_dict, |
| 18 | load_config, | 20 | load_config, |
| 19 | load_default_config_dict, | 21 | load_default_config_dict, |
| 20 | parse_set_overrides, | 22 | parse_set_overrides, |
| 21 | ) | 23 | ) |
| 22 | 24 | ||
| 23 | 25 | ||
| 24 | def test_model_defaults_match_the_packaged_json(): | 26 | def test_model_defaults_match_packaged_json(): |
| 25 | assert Seg3dConfig().model_dump() == config_from_dict( | 27 | assert Seg3dConfig().model_dump() == config_from_dict( |
| 26 | load_default_config_dict() | 28 | load_default_config_dict() |
| 27 | ).model_dump() | 29 | ).model_dump() |
| 28 | assert set(load_default_config_dict()) == set(Seg3dConfig.model_fields) | 30 | assert set(load_default_config_dict()) == set(Seg3dConfig.model_fields) |
| 29 | 31 | ||
| 30 | 32 | ||
| 33 | def test_error_class_is_config_error(): | ||
| 34 | assert issubclass(Seg3dConfigError, config_loader.ConfigError) | ||
| 35 | assert issubclass(Seg3dConfigError, ValueError) | ||
| 36 | # The pre-rename spelling stays importable for existing callers. | ||
| 37 | assert ConfigError is Seg3dConfigError | ||
| 38 | |||
| 39 | |||
| 40 | def test_load_config_returns_packaged_defaults(): | ||
| 41 | assert load_config().model_dump() == load_default_config_dict() | ||
| 42 | |||
| 43 | |||
| 31 | def test_las_mode_literals_match_las_modes(): | 44 | def test_las_mode_literals_match_las_modes(): |
| 32 | # The CLI offers `las_modes` as argparse choices and the writer | 45 | # The CLI offers `las_modes` as argparse choices and the writer |
| 33 | # re-checks them; the config model validates its own Literals. | 46 | # re-checks them; the config model validates its own Literals. |
| 34 | assert typing.get_args(LasRgbMode) == las_modes.LAS_RGB_MODES | 47 | assert typing.get_args(LasRgbMode) == las_modes.LAS_RGB_MODES |
| 72 | 85 | ||
| 73 | def test_hash_rounding_must_be_positive(): | 86 | def test_hash_rounding_must_be_positive(): |
| 74 | assert config_from_dict({"hash_round_units_per_m": 500.0}) \ | 87 | assert config_from_dict({"hash_round_units_per_m": 500.0}) \ |
| 75 | .hash_round_units_per_m == 500.0 | 88 | .hash_round_units_per_m == 500.0 |
| 76 | with pytest.raises(ConfigError, match="hash_round_units_per_m"): | 89 | with pytest.raises(Seg3dConfigError, match="hash_round_units_per_m"): |
| 77 | config_from_dict({"hash_round_units_per_m": 0}) | 90 | config_from_dict({"hash_round_units_per_m": 0}) |
| 78 | 91 | ||
| 79 | 92 | ||
| 80 | def test_set_overrides_and_types(): | 93 | def test_set_overrides_and_types(): |
| 106 | assert isinstance(cfg.signs_json_paint_radius_max_m, float) | 119 | assert isinstance(cfg.signs_json_paint_radius_max_m, float) |
| 107 | assert cfg.signs_json_paint_enabled is False | 120 | assert cfg.signs_json_paint_enabled is False |
| 108 | 121 | ||
| 109 | 122 | ||
| 110 | def test_unknown_key_rejected(): | 123 | def test_unknown_top_level_key_is_rejected(): |
| 111 | with pytest.raises(ConfigError): | 124 | with pytest.raises(Seg3dConfigError, match="nope"): |
| 112 | config_from_dict({"nope": 1}) | 125 | config_from_dict({"nope": 1}) |
| 113 | 126 | ||
| 114 | 127 | ||
| 115 | def test_las_rgb_mode_validated(): | 128 | def test_las_rgb_mode_validated(): |
| 116 | assert config_from_dict({"las_rgb_mode": "class"}).las_rgb_mode == "class" | 129 | assert config_from_dict({"las_rgb_mode": "class"}).las_rgb_mode == "class" |
| 117 | with pytest.raises(ConfigError, match="las_rgb_mode"): | 130 | with pytest.raises(Seg3dConfigError, match="las_rgb_mode"): |
| 118 | config_from_dict({"las_rgb_mode": "palette"}) | 131 | config_from_dict({"las_rgb_mode": "palette"}) |
| 119 | 132 | ||
| 120 | 133 | ||
| 121 | def test_las_split_validated(): | 134 | def test_las_split_validated(): |
| 122 | assert config_from_dict({"las_split": "instance"}).las_split == "instance" | 135 | assert config_from_dict({"las_split": "instance"}).las_split == "instance" |
| 123 | assert config_from_dict({"las_split": "class"}).las_split == "class" | 136 | assert config_from_dict({"las_split": "class"}).las_split == "class" |
| 124 | with pytest.raises(ConfigError, match="las_split"): | 137 | with pytest.raises(Seg3dConfigError, match="las_split"): |
| 125 | config_from_dict({"las_split": "per_object"}) | 138 | config_from_dict({"las_split": "per_object"}) |
| 126 | 139 | ||
| 127 | 140 | ||
| 128 | def test_las_crs_epsg_validated(): | 141 | def test_las_crs_epsg_validated(): |
| 129 | # 0 disables the VLR; a negative code is rejected. | 142 | # 0 disables the VLR; a negative code is rejected. |
| 130 | assert config_from_dict({"las_crs_epsg": 0}).las_crs_epsg == 0 | 143 | assert config_from_dict({"las_crs_epsg": 0}).las_crs_epsg == 0 |
| 131 | with pytest.raises(ConfigError, match="las_crs_epsg"): | 144 | with pytest.raises(Seg3dConfigError, match="las_crs_epsg"): |
| 132 | config_from_dict({"las_crs_epsg": -1}) | 145 | config_from_dict({"las_crs_epsg": -1}) |
| 133 | 146 | ||
| 134 | 147 | ||
| 135 | def test_las_georeference_can_be_disabled(): | 148 | def test_las_georeference_can_be_disabled(): |
| 153 | assert config_from_dict({"write_ply": "false"}).write_ply is False | 166 | assert config_from_dict({"write_ply": "false"}).write_ply is False |
| 154 | 167 | ||
| 155 | 168 | ||
| 156 | def test_bool_typo_is_rejected_not_read_as_false(): | 169 | def test_bool_typo_is_rejected_not_read_as_false(): |
| 157 | with pytest.raises(ConfigError, match="write_ply"): | 170 | with pytest.raises(Seg3dConfigError, match="write_ply"): |
| 158 | config_from_dict({"write_ply": "flase"}) | 171 | config_from_dict({"write_ply": "flase"}) |
| 159 | with pytest.raises(ConfigError, match="write_ply"): | 172 | with pytest.raises(Seg3dConfigError, match="write_ply"): |
| 160 | config_from_dict({"write_ply": 2}) | 173 | config_from_dict({"write_ply": 2}) |
| 161 | 174 | ||
| 162 | 175 | ||
| 163 | def test_non_integral_value_for_an_int_field_is_rejected(): | 176 | def test_non_integral_value_for_an_int_field_is_rejected(): |
| 164 | with pytest.raises(ConfigError, match="signs_json_paint_min_points"): | 177 | with pytest.raises(Seg3dConfigError, match="signs_json_paint_min_points"): |
| 165 | config_from_dict({"signs_json_paint_min_points": 3.7}) | 178 | config_from_dict({"signs_json_paint_min_points": 3.7}) |
| 166 | with pytest.raises(ConfigError, match="las_crs_epsg"): | 179 | with pytest.raises(Seg3dConfigError, match="las_crs_epsg"): |
| 167 | config_from_dict({"las_crs_epsg": "not-a-number"}) | 180 | config_from_dict({"las_crs_epsg": "not-a-number"}) |
| 168 | 181 | ||
| 169 | 182 | ||
| 170 | def test_vegetation_enums_validated(): | 183 | def test_vegetation_enums_validated(): |
| 178 | {"vegetation_asphalt_rule": "corridor"} | 191 | {"vegetation_asphalt_rule": "corridor"} |
| 179 | ).vegetation_asphalt_rule == "corridor" | 192 | ).vegetation_asphalt_rule == "corridor" |
| 180 | # A typo must die at load time, not silently take the other branch on a | 193 | # A typo must die at load time, not silently take the other branch on a |
| 181 | # 3.5 min fusion run. | 194 | # 3.5 min fusion run. |
| 182 | with pytest.raises(ConfigError, match="vegetation_tall_class"): | 195 | with pytest.raises(Seg3dConfigError, match="vegetation_tall_class"): |
| 183 | config_from_dict({"vegetation_tall_class": "hedge"}) | 196 | config_from_dict({"vegetation_tall_class": "hedge"}) |
| 184 | with pytest.raises(ConfigError, match="vegetation_band_mode"): | 197 | with pytest.raises(Seg3dConfigError, match="vegetation_band_mode"): |
| 185 | config_from_dict({"vegetation_band_mode": "colum"}) | 198 | config_from_dict({"vegetation_band_mode": "colum"}) |
| 186 | with pytest.raises(ConfigError, match="vegetation_asphalt_rule"): | 199 | with pytest.raises(Seg3dConfigError, match="vegetation_asphalt_rule"): |
| 187 | config_from_dict({"vegetation_asphalt_rule": "polygon"}) | 200 | config_from_dict({"vegetation_asphalt_rule": "polygon"}) |
| 188 | 201 | ||
| 189 | 202 | ||
| 190 | def test_vegetation_limiter_ranges_validated(): | 203 | def test_vegetation_limiter_ranges_validated(): |
| 195 | {"vegetation_asphalt_dilate_cells": 0} | 208 | {"vegetation_asphalt_dilate_cells": 0} |
| 196 | ).vegetation_asphalt_dilate_cells == 0 | 209 | ).vegetation_asphalt_dilate_cells == 0 |
| 197 | # A zero cell size divides by zero deep in the rasteriser and a | 210 | # A zero cell size divides by zero deep in the rasteriser and a |
| 198 | # negative count silently means "no floor": both must fail at load. | 211 | # negative count silently means "no floor": both must fail at load. |
| 199 | with pytest.raises(ConfigError, match="vegetation_green_rg_ratio"): | 212 | with pytest.raises(Seg3dConfigError, match="vegetation_green_rg_ratio"): |
| 200 | config_from_dict({"vegetation_green_rg_ratio": 0.0}) | 213 | config_from_dict({"vegetation_green_rg_ratio": 0.0}) |
| 201 | with pytest.raises(ConfigError, match="vegetation_asphalt_cell_m"): | 214 | with pytest.raises(Seg3dConfigError, match="vegetation_asphalt_cell_m"): |
| 202 | config_from_dict({"vegetation_asphalt_cell_m": 0.0}) | 215 | config_from_dict({"vegetation_asphalt_cell_m": 0.0}) |
| 203 | with pytest.raises(ConfigError, match="vegetation_asphalt_cell_m"): | 216 | with pytest.raises(Seg3dConfigError, match="vegetation_asphalt_cell_m"): |
| 204 | config_from_dict({"vegetation_asphalt_cell_m": -0.25}) | 217 | config_from_dict({"vegetation_asphalt_cell_m": -0.25}) |
| 205 | with pytest.raises(ConfigError, match="vegetation_asphalt_dilate_cells"): | 218 | with pytest.raises(Seg3dConfigError, match="vegetation_asphalt_dilate_cells"): |
| 206 | config_from_dict({"vegetation_asphalt_dilate_cells": -1}) | 219 | config_from_dict({"vegetation_asphalt_dilate_cells": -1}) |
| 207 | with pytest.raises(ConfigError, match="vegetation_asphalt_min_points"): | 220 | with pytest.raises(Seg3dConfigError, match="vegetation_asphalt_min_points"): |
| 208 | config_from_dict({"vegetation_asphalt_min_points": -2}) | 221 | config_from_dict({"vegetation_asphalt_min_points": -2}) |
| 209 | with pytest.raises(ConfigError, match="vegetation_min_cell_points"): | 222 | with pytest.raises(Seg3dConfigError, match="vegetation_min_cell_points"): |
| 210 | config_from_dict({"vegetation_min_cell_points": -1}) | 223 | config_from_dict({"vegetation_min_cell_points": -1}) |
| 211 | 224 | ||
| 212 | 225 | ||
| 213 | def test_vegetation_set_overrides_coerce(): | 226 | def test_vegetation_set_overrides_coerce(): |
| 271 | {"vegetation_corridor_rail_m": 0.0} | 284 | {"vegetation_corridor_rail_m": 0.0} |
| 272 | ).vegetation_corridor_rail_m == 0.0 | 285 | ).vegetation_corridor_rail_m == 0.0 |
| 273 | 286 | ||
| 274 | for bad in (-1.0, float("nan"), float("inf")): | 287 | for bad in (-1.0, float("nan"), float("inf")): |
| 275 | with pytest.raises(ConfigError, match="vegetation_corridor_rail_m"): | 288 | with pytest.raises(Seg3dConfigError, match="vegetation_corridor_rail_m"): |
| 276 | config_from_dict({"vegetation_corridor_rail_m": bad}) | 289 | config_from_dict({"vegetation_corridor_rail_m": bad}) |
| 277 | 290 | ||
| 278 | 291 | ||
| 279 | def test_vegetation_corridor_max_height_validated(): | 292 | def test_vegetation_corridor_max_height_validated(): |
| 284 | ).vegetation_corridor_max_height_m == 0.0 | 297 | ).vegetation_corridor_max_height_m == 0.0 |
| 285 | 298 | ||
| 286 | for bad in (-1.0, float("nan"), float("inf")): | 299 | for bad in (-1.0, float("nan"), float("inf")): |
| 287 | with pytest.raises( | 300 | with pytest.raises( |
| 288 | ConfigError, match="vegetation_corridor_max_height_m" | 301 | Seg3dConfigError, match="vegetation_corridor_max_height_m" |
| 289 | ): | 302 | ): |
| 290 | config_from_dict({"vegetation_corridor_max_height_m": bad}) | 303 | config_from_dict({"vegetation_corridor_max_height_m": bad}) |
| 291 | 304 | ||
| 292 | 305 |
| 295 | # every one of these has to fail at load time instead. | 308 | # every one of these has to fail at load time instead. |
| 296 | for name in ("vegetation_ground_cell_m", "vegetation_band_cell_m"): | 309 | for name in ("vegetation_ground_cell_m", "vegetation_band_cell_m"): |
| 297 | assert getattr(config_from_dict({name: 2.0}), name) == 2.0 | 310 | assert getattr(config_from_dict({name: 2.0}), name) == 2.0 |
| 298 | for bad in (0.0, -0.5, float("nan"), float("inf")): | 311 | for bad in (0.0, -0.5, float("nan"), float("inf")): |
| 299 | with pytest.raises(ConfigError, match=name): | 312 | with pytest.raises(Seg3dConfigError, match=name): |
| 300 | config_from_dict({name: bad}) | 313 | config_from_dict({name: bad}) |
| 301 | 314 | ||
| 302 | 315 | ||
| 303 | def test_vegetation_min_ground_points_validated(): | 316 | def test_vegetation_min_ground_points_validated(): |
| 305 | {"vegetation_min_ground_points": 1} | 318 | {"vegetation_min_ground_points": 1} |
| 306 | ).vegetation_min_ground_points == 1 | 319 | ).vegetation_min_ground_points == 1 |
| 307 | # 0 reached numpy as a zero-size reduction. | 320 | # 0 reached numpy as a zero-size reduction. |
| 308 | for bad in (0, -5): | 321 | for bad in (0, -5): |
| 309 | with pytest.raises(ConfigError, match="vegetation_min_ground_points"): | 322 | with pytest.raises(Seg3dConfigError, match="vegetation_min_ground_points"): |
| 310 | config_from_dict({"vegetation_min_ground_points": bad}) | 323 | config_from_dict({"vegetation_min_ground_points": bad}) |
| 311 | 324 | ||
| 312 | 325 | ||
| 313 | def test_vegetation_percentiles_validated(): | 326 | def test_vegetation_percentiles_validated(): |
| 316 | ): | 329 | ): |
| 317 | for good in (0.0, 50.0, 100.0): | 330 | for good in (0.0, 50.0, 100.0): |
| 318 | assert getattr(config_from_dict({name: good}), name) == good | 331 | assert getattr(config_from_dict({name: good}), name) == good |
| 319 | for bad in (-1.0, 100.1, float("nan")): | 332 | for bad in (-1.0, 100.1, float("nan")): |
| 320 | with pytest.raises(ConfigError, match=name): | 333 | with pytest.raises(Seg3dConfigError, match=name): |
| 321 | config_from_dict({name: bad}) | 334 | config_from_dict({name: bad}) |
| 322 | 335 | ||
| 323 | 336 | ||
| 324 | def test_vegetation_min_height_must_be_finite(): | 337 | def test_vegetation_min_height_must_be_finite(): |
| 327 | assert config_from_dict( | 340 | assert config_from_dict( |
| 328 | {"vegetation_min_height_m": -100.0} | 341 | {"vegetation_min_height_m": -100.0} |
| 329 | ).vegetation_min_height_m == -100.0 | 342 | ).vegetation_min_height_m == -100.0 |
| 330 | for bad in (float("nan"), float("inf"), float("-inf")): | 343 | for bad in (float("nan"), float("inf"), float("-inf")): |
| 331 | with pytest.raises(ConfigError, match="vegetation_min_height_m"): | 344 | with pytest.raises(Seg3dConfigError, match="vegetation_min_height_m"): |
| 332 | config_from_dict({"vegetation_min_height_m": bad}) | 345 | config_from_dict({"vegetation_min_height_m": bad}) |
| 333 | 346 | ||
| 334 | 347 | ||
| 335 | def test_vegetation_green_and_tree_knobs_must_be_finite(): | 348 | def test_vegetation_green_and_tree_knobs_must_be_finite(): |
| 352 | "vegetation_green_min_brightness", | 365 | "vegetation_green_min_brightness", |
| 353 | "vegetation_tree_min_height_m", | 366 | "vegetation_tree_min_height_m", |
| 354 | ): | 367 | ): |
| 355 | for bad in (float("nan"), float("inf"), float("-inf")): | 368 | for bad in (float("nan"), float("inf"), float("-inf")): |
| 356 | with pytest.raises(ConfigError, match=name): | 369 | with pytest.raises(Seg3dConfigError, match=name): |
| 357 | config_from_dict({name: bad}) | 370 | config_from_dict({name: bad}) |
| 358 | 371 | ||
| 359 | 372 | ||
| 360 | def test_vegetation_asphalt_dilate_cells_has_an_upper_bound(): | 373 | def test_vegetation_asphalt_dilate_cells_has_an_upper_bound(): |
| 364 | {"vegetation_asphalt_dilate_cells": 64} | 377 | {"vegetation_asphalt_dilate_cells": 64} |
| 365 | ).vegetation_asphalt_dilate_cells == 64 | 378 | ).vegetation_asphalt_dilate_cells == 64 |
| 366 | for bad in (65, 500): | 379 | for bad in (65, 500): |
| 367 | with pytest.raises( | 380 | with pytest.raises( |
| 368 | ConfigError, match="vegetation_asphalt_dilate_cells" | 381 | Seg3dConfigError, match="vegetation_asphalt_dilate_cells" |
| 369 | ): | 382 | ): |
| 370 | config_from_dict({"vegetation_asphalt_dilate_cells": bad}) | 383 | config_from_dict({"vegetation_asphalt_dilate_cells": bad}) |
| 371 | 384 | ||
| 372 | 385 |
| 377 | assert ok.vegetation_low_max_m == ok.vegetation_medium_max_m == 1.0 | 390 | assert ok.vegetation_low_max_m == ok.vegetation_medium_max_m == 1.0 |
| 378 | 391 | ||
| 379 | for name in ("vegetation_low_max_m", "vegetation_medium_max_m"): | 392 | for name in ("vegetation_low_max_m", "vegetation_medium_max_m"): |
| 380 | for bad in (0.0, -1.0, float("nan"), float("inf")): | 393 | for bad in (0.0, -1.0, float("nan"), float("inf")): |
| 381 | with pytest.raises(ConfigError, match=name): | 394 | with pytest.raises(Seg3dConfigError, match=name): |
| 382 | config_from_dict({name: bad}) | 395 | config_from_dict({name: bad}) |
| 383 | # The low band cannot end above where the medium band ends. | 396 | # The low band cannot end above where the medium band ends. |
| 384 | with pytest.raises(ConfigError, match="vegetation_low_max_m"): | 397 | with pytest.raises(Seg3dConfigError, match="vegetation_low_max_m"): |
| 385 | config_from_dict( | 398 | config_from_dict( |
| 386 | {"vegetation_low_max_m": 3.0, "vegetation_medium_max_m": 2.0} | 399 | {"vegetation_low_max_m": 3.0, "vegetation_medium_max_m": 2.0} |
| 387 | ) | 400 | ) |
| 388 | 401 |
| 391 | # A cross-field rule is a whole-model validator, so it carries no field | 404 | # A cross-field rule is a whole-model validator, so it carries no field |
| 392 | # location; the message must stay the rule's own text and not grow a | 405 | # location; the message must stay the rule's own text and not grow a |
| 393 | # dump of every config key (which is what an unlocated value error | 406 | # dump of every config key (which is what an unlocated value error |
| 394 | # would otherwise echo back). | 407 | # would otherwise echo back). |
| 395 | with pytest.raises(ConfigError) as excinfo: | 408 | with pytest.raises(Seg3dConfigError) as excinfo: |
| 396 | config_from_dict( | 409 | config_from_dict( |
| 397 | {**load_default_config_dict(), "vegetation_low_max_m": 3.0} | 410 | {**load_default_config_dict(), "vegetation_low_max_m": 3.0} |
| 398 | ) | 411 | ) |
| 399 | assert str(excinfo.value) == ( | 412 | assert str(excinfo.value) == ( |
| 403 | ) | 416 | ) |
| 404 | 417 | ||
| 405 | 418 | ||
| 406 | def test_naming_rule_reports_only_its_own_message(): | 419 | def test_naming_rule_reports_only_its_own_message(): |
| 407 | with pytest.raises(ConfigError) as excinfo: | 420 | with pytest.raises(Seg3dConfigError) as excinfo: |
| 408 | config_from_dict( | 421 | config_from_dict( |
| 409 | {**load_default_config_dict(), "date_tag": "notadate"} | 422 | {**load_default_config_dict(), "date_tag": "notadate"} |
| 410 | ) | 423 | ) |
| 411 | assert str(excinfo.value) == ( | 424 | assert str(excinfo.value) == ( |
| 430 | "vegetation_green_min_brightness=inf", | 443 | "vegetation_green_min_brightness=inf", |
| 431 | "vegetation_tree_min_height_m=nan", | 444 | "vegetation_tree_min_height_m=nan", |
| 432 | "vegetation_asphalt_dilate_cells=500", | 445 | "vegetation_asphalt_dilate_cells=500", |
| 433 | ): | 446 | ): |
| 434 | with pytest.raises(ConfigError, match="vegetation_"): | 447 | with pytest.raises(Seg3dConfigError, match="vegetation_"): |
| 435 | load_config(overrides=parse_set_overrides([override])) | 448 | load_config(overrides=parse_set_overrides([override])) |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.