Back to report index

Step 3 3dsegmentation 22ba30b: AI3D-379 Align config module with fleet pattern

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(-)
Importance #1: src/iolabs_point_cloud_segmentation_3d/_config_model.py @@ -32,17 +35,22 @@
32LasRgbMode = Literal["sensor", "class"]35LasRgbMode = Literal["sensor", "class"]
33LasSplitMode = Literal["none", "class", "instance"]36LasSplitMode = Literal["none", "class", "instance"]
3437
3538
36class ConfigError(config_loader.ConfigError):39class 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.
3841
39 Covers malformed config JSON, unknown keys, values that are not valid42 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 """
4346
4447
48#: Deprecated alias kept for callers that import the pre-rename spelling
49#: (`scripts/veg_sweep.py`); use `Seg3dConfigError`.
50ConfigError = Seg3dConfigError
51
52
45class Seg3dConfig(config_loader.ConfigModel):53class Seg3dConfig(config_loader.ConfigModel):
46 """Numeric thresholds for the fusion pipeline (metres unless stated).54 """Numeric thresholds for the fusion pipeline (metres unless stated).
4755
48 The per-field comments below carry the rationale; this section is the56 The per-field comments below carry the rationale; this section is the
Importance #2: src/iolabs_point_cloud_segmentation_3d/config.py @@ -36,21 +43,21 @@
3643
37logger = logging.getLogger(__name__)44logger = logging.getLogger(__name__)
3845
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"
4249
4350
44def load_default_config_dict() -> dict[str, Any]:51def 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_NAME54 _PACKAGE_NAME, _DEFAULT_FILENAME
48 )55 )
4956
5057
51def config_from_dict(raw: dict[str, Any]) -> Seg3dConfig:58def 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.
5360
54 Unknown keys are rejected and each raw value is coerced to its field's61 Unknown keys are rejected and each raw value is coerced to its field's
55 declared type by `iolabs.common.config_loader.ConfigModel`, which is62 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/163 strict: a bool typo (`"flase"`), a bool given as an int other than 0/1
Importance #3: src/iolabs_point_cloud_segmentation_3d/config.py @@ -65,23 +72,23 @@
65 Returns:72 Returns:
66 The validated `Seg3dConfig`.73 The validated `Seg3dConfig`.
6774
68 Raises:75 Raises:
69 ConfigError: `raw` contains an unknown key, or a value that is not76 Seg3dConfigError: `raw` contains an unknown key, or a value that is not
70 valid for its field's declared type or outside its declared77 valid for its field's declared type or outside its declared
71 range (see the `pydantic.Field` bounds and the model78 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=ConfigError82 Seg3dConfig, raw, context=_CONTEXT, error_cls=Seg3dConfigError
76 )83 )
7784
7885
79def load_config(86def 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.
8491
85 Args:92 Args:
86 config_path: JSON file read instead of the packaged default, or93 config_path: JSON file read instead of the packaged default, or
87 `None`. It may be partial: keys it omits fall back to the94 `None`. It may be partial: keys it omits fall back to the
Importance #4: src/iolabs_point_cloud_segmentation_3d/_config_model.py @@ -1,16 +1,19 @@
1"""The `Seg3dConfig` schema: every knob, its default and its range.1"""The `Seg3dConfig` schema: every knob, its default and its range.
22
3The model mirrors `seg3d.default.json` key for key -- adding a knob is a3The schema is `Seg3dConfig` (a `config_loader.ConfigModel`), mirroring
4field 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
5value coercion and the error messages come from5cross-field rules are model validators. `config.py` is the public entry
6`iolabs.common.config_loader.ConfigModel`; ranges are `pydantic.Field`6point (loading, merging, `--set` parsing) and re-exports both names.
7bounds, cross-field rules are model validators. `config.py` is the public7
8entry point (loading, merging, `--set` parsing) and re-exports both names.8Adding 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.
910
10Distances are in metres unless the field name says otherwise.11Distances are in metres unless the field name says otherwise.
11"""12"""
1213
14from __future__ import annotations
15
13import logging16import logging
14from typing import Literal17from typing import Literal
1518
16import pydantic19import pydantic
Importance #5: src/iolabs_point_cloud_segmentation_3d/_config_model.py @@ -368,10 +376,10 @@
368 branch_tag: str = ""376 branch_tag: str = ""
369 date_tag: str = ""377 date_tag: str = ""
370378
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 "
Importance #6: src/iolabs_point_cloud_segmentation_3d/_config_model.py @@ -381,10 +389,10 @@
381 )389 )
382 return self390 return self
383391
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=...`
Importance #7: src/iolabs_point_cloud_segmentation_3d/_config_model.py @@ -393,10 +401,10 @@
393 raise ValueError(str(exc)) from exc401 raise ValueError(str(exc)) from exc
394 return self402 return self
395403
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.
399407
400 A warning, not an error: single-tier boosts are legitimate. The408 A warning, not an error: single-tier boosts are legitimate. The
401 sharp edge is pre-ground overlay configs that pin the old numbers409 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`
Importance #8: src/iolabs_point_cloud_segmentation_3d/config.py @@ -1,34 +1,41 @@
1"""Package-owned algorithm configuration.1"""Package-owned algorithm configuration for the seg3d fusion CLI.
22
3Mirrors the config convention used by the sibling iolabs point-cloud3The schema is `Seg3dConfig` (a `config_loader.ConfigModel`, declared in
4packages (`guardrails` / `verticalsigns`): the package owns a4`_config_model.py`), mirroring `seg3d.default.json` key for key. Runtime
5`seg3d.default.json` algorithm config, and a frozen typed params object5overrides are applied through repeatable `--set KEY=VALUE` flags or a
6(`Seg3dConfig`, the pydantic model in `_config_model.py`) is loaded from it6`--config` JSON file, never repo-local edits to the packaged default.
7at CLI start. Every model field default is kept identical to7
8`seg3d.default.json` (guarded by `tests/test_config.py`), so `Seg3dConfig()`8Adding a config key means adding the field to the model (with its range
9and `load_config` agree. Runtime overrides are applied through repeatable
10`--set KEY=VALUE` flags or a `--config` JSON file, never repo-local edits to
11the packaged default.
12
13Adding a knob is two edits: a field on `Seg3dConfig` (with its range
14expressed as `pydantic.Field(...)` bounds or a `model_validator`) and the9expressed as `pydantic.Field(...)` bounds or a `model_validator`) and the
15same key with the same default in `seg3d.default.json`.10same 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"""
1714
15from __future__ import annotations
16
18import logging17import logging
19from pathlib import Path18from pathlib import Path
20from typing import Any19from typing import Any
2120
22from iolabs.common import config_loader21from iolabs.common import config_loader
2322
24from ._config_model import ConfigError, LasRgbMode, LasSplitMode, Seg3dConfig23from ._config_model import (
24 ConfigError,
25 LasRgbMode,
26 LasSplitMode,
27 Seg3dConfig,
28 Seg3dConfigError,
29)
2530
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",
Importance #9: src/iolabs_point_cloud_segmentation_3d/config.py @@ -93,18 +100,18 @@
93 Returns:100 Returns:
94 The validated `Seg3dConfig`.101 The validated `Seg3dConfig`.
95102
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:
Importance #10: src/iolabs_point_cloud_segmentation_3d/config.py @@ -114,13 +121,13 @@
114 return config121 return config
115122
116123
117def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]:124def 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.
119126
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 own128 flat keys (the seg3d config has no sections) and seg3d's own
122 `ConfigError`.129 `Seg3dConfigError`.
123130
124 Args:131 Args:
125 raw_overrides: The raw `KEY=VALUE` strings, or `None`.132 raw_overrides: The raw `KEY=VALUE` strings, or `None`.
126133
Importance #11: src/iolabs_point_cloud_segmentation_3d/config.py @@ -128,9 +135,9 @@
128 A flat mapping of key to decoded value (raw text when the value is135 A flat mapping of key to decoded value (raw text when the value is
129 not valid JSON).136 not valid JSON).
130137
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=ConfigError142 raw_overrides, error_cls=Seg3dConfigError
136 )143 )
Importance #12: tests/test_config.py @@ -6,29 +6,42 @@
66
7import typing7import typing
88
9import pytest9import pytest
10from iolabs.common import config_loader
1011
11from iolabs_point_cloud_segmentation_3d import classes, las_modes12from iolabs_point_cloud_segmentation_3d import classes, las_modes
12from iolabs_point_cloud_segmentation_3d.config import (13from 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)
2224
2325
24def test_model_defaults_match_the_packaged_json():26def 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)
2931
3032
33def 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
40def test_load_config_returns_packaged_defaults():
41 assert load_config().model_dump() == load_default_config_dict()
42
43
31def test_las_mode_literals_match_las_modes():44def test_las_mode_literals_match_las_modes():
32 # The CLI offers `las_modes` as argparse choices and the writer45 # 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_MODES47 assert typing.get_args(LasRgbMode) == las_modes.LAS_RGB_MODES
Importance #13: tests/test_config.py @@ -72,9 +85,9 @@
7285
73def test_hash_rounding_must_be_positive():86def 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.088 .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})
7891
7992
80def test_set_overrides_and_types():93def test_set_overrides_and_types():
Importance #14: tests/test_config.py @@ -106,30 +119,30 @@
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 False120 assert cfg.signs_json_paint_enabled is False
108121
109122
110def test_unknown_key_rejected():123def 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})
113126
114127
115def test_las_rgb_mode_validated():128def 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"})
119132
120133
121def test_las_split_validated():134def 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"})
126139
127140
128def test_las_crs_epsg_validated():141def 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 == 0143 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})
133146
134147
135def test_las_georeference_can_be_disabled():148def test_las_georeference_can_be_disabled():
Importance #15: tests/test_config.py @@ -153,18 +166,18 @@
153 assert config_from_dict({"write_ply": "false"}).write_ply is False166 assert config_from_dict({"write_ply": "false"}).write_ply is False
154167
155168
156def test_bool_typo_is_rejected_not_read_as_false():169def 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})
161174
162175
163def test_non_integral_value_for_an_int_field_is_rejected():176def 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"})
168181
169182
170def test_vegetation_enums_validated():183def test_vegetation_enums_validated():
Importance #16: tests/test_config.py @@ -178,13 +191,13 @@
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 a193 # 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"})
188201
189202
190def test_vegetation_limiter_ranges_validated():203def test_vegetation_limiter_ranges_validated():
Importance #17: tests/test_config.py @@ -195,19 +208,19 @@
195 {"vegetation_asphalt_dilate_cells": 0}208 {"vegetation_asphalt_dilate_cells": 0}
196 ).vegetation_asphalt_dilate_cells == 0209 ).vegetation_asphalt_dilate_cells == 0
197 # A zero cell size divides by zero deep in the rasteriser and a210 # 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})
211224
212225
213def test_vegetation_set_overrides_coerce():226def test_vegetation_set_overrides_coerce():
Importance #18: tests/test_config.py @@ -271,9 +284,9 @@
271 {"vegetation_corridor_rail_m": 0.0}284 {"vegetation_corridor_rail_m": 0.0}
272 ).vegetation_corridor_rail_m == 0.0285 ).vegetation_corridor_rail_m == 0.0
273286
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})
277290
278291
279def test_vegetation_corridor_max_height_validated():292def test_vegetation_corridor_max_height_validated():
Importance #19: tests/test_config.py @@ -284,9 +297,9 @@
284 ).vegetation_corridor_max_height_m == 0.0297 ).vegetation_corridor_max_height_m == 0.0
285298
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})
291304
292305
Importance #20: tests/test_config.py @@ -295,9 +308,9 @@
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.0310 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})
301314
302315
303def test_vegetation_min_ground_points_validated():316def test_vegetation_min_ground_points_validated():
Importance #21: tests/test_config.py @@ -305,9 +318,9 @@
305 {"vegetation_min_ground_points": 1}318 {"vegetation_min_ground_points": 1}
306 ).vegetation_min_ground_points == 1319 ).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})
311324
312325
313def test_vegetation_percentiles_validated():326def test_vegetation_percentiles_validated():
Importance #22: tests/test_config.py @@ -316,9 +329,9 @@
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) == good331 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})
322335
323336
324def test_vegetation_min_height_must_be_finite():337def test_vegetation_min_height_must_be_finite():
Importance #23: tests/test_config.py @@ -327,9 +340,9 @@
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.0342 ).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})
333346
334347
335def test_vegetation_green_and_tree_knobs_must_be_finite():348def test_vegetation_green_and_tree_knobs_must_be_finite():
Importance #24: tests/test_config.py @@ -352,9 +365,9 @@
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})
358371
359372
360def test_vegetation_asphalt_dilate_cells_has_an_upper_bound():373def test_vegetation_asphalt_dilate_cells_has_an_upper_bound():
Importance #25: tests/test_config.py @@ -364,9 +377,9 @@
364 {"vegetation_asphalt_dilate_cells": 64}377 {"vegetation_asphalt_dilate_cells": 64}
365 ).vegetation_asphalt_dilate_cells == 64378 ).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})
371384
372385
Importance #26: tests/test_config.py @@ -377,12 +390,12 @@
377 assert ok.vegetation_low_max_m == ok.vegetation_medium_max_m == 1.0390 assert ok.vegetation_low_max_m == ok.vegetation_medium_max_m == 1.0
378391
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 )
388401
Importance #27: tests/test_config.py @@ -391,9 +404,9 @@
391 # A cross-field rule is a whole-model validator, so it carries no field404 # 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 a405 # 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 error406 # 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) == (
Importance #28: tests/test_config.py @@ -403,9 +416,9 @@
403 )416 )
404417
405418
406def test_naming_rule_reports_only_its_own_message():419def 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) == (
Importance #29: tests/test_config.py @@ -430,6 +443,6 @@
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]))
Importance #30: README.md @@ -141,17 +141,20 @@
141```141```
142142
143## Config143## Config
144144
145All numeric thresholds live in the package-owned `seg3d.default.json`, loaded145Defaults live in `src/iolabs_point_cloud_segmentation_3d/seg3d.default.json`.
146into a frozen `Seg3dConfig` pydantic model (`_config_model.py`, derived from146The schema is `Seg3dConfig` in `_config_model.py` (a
147`iolabs.common.config_loader.ConfigModel`; `config.py` is the loading entry147`config_loader.ConfigModel`); unknown keys are rejected. **To add a config key:
148point). Every model field default is kept identical to the JSON, asserted by148add 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.149to the model and the same key with the same default to the JSON โ€” nothing
150Adding a knob is two edits: the field on `Seg3dConfig` (its range expressed as150else.** `load_default_config_dict` returns a plain `dict`; `config_from_dict`
151`pydantic.Field(...)` bounds or a model validator) and the same key with the151and `load_config` (in `config.py`, the loading entry point) return the frozen
152same default in `seg3d.default.json` -- unknown-key rejection, value coercion152`Seg3dConfig`, and its errors are `Seg3dConfigError`. Every model field default
153and the error messages come from the shared layer.153is kept identical to the JSON, asserted by `tests/test_config.py`, so
154`Seg3dConfig()` and `load_config()` always agree. Runtime overrides come from
155repeatable `--set KEY=VALUE` or a `--config` JSON file, never repo-local edits
156to the packaged default.
154157
155Override without editing the packaged default:158Override without editing the packaged default:
156159
157```bash160```bash
Importance #31: src/iolabs_point_cloud_segmentation_3d/_config_model.py @@ -1,16 +1,19 @@
1"""The `Seg3dConfig` schema: every knob, its default and its range.1"""The `Seg3dConfig` schema: every knob, its default and its range.
22
3The model mirrors `seg3d.default.json` key for key -- adding a knob is a3The schema is `Seg3dConfig` (a `config_loader.ConfigModel`), mirroring
4field 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
5value coercion and the error messages come from5cross-field rules are model validators. `config.py` is the public entry
6`iolabs.common.config_loader.ConfigModel`; ranges are `pydantic.Field`6point (loading, merging, `--set` parsing) and re-exports both names.
7bounds, cross-field rules are model validators. `config.py` is the public7
8entry point (loading, merging, `--set` parsing) and re-exports both names.8Adding 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.
910
10Distances are in metres unless the field name says otherwise.11Distances are in metres unless the field name says otherwise.
11"""12"""
1213
14from __future__ import annotations
15
13import logging16import logging
14from typing import Literal17from typing import Literal
1518
16import pydantic19import pydantic
Importance #32: src/iolabs_point_cloud_segmentation_3d/_config_model.py @@ -32,17 +35,22 @@
32LasRgbMode = Literal["sensor", "class"]35LasRgbMode = Literal["sensor", "class"]
33LasSplitMode = Literal["none", "class", "instance"]36LasSplitMode = Literal["none", "class", "instance"]
3437
3538
36class ConfigError(config_loader.ConfigError):39class 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.
3841
39 Covers malformed config JSON, unknown keys, values that are not valid42 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 """
4346
4447
48#: Deprecated alias kept for callers that import the pre-rename spelling
49#: (`scripts/veg_sweep.py`); use `Seg3dConfigError`.
50ConfigError = Seg3dConfigError
51
52
45class Seg3dConfig(config_loader.ConfigModel):53class Seg3dConfig(config_loader.ConfigModel):
46 """Numeric thresholds for the fusion pipeline (metres unless stated).54 """Numeric thresholds for the fusion pipeline (metres unless stated).
4755
48 The per-field comments below carry the rationale; this section is the56 The per-field comments below carry the rationale; this section is the
Importance #33: src/iolabs_point_cloud_segmentation_3d/_config_model.py @@ -368,10 +376,10 @@
368 branch_tag: str = ""376 branch_tag: str = ""
369 date_tag: str = ""377 date_tag: str = ""
370378
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 "
Importance #34: src/iolabs_point_cloud_segmentation_3d/_config_model.py @@ -381,10 +389,10 @@
381 )389 )
382 return self390 return self
383391
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=...`
Importance #35: src/iolabs_point_cloud_segmentation_3d/_config_model.py @@ -393,10 +401,10 @@
393 raise ValueError(str(exc)) from exc401 raise ValueError(str(exc)) from exc
394 return self402 return self
395403
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.
399407
400 A warning, not an error: single-tier boosts are legitimate. The408 A warning, not an error: single-tier boosts are legitimate. The
401 sharp edge is pre-ground overlay configs that pin the old numbers409 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`
Importance #36: src/iolabs_point_cloud_segmentation_3d/config.py @@ -1,34 +1,41 @@
1"""Package-owned algorithm configuration.1"""Package-owned algorithm configuration for the seg3d fusion CLI.
22
3Mirrors the config convention used by the sibling iolabs point-cloud3The schema is `Seg3dConfig` (a `config_loader.ConfigModel`, declared in
4packages (`guardrails` / `verticalsigns`): the package owns a4`_config_model.py`), mirroring `seg3d.default.json` key for key. Runtime
5`seg3d.default.json` algorithm config, and a frozen typed params object5overrides are applied through repeatable `--set KEY=VALUE` flags or a
6(`Seg3dConfig`, the pydantic model in `_config_model.py`) is loaded from it6`--config` JSON file, never repo-local edits to the packaged default.
7at CLI start. Every model field default is kept identical to7
8`seg3d.default.json` (guarded by `tests/test_config.py`), so `Seg3dConfig()`8Adding a config key means adding the field to the model (with its range
9and `load_config` agree. Runtime overrides are applied through repeatable
10`--set KEY=VALUE` flags or a `--config` JSON file, never repo-local edits to
11the packaged default.
12
13Adding a knob is two edits: a field on `Seg3dConfig` (with its range
14expressed as `pydantic.Field(...)` bounds or a `model_validator`) and the9expressed as `pydantic.Field(...)` bounds or a `model_validator`) and the
15same key with the same default in `seg3d.default.json`.10same 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"""
1714
15from __future__ import annotations
16
18import logging17import logging
19from pathlib import Path18from pathlib import Path
20from typing import Any19from typing import Any
2120
22from iolabs.common import config_loader21from iolabs.common import config_loader
2322
24from ._config_model import ConfigError, LasRgbMode, LasSplitMode, Seg3dConfig23from ._config_model import (
24 ConfigError,
25 LasRgbMode,
26 LasSplitMode,
27 Seg3dConfig,
28 Seg3dConfigError,
29)
2530
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",
Importance #37: src/iolabs_point_cloud_segmentation_3d/config.py @@ -36,21 +43,21 @@
3643
37logger = logging.getLogger(__name__)44logger = logging.getLogger(__name__)
3845
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"
4249
4350
44def load_default_config_dict() -> dict[str, Any]:51def 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_NAME54 _PACKAGE_NAME, _DEFAULT_FILENAME
48 )55 )
4956
5057
51def config_from_dict(raw: dict[str, Any]) -> Seg3dConfig:58def 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.
5360
54 Unknown keys are rejected and each raw value is coerced to its field's61 Unknown keys are rejected and each raw value is coerced to its field's
55 declared type by `iolabs.common.config_loader.ConfigModel`, which is62 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/163 strict: a bool typo (`"flase"`), a bool given as an int other than 0/1
Importance #38: src/iolabs_point_cloud_segmentation_3d/config.py @@ -65,23 +72,23 @@
65 Returns:72 Returns:
66 The validated `Seg3dConfig`.73 The validated `Seg3dConfig`.
6774
68 Raises:75 Raises:
69 ConfigError: `raw` contains an unknown key, or a value that is not76 Seg3dConfigError: `raw` contains an unknown key, or a value that is not
70 valid for its field's declared type or outside its declared77 valid for its field's declared type or outside its declared
71 range (see the `pydantic.Field` bounds and the model78 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=ConfigError82 Seg3dConfig, raw, context=_CONTEXT, error_cls=Seg3dConfigError
76 )83 )
7784
7885
79def load_config(86def 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.
8491
85 Args:92 Args:
86 config_path: JSON file read instead of the packaged default, or93 config_path: JSON file read instead of the packaged default, or
87 `None`. It may be partial: keys it omits fall back to the94 `None`. It may be partial: keys it omits fall back to the
Importance #39: src/iolabs_point_cloud_segmentation_3d/config.py @@ -93,18 +100,18 @@
93 Returns:100 Returns:
94 The validated `Seg3dConfig`.101 The validated `Seg3dConfig`.
95102
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:
Importance #40: src/iolabs_point_cloud_segmentation_3d/config.py @@ -114,13 +121,13 @@
114 return config121 return config
115122
116123
117def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]:124def 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.
119126
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 own128 flat keys (the seg3d config has no sections) and seg3d's own
122 `ConfigError`.129 `Seg3dConfigError`.
123130
124 Args:131 Args:
125 raw_overrides: The raw `KEY=VALUE` strings, or `None`.132 raw_overrides: The raw `KEY=VALUE` strings, or `None`.
126133
Importance #41: src/iolabs_point_cloud_segmentation_3d/config.py @@ -128,9 +135,9 @@
128 A flat mapping of key to decoded value (raw text when the value is135 A flat mapping of key to decoded value (raw text when the value is
129 not valid JSON).136 not valid JSON).
130137
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=ConfigError142 raw_overrides, error_cls=Seg3dConfigError
136 )143 )
Importance #42: tests/test_config.py @@ -6,29 +6,42 @@
66
7import typing7import typing
88
9import pytest9import pytest
10from iolabs.common import config_loader
1011
11from iolabs_point_cloud_segmentation_3d import classes, las_modes12from iolabs_point_cloud_segmentation_3d import classes, las_modes
12from iolabs_point_cloud_segmentation_3d.config import (13from 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)
2224
2325
24def test_model_defaults_match_the_packaged_json():26def 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)
2931
3032
33def 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
40def test_load_config_returns_packaged_defaults():
41 assert load_config().model_dump() == load_default_config_dict()
42
43
31def test_las_mode_literals_match_las_modes():44def test_las_mode_literals_match_las_modes():
32 # The CLI offers `las_modes` as argparse choices and the writer45 # 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_MODES47 assert typing.get_args(LasRgbMode) == las_modes.LAS_RGB_MODES
Importance #43: tests/test_config.py @@ -72,9 +85,9 @@
7285
73def test_hash_rounding_must_be_positive():86def 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.088 .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})
7891
7992
80def test_set_overrides_and_types():93def test_set_overrides_and_types():
Importance #44: tests/test_config.py @@ -106,30 +119,30 @@
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 False120 assert cfg.signs_json_paint_enabled is False
108121
109122
110def test_unknown_key_rejected():123def 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})
113126
114127
115def test_las_rgb_mode_validated():128def 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"})
119132
120133
121def test_las_split_validated():134def 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"})
126139
127140
128def test_las_crs_epsg_validated():141def 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 == 0143 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})
133146
134147
135def test_las_georeference_can_be_disabled():148def test_las_georeference_can_be_disabled():
Importance #45: tests/test_config.py @@ -153,18 +166,18 @@
153 assert config_from_dict({"write_ply": "false"}).write_ply is False166 assert config_from_dict({"write_ply": "false"}).write_ply is False
154167
155168
156def test_bool_typo_is_rejected_not_read_as_false():169def 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})
161174
162175
163def test_non_integral_value_for_an_int_field_is_rejected():176def 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"})
168181
169182
170def test_vegetation_enums_validated():183def test_vegetation_enums_validated():
Importance #46: tests/test_config.py @@ -178,13 +191,13 @@
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 a193 # 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"})
188201
189202
190def test_vegetation_limiter_ranges_validated():203def test_vegetation_limiter_ranges_validated():
Importance #47: tests/test_config.py @@ -195,19 +208,19 @@
195 {"vegetation_asphalt_dilate_cells": 0}208 {"vegetation_asphalt_dilate_cells": 0}
196 ).vegetation_asphalt_dilate_cells == 0209 ).vegetation_asphalt_dilate_cells == 0
197 # A zero cell size divides by zero deep in the rasteriser and a210 # 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})
211224
212225
213def test_vegetation_set_overrides_coerce():226def test_vegetation_set_overrides_coerce():
Importance #48: tests/test_config.py @@ -271,9 +284,9 @@
271 {"vegetation_corridor_rail_m": 0.0}284 {"vegetation_corridor_rail_m": 0.0}
272 ).vegetation_corridor_rail_m == 0.0285 ).vegetation_corridor_rail_m == 0.0
273286
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})
277290
278291
279def test_vegetation_corridor_max_height_validated():292def test_vegetation_corridor_max_height_validated():
Importance #49: tests/test_config.py @@ -284,9 +297,9 @@
284 ).vegetation_corridor_max_height_m == 0.0297 ).vegetation_corridor_max_height_m == 0.0
285298
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})
291304
292305
Importance #50: tests/test_config.py @@ -295,9 +308,9 @@
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.0310 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})
301314
302315
303def test_vegetation_min_ground_points_validated():316def test_vegetation_min_ground_points_validated():
Importance #51: tests/test_config.py @@ -305,9 +318,9 @@
305 {"vegetation_min_ground_points": 1}318 {"vegetation_min_ground_points": 1}
306 ).vegetation_min_ground_points == 1319 ).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})
311324
312325
313def test_vegetation_percentiles_validated():326def test_vegetation_percentiles_validated():
Importance #52: tests/test_config.py @@ -316,9 +329,9 @@
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) == good331 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})
322335
323336
324def test_vegetation_min_height_must_be_finite():337def test_vegetation_min_height_must_be_finite():
Importance #53: tests/test_config.py @@ -327,9 +340,9 @@
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.0342 ).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})
333346
334347
335def test_vegetation_green_and_tree_knobs_must_be_finite():348def test_vegetation_green_and_tree_knobs_must_be_finite():
Importance #54: tests/test_config.py @@ -352,9 +365,9 @@
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})
358371
359372
360def test_vegetation_asphalt_dilate_cells_has_an_upper_bound():373def test_vegetation_asphalt_dilate_cells_has_an_upper_bound():
Importance #55: tests/test_config.py @@ -364,9 +377,9 @@
364 {"vegetation_asphalt_dilate_cells": 64}377 {"vegetation_asphalt_dilate_cells": 64}
365 ).vegetation_asphalt_dilate_cells == 64378 ).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})
371384
372385
Importance #56: tests/test_config.py @@ -377,12 +390,12 @@
377 assert ok.vegetation_low_max_m == ok.vegetation_medium_max_m == 1.0390 assert ok.vegetation_low_max_m == ok.vegetation_medium_max_m == 1.0
378391
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 )
388401
Importance #57: tests/test_config.py @@ -391,9 +404,9 @@
391 # A cross-field rule is a whole-model validator, so it carries no field404 # 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 a405 # 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 error406 # 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) == (
Importance #58: tests/test_config.py @@ -403,9 +416,9 @@
403 )416 )
404417
405418
406def test_naming_rule_reports_only_its_own_message():419def 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) == (
Importance #59: tests/test_config.py @@ -430,6 +443,6 @@
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]))