Back to report index

Step 8 asphaltedge e4b55dd: AI3D-379 Pydantic config models via iolabs-common ConfigModel

Miroslav Simko <developer.ai@iolabs.ch> 2026-09-02T08:33:50+02:00

Commit #50 ยท 7 snippets

 pyproject.toml                                     |   5 +-
 .../config.py                                      | 299 ++++++++++++++-------
 .../detector.py                                    |   9 +-
 tests/test_config.py                               | 165 ++++++++++++
 4 files changed, 380 insertions(+), 98 deletions(-)
Importance #1: src/iolabs_point_cloud_detection_asphaltedge/config.py @@ -1,124 +1,237 @@
1"""Packaged-default configuration loader for asphalt edge detection.1"""Packaged-default configuration loader for asphalt edge detection.
22
3Mirrors the sibling iolabs point-cloud packages: defaults live in a3Defaults live in the force-included ``asphalt_edge.default.json``. A pydantic
4force-included ``asphalt_edge.default.json`` and are loaded/merged here into the4model tree derived from :class:`iolabs.common.config_loader.ConfigModel` is
5typed :class:`Config` carrier, rather than being hardcoded only as dataclass5the schema: nested JSON sections are nested models, field names are JSON keys,
6field defaults.6and unknown keys are rejected. Validated values are copied onto the runtime
7dataclasses (:class:`~.detector.Config`, :class:`~.features.FeatureWeights`,
8:class:`~.image_edge.ImageEdgeConfig`, :class:`~.gutter.GutterConfig`) because
9the algorithm, tests and scripts mutate those carriers.
10
11To add a config key: add the field to the runtime dataclass *and* to the
12matching ``ConfigModel`` subclass here, with the same name, type and default
13(``_to_runtime`` passes every model field on to the dataclass, so a field on
14only one side raises). Add it to the packaged JSON only when the shipped
15default differs from the field default; ``test_config`` guards the parity.
7"""16"""
817
9import json18from __future__ import annotations
10from dataclasses import fields19
20import logging
11from pathlib import Path21from pathlib import Path
12from typing import Any22from typing import Any
1323
14from iolabs.common.config_loader import (24from iolabs.common import config_loader
15 ConfigError,
16 deep_merge_dicts,
17 default_config_path,
18 validate_allowed_keys,
19)
2025
21from .detector import Config26from .detector import Config
22from .features import FeatureWeights27from .features import FeatureWeights
23from .gutter import GutterConfig28from .gutter import GutterConfig
24from .image_edge import ImageEdgeConfig29from .image_edge import ImageEdgeConfig
2530
26ALLOWED_WEIGHT_KEYS = frozenset(field.name for field in fields(FeatureWeights))31logger = logging.getLogger(__name__)
27ALLOWED_IMAGE_KEYS = frozenset(field.name for field in fields(ImageEdgeConfig))
28ALLOWED_GUTTER_KEYS = frozenset(field.name for field in fields(GutterConfig))
29ALLOWED_CONFIG_KEYS = (
30 frozenset(field.name for field in fields(Config)) | {"weights", "image", "gutter"}
31)
32_IMAGE_TUPLE_KEYS = (
33 "left_band",
34 "right_band",
35 "inner_left_band",
36 "inner_right_band",
37 "zrange_resume_zone_m",
38)
3932
40_PACKAGE = "iolabs_point_cloud_detection_asphaltedge"33_PACKAGE = "iolabs_point_cloud_detection_asphaltedge"
41_DEFAULT_CONFIG_FILENAME = "asphalt_edge.default.json"34_DEFAULT_CONFIG_FILENAME = "asphalt_edge.default.json"
4235_CONTEXT = "asphalt edge config"
4336
44class AsphaltEdgeConfigError(ConfigError):37
45 """Raised when the asphalt edge config contains unsupported keys."""38class AsphaltEdgeConfigError(config_loader.ConfigError):
39 """Raised when the asphalt edge config contains unsupported keys or values."""
40
41
42class FeatureWeightsConfig(config_loader.ConfigModel):
43 """``weights`` block of ``asphalt_edge.default.json``."""
44
45 intensity_step: float = 2.5
46 density_drop: float = 2.0
47 z_rise: float = 1.0
48 roughness_rise: float = 0.8
49 surface_notch: float = 1.5
50 dog_step: float = 1.0
51
52
53class ImageEdgeConfigModel(config_loader.ConfigModel):
54 """``image`` block plus the dataclass knobs accepted as overrides."""
55
56 station_step: float = 0.25
57 offset_step: float = 0.05
58 offset_min: float = -18.0
59 offset_max: float = 18.0
60 left_band: tuple[float, float] = (1.0, 17.0)
61 right_band: tuple[float, float] = (-17.0, -1.0)
62 inner_left_band: tuple[float, float] = (0.3, 6.0)
63 inner_right_band: tuple[float, float] = (-6.0, -0.3)
64 inner_min_valid_frac: float = 0.2
65 inner_bound_margin_m: float = 1.25
66 step_win_m: float = 1.2
67 tex_win_m: float = 0.5
68 smooth_offset_m: float = 0.1
69 intensity_weight: float = 1.0
70 texture_weight: float = 0.6
71 density_weight: float = 0.0
72 roughness_weight: float = 0.0
73 z_step_weight: float = 0.0
74 zrange_weight: float = 1.5
75 zrange_win_m: float = 0.4
76 zrange_cap_m: float = 0.15
77 zrange_snap: bool = True
78 zrange_snap_window_m: float = 0.4
79 zrange_snap_max_shift_m: float = 0.25
80 zrange_snap_thresh_min_m: float = 0.012
81 zrange_snap_mad_k: float = 4.0
82 zrange_snap_rel_h: float = 0.10
83 zrange_snap_regularize: bool = True
84 zrange_snap_reg_max_dev_m: float = 0.30
85 zrange_intensity_snap: bool = True
86 zrange_isnap_grad_k: float = 3.0
87 zrange_isnap_min_step: float = 8.0
88 zrange_isnap_paint_guard: bool = True
89 zrange_onset_bonus: float = 1.2
90 zrange_onset_smooth: bool = True
91 zrange_guardrail_z_m: float = 0.30
92 zrange_guardrail_lookback_m: float = 2.0
93 zrange_guardrail_veto: bool = False
94 zrange_guardrail_veto_conf: float = 0.7
95 zrange_guardrail_veto_margin_m: float = 0.5
96 zrange_guardrail_veto_types: tuple[str, ...] = ("w_beam",)
97 zrange_guardrail_veto_walls: bool = False
98 zrange_resume_weight: float = 3.0
99 zrange_resume_zone_m: tuple[float, float] = (0.5, 2.5)
100 zrange_flat_thresh_m: float = 0.015
101 zrange_resume_tex_k: float = 1.5
102 zrange_shoulder_rule: bool = True
103 zrange_shoulder_lookback_m: float = 2.0
104 zrange_shoulder_min_gap_m: float = 0.3
105 zrange_shoulder_corridor_anchor: bool = True
106 zrange_shoulder_sustain_m: float = 1.5
107 zrange_shoulder_max_search_m: float = 8.0
108 zrange_shoulder_grad_k: float = 2.0
109 zrange_shoulder_min_step: float = 6.0
110 zrange_shoulder_min_width_m: float = 3.0
111 zrange_shoulder_support_m: float = 1.5
112 zrange_shoulder_support_tol_m: float = 0.6
113 zrange_shoulder_support_frac: float = 0.5
114 zrange_shoulder_consolidate: bool = True
115 zrange_shoulder_revert_isolated: bool = True
116 zrange_shoulder_run_smoothness_guard: bool = False
117 zrange_shoulder_run_max_step_m: float = 0.046
118 zrange_paint_veto: bool = True
119 zrange_paint_quantile: float = 0.92
120 zrange_paint_max_width_m: float = 0.5
121 zrange_paint_corridor_margin_m: float = 1.5
122 robust: bool = True
123 edge_smooth_window_m: float = 2.0
124 slope_cheap_m: float = 0.15
125 trans_lin: float = 9.0
126 trans_quad: float = 220.0
127 max_jump_m: float = 0.9
128 bridge_stiffness: float = 3.0
129 bridge_anchor_min_conf: float = 0.75
130 invalid_penalty: float = 2.5
131 min_valid_frac: float = 0.35
132 conf_gain: float = 0.7
133 outer_bound_margin_m: float = 1.5
134 outer_median_bound: bool = False
135 outer_median_bound_margin_m: float = 0.5
136 outer_median_bound_hard: bool = False
137 vehicle_mask: bool = True
138 vehicle_z_thresh_m: float = 0.02
139 vehicle_corridor_margin_m: float = 0.5
140 vehicle_mask_z_only: bool = False
141 vehicle_mask_neutral: bool = True
142 vehicle_mask_seed_guard: bool = False
143 vehicle_mask_baseline_fill: bool = False
144 edge_support_gate: bool = False
145 use_crf: bool = False
146 crf_second_order: bool = True
147 crf_lambda_parallel: float = 4.0
148 crf_lambda_curvature: float = 8.0
149 crf_lambda_prior: float = 1.5
150 crf_huber_delta_m: float = 0.10
151 crf_delta_cap_m: float = 0.6
152 crf_conf_ref: float = 1.0
153 crf_not_observable_support: float = 0.15
154 crf_support_intensity_ref_dn: float = 40.0
155 crf_support_zrange_ref_m: float = 0.05
156
157
158class GutterConfigModel(config_loader.ConfigModel):
159 """``gutter`` block of ``asphalt_edge.default.json``."""
160
161 enabled: bool = True
162 station_step: float = 1.0
163 half_window_m: float = 0.75
164 step_thr: float = 4000.0
165 rough_mult: float = 2.5
166 max_gutter_m: float = 1.5
167 min_conf: float = 0.35
168
169
170class AsphaltEdgeConfig(config_loader.ConfigModel):
171 """Root schema mirroring ``asphalt_edge.default.json``."""
172
173 station_step: float = 0.5
174 half_thickness: float = 0.5
175 bin_width: float = 0.1
176 off_range: tuple[float, float] = (-15.0, 15.0)
177 search_in: float = 4.5
178 search_out: float = 0.3
179 band_m: float = 1.0
180 weights: FeatureWeightsConfig = FeatureWeightsConfig()
181 image: ImageEdgeConfigModel = ImageEdgeConfigModel()
182 gutter: GutterConfigModel = GutterConfigModel()
183 min_confidence: float = 0.25
184 median_window_m: float = 15.0
185 mad_k: float = 3.5
186 max_interp_gap_m: float = 5.0
187 max_interp_offset_jump_m: float = 0.5
188 max_offset_rate: float = 0.6
189 min_run_m: float = 3.0
190 end_stub_m: float = 5.0
191 savgol_window_m: float = 11.0
192 savgol_order: int = 2
193 max_points: int = 7_000_000
194 hash_round_units_per_m: float = 1000.0
195 min_carriageway_m: float = 2.0
196
197
198def _to_runtime(model: AsphaltEdgeConfig) -> Config:
199 """Copy a validated model onto the mutable runtime dataclasses."""
200 data = model.model_dump()
201 return Config(
202 weights=FeatureWeights(**data.pop("weights")),
203 image=ImageEdgeConfig(**data.pop("image")),
204 gutter=GutterConfig(**data.pop("gutter")),
205 **data,
206 )
46207
47208
48def config_from_dict(raw_config: dict[str, Any]) -> Config:209def config_from_dict(raw_config: dict[str, Any]) -> Config:
49 """Build a typed :class:`Config` from a (possibly partial) mapping."""210 """Build a typed :class:`Config` from a (possibly partial) mapping."""
50 config = dict(raw_config)211 model = config_loader.validate_config(
51 validate_allowed_keys(212 AsphaltEdgeConfig,
52 config, ALLOWED_CONFIG_KEYS, context="asphalt edge config", error_cls=AsphaltEdgeConfigError213 raw_config,
214 context=_CONTEXT,
215 error_cls=AsphaltEdgeConfigError,
53 )216 )
54217 return _to_runtime(model)
55 weights_raw = config.pop("weights", None)
56 if weights_raw is not None:
57 if not isinstance(weights_raw, dict):
58 raise AsphaltEdgeConfigError("asphalt edge config field 'weights' must be a mapping")
59 validate_allowed_keys(
60 weights_raw,
61 ALLOWED_WEIGHT_KEYS,
62 context="asphalt edge weights",
63 error_cls=AsphaltEdgeConfigError,
64 )
65 weights = FeatureWeights(**weights_raw)
66 else:
67 weights = FeatureWeights()
68
69 image_raw = config.pop("image", None)
70 if image_raw is not None:
71 if not isinstance(image_raw, dict):
72 raise AsphaltEdgeConfigError("asphalt edge config field 'image' must be a mapping")
73 image_raw = dict(image_raw)
74 validate_allowed_keys(
75 image_raw,
76 ALLOWED_IMAGE_KEYS,
77 context="asphalt edge image",
78 error_cls=AsphaltEdgeConfigError,
79 )
80 for key in _IMAGE_TUPLE_KEYS:
81 if key in image_raw and image_raw[key] is not None:
82 lo, hi = image_raw[key]
83 image_raw[key] = (float(lo), float(hi))
84 image = ImageEdgeConfig(**image_raw)
85 else:
86 image = ImageEdgeConfig()
87
88 gutter_raw = config.pop("gutter", None)
89 if gutter_raw is not None:
90 if not isinstance(gutter_raw, dict):
91 raise AsphaltEdgeConfigError("asphalt edge config field 'gutter' must be a mapping")
92 validate_allowed_keys(
93 gutter_raw,
94 ALLOWED_GUTTER_KEYS,
95 context="asphalt edge gutter",
96 error_cls=AsphaltEdgeConfigError,
97 )
98 gutter = GutterConfig(**gutter_raw)
99 else:
100 gutter = GutterConfig()
101
102 if "off_range" in config and config["off_range"] is not None:
103 lo, hi = config["off_range"]
104 config["off_range"] = (float(lo), float(hi))
105
106 return Config(weights=weights, image=image, gutter=gutter, **config)
107218
108219
109def load_asphalt_edge_config(220def load_asphalt_edge_config(
110 config_path: str | Path | None = None,221 config_path: str | Path | None = None,
111 *,222 *,
112 overrides: dict[str, Any] | None = None,223 overrides: dict[str, Any] | None = None,
113) -> Config:224) -> Config:
114 """Load the packaged default config, apply optional overrides, return a Config."""225 """Load the packaged default config, apply optional overrides, return a Config."""
115 resolved = (226 model = config_loader.load_config(
116 Path(config_path)227 AsphaltEdgeConfig,
117 if config_path is not None228 package=_PACKAGE,
118 else default_config_path(_PACKAGE, _DEFAULT_CONFIG_FILENAME)229 filename=_DEFAULT_CONFIG_FILENAME,
230 overrides=overrides,
231 config_path=config_path,
232 context=_CONTEXT,
233 error_cls=AsphaltEdgeConfigError,
119 )234 )
120 with resolved.open("r", encoding="utf-8") as handle:
121 raw_config: dict[str, Any] = json.load(handle)
122 if overrides:235 if overrides:
123 raw_config = deep_merge_dicts(raw_config, dict(overrides))236 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
124 return config_from_dict(raw_config)237 return _to_runtime(model)
Importance #2: src/iolabs_point_cloud_detection_asphaltedge/detector.py @@ -34,11 +34,14 @@
34@dataclass34@dataclass
35class Config:35class Config:
36 """Tunable parameters of one asphalt-edge detection run.36 """Tunable parameters of one asphalt-edge detection run.
3737
38 Defaults mirror ``asphalt_edge.default.json``; :func:`.config_from_dict`38 Defaults of a loaded config come from ``asphalt_edge.default.json`` via
39 overlays a user config onto them. The nested rectified-image, gutter and39 :func:`.config_from_dict`. Nested rectified-image, gutter and weight
40 weight blocks live in their own dataclasses reachable from here.40 blocks live in their own dataclasses reachable from here. To add a key,
41 add the field here *and* to the matching ``ConfigModel`` in ``config.py``
42 with the same name, type and default; the packaged JSON only needs it when
43 the shipped default differs.
41 """44 """
4245
43 station_step: float = 0.546 station_step: float = 0.5
44 half_thickness: float = 0.547 half_thickness: float = 0.5
Importance #3: tests/test_config.py @@ -0,0 +1,165 @@
1"""Packaged-JSON asphalt-edge config loading and validation."""
2
3from __future__ import annotations
4
5import dataclasses
6import typing
7from pathlib import Path
8
9import pytest
10from iolabs.common import config_loader
11
12from iolabs_point_cloud_detection_asphaltedge.config import (
13 AsphaltEdgeConfig,
14 AsphaltEdgeConfigError,
15 FeatureWeightsConfig,
16 GutterConfigModel,
17 ImageEdgeConfigModel,
18 config_from_dict,
19 load_asphalt_edge_config,
20)
21from iolabs_point_cloud_detection_asphaltedge.detector import Config
22from iolabs_point_cloud_detection_asphaltedge.features import FeatureWeights
23from iolabs_point_cloud_detection_asphaltedge.gutter import GutterConfig
24from iolabs_point_cloud_detection_asphaltedge.image_edge import ImageEdgeConfig
25
26
27def test_error_class_is_config_error_and_value_error() -> None:
28 assert issubclass(AsphaltEdgeConfigError, config_loader.ConfigError)
29 assert issubclass(AsphaltEdgeConfigError, ValueError)
30
31
32def test_load_packaged_defaults_with_zero_overrides() -> None:
33 cfg = load_asphalt_edge_config()
34 assert isinstance(cfg, Config)
35 assert cfg.station_step == 0.5
36 assert cfg.off_range == (-20.0, 20.0)
37 assert cfg.mad_k == 3.0
38 assert cfg.weights.intensity_step == 2.5
39 assert cfg.image.left_band == (1.0, 17.0)
40 assert cfg.image.roughness_weight == 0.4
41 assert cfg.image.robust is True
42 assert cfg.gutter.enabled is True
43 assert cfg.gutter.step_thr == 4000.0
44 # Dataclass-only knobs (not in the packaged JSON) keep their class defaults.
45 assert cfg.image.zrange_weight == 1.5
46 assert cfg.polyline.min_confidence == cfg.min_confidence
47
48
49def test_overrides_deep_merge_nested_sections() -> None:
50 cfg = load_asphalt_edge_config(
51 overrides={
52 "min_confidence": 0.4,
53 "weights": {"z_rise": 9.0},
54 "image": {"robust": False, "zrange_weight": 2.25},
55 "gutter": {"enabled": False},
56 }
57 )
58 assert cfg.min_confidence == 0.4
59 assert cfg.weights.z_rise == 9.0
60 assert cfg.weights.intensity_step == 2.5
61 assert cfg.image.robust is False
62 assert cfg.image.zrange_weight == 2.25
63 assert cfg.image.texture_weight == 0.6
64 assert cfg.gutter.enabled is False
65 assert cfg.gutter.min_conf == 0.35
66
67
68def test_unknown_top_level_key_rejected() -> None:
69 with pytest.raises(AsphaltEdgeConfigError, match="not_a_key") as excinfo:
70 load_asphalt_edge_config(overrides={"not_a_key": 1})
71 message = str(excinfo.value)
72 assert "Unknown" in message
73 assert "station_step" in message
74
75
76def test_unknown_nested_key_rejected() -> None:
77 with pytest.raises(AsphaltEdgeConfigError, match="typo_band") as excinfo:
78 load_asphalt_edge_config(overrides={"image": {"typo_band": [0.0, 1.0]}})
79 message = str(excinfo.value)
80 assert "Unknown" in message
81 assert "left_band" in message
82
83
84def test_config_from_dict_unknown_key_rejected() -> None:
85 with pytest.raises(AsphaltEdgeConfigError, match="bogus"):
86 config_from_dict({"station_step": 0.5, "bogus": 1})
87
88
89def test_bool_string_overrides_coerce() -> None:
90 cfg = load_asphalt_edge_config(
91 overrides={"image": {"robust": "false"}, "gutter": {"enabled": "off"}}
92 )
93 assert cfg.image.robust is False
94 assert cfg.gutter.enabled is False
95
96
97def test_invalid_bool_token_rejected() -> None:
98 with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"):
99 load_asphalt_edge_config(overrides={"gutter": {"enabled": "flase"}})
100
101
102def test_invalid_float_token_rejected() -> None:
103 with pytest.raises(AsphaltEdgeConfigError, match="Invalid float"):
104 load_asphalt_edge_config(overrides={"station_step": "abc"})
105
106
107def test_off_range_list_becomes_float_tuple() -> None:
108 cfg = config_from_dict({"off_range": ["-3", "4"]})
109 assert cfg.off_range == (-3.0, 4.0)
110
111
112def test_image_band_list_becomes_float_tuple() -> None:
113 cfg = config_from_dict({"image": {"left_band": [2, 8]}})
114 assert cfg.image.left_band == (2.0, 8.0)
115
116
117def test_config_from_dict_builds_runtime_dataclasses() -> None:
118 cfg = config_from_dict({"min_confidence": 0.1})
119 assert isinstance(cfg, Config)
120 assert isinstance(cfg.weights, FeatureWeights)
121 assert isinstance(cfg.image, ImageEdgeConfig)
122 assert isinstance(cfg.gutter, GutterConfig)
123 assert cfg.min_confidence == 0.1
124 # Partial mappings keep dataclass defaults for omitted keys.
125 assert cfg.off_range == (-15.0, 15.0)
126 assert cfg.image.roughness_weight == 0.0
127
128
129def test_load_from_config_path(tmp_path: Path) -> None:
130 path = tmp_path / "custom.json"
131 path.write_text('{"station_step": 1.25, "gutter": {"enabled": false}}\n', encoding="utf-8")
132 cfg = load_asphalt_edge_config(config_path=path)
133 assert cfg.station_step == 1.25
134 assert cfg.gutter.enabled is False
135 assert cfg.band_m == 1.0
136
137
138def test_except_value_error_still_catches_config_errors() -> None:
139 with pytest.raises(ValueError):
140 load_asphalt_edge_config(overrides={"nope": True})
141
142
143@pytest.mark.parametrize(
144 ("dataclass_cls", "model_cls"),
145 [
146 (Config, AsphaltEdgeConfig),
147 (FeatureWeights, FeatureWeightsConfig),
148 (ImageEdgeConfig, ImageEdgeConfigModel),
149 (GutterConfig, GutterConfigModel),
150 ],
151)
152def test_model_mirrors_runtime_dataclass(dataclass_cls: type, model_cls: type) -> None:
153 """Every model field must exist on the dataclass with the same type and default."""
154 hints = typing.get_type_hints(dataclass_cls)
155 nested = {"weights", "image", "gutter"}
156 assert {field.name for field in dataclasses.fields(dataclass_cls)} == set(
157 model_cls.model_fields
158 )
159 defaults = dataclass_cls()
160 model = model_cls()
161 for name, field in model_cls.model_fields.items():
162 if name in nested:
163 continue
164 assert hints[name] == field.annotation, name
165 assert getattr(defaults, name) == getattr(model, name), name
0
Importance #4: pyproject.toml @@ -1,15 +1,16 @@
1[project]1[project]
2name = "iolabs-point-cloud-detection-asphaltedge"2name = "iolabs-point-cloud-detection-asphaltedge"
3version = "0.2.0"3version = "0.2.1"
4description = "Asphalt edge detection from highway LIDAR road-surface point clouds"4description = "Asphalt edge detection from highway LIDAR road-surface point clouds"
5requires-python = ">=3.11,<3.13"5requires-python = ">=3.11,<3.13"
6dependencies = [6dependencies = [
7 "numpy>=1.20.0",7 "numpy>=1.20.0",
8 "scipy>=1.7.0",8 "scipy>=1.7.0",
9 "matplotlib>=3.4.0",9 "matplotlib>=3.4.0",
10 "pillow>=10.0",10 "pillow>=10.0",
11 "iolabs-common>=0.6.0",11 "pydantic>=2.7",
12 "iolabs-common>=0.8.0",
12 "iolabs-geometry-geometry>=0.11.0",13 "iolabs-geometry-geometry>=0.11.0",
13 "iolabs-geometry-raster>=0.2.0",14 "iolabs-geometry-raster>=0.2.0",
14]15]
1516
Importance #5: src/iolabs_point_cloud_detection_asphaltedge/config.py @@ -1,124 +1,237 @@
1"""Packaged-default configuration loader for asphalt edge detection.1"""Packaged-default configuration loader for asphalt edge detection.
22
3Mirrors the sibling iolabs point-cloud packages: defaults live in a3Defaults live in the force-included ``asphalt_edge.default.json``. A pydantic
4force-included ``asphalt_edge.default.json`` and are loaded/merged here into the4model tree derived from :class:`iolabs.common.config_loader.ConfigModel` is
5typed :class:`Config` carrier, rather than being hardcoded only as dataclass5the schema: nested JSON sections are nested models, field names are JSON keys,
6field defaults.6and unknown keys are rejected. Validated values are copied onto the runtime
7dataclasses (:class:`~.detector.Config`, :class:`~.features.FeatureWeights`,
8:class:`~.image_edge.ImageEdgeConfig`, :class:`~.gutter.GutterConfig`) because
9the algorithm, tests and scripts mutate those carriers.
10
11To add a config key: add the field to the runtime dataclass *and* to the
12matching ``ConfigModel`` subclass here, with the same name, type and default
13(``_to_runtime`` passes every model field on to the dataclass, so a field on
14only one side raises). Add it to the packaged JSON only when the shipped
15default differs from the field default; ``test_config`` guards the parity.
7"""16"""
817
9import json18from __future__ import annotations
10from dataclasses import fields19
20import logging
11from pathlib import Path21from pathlib import Path
12from typing import Any22from typing import Any
1323
14from iolabs.common.config_loader import (24from iolabs.common import config_loader
15 ConfigError,
16 deep_merge_dicts,
17 default_config_path,
18 validate_allowed_keys,
19)
2025
21from .detector import Config26from .detector import Config
22from .features import FeatureWeights27from .features import FeatureWeights
23from .gutter import GutterConfig28from .gutter import GutterConfig
24from .image_edge import ImageEdgeConfig29from .image_edge import ImageEdgeConfig
2530
26ALLOWED_WEIGHT_KEYS = frozenset(field.name for field in fields(FeatureWeights))31logger = logging.getLogger(__name__)
27ALLOWED_IMAGE_KEYS = frozenset(field.name for field in fields(ImageEdgeConfig))
28ALLOWED_GUTTER_KEYS = frozenset(field.name for field in fields(GutterConfig))
29ALLOWED_CONFIG_KEYS = (
30 frozenset(field.name for field in fields(Config)) | {"weights", "image", "gutter"}
31)
32_IMAGE_TUPLE_KEYS = (
33 "left_band",
34 "right_band",
35 "inner_left_band",
36 "inner_right_band",
37 "zrange_resume_zone_m",
38)
3932
40_PACKAGE = "iolabs_point_cloud_detection_asphaltedge"33_PACKAGE = "iolabs_point_cloud_detection_asphaltedge"
41_DEFAULT_CONFIG_FILENAME = "asphalt_edge.default.json"34_DEFAULT_CONFIG_FILENAME = "asphalt_edge.default.json"
4235_CONTEXT = "asphalt edge config"
4336
44class AsphaltEdgeConfigError(ConfigError):37
45 """Raised when the asphalt edge config contains unsupported keys."""38class AsphaltEdgeConfigError(config_loader.ConfigError):
39 """Raised when the asphalt edge config contains unsupported keys or values."""
40
41
42class FeatureWeightsConfig(config_loader.ConfigModel):
43 """``weights`` block of ``asphalt_edge.default.json``."""
44
45 intensity_step: float = 2.5
46 density_drop: float = 2.0
47 z_rise: float = 1.0
48 roughness_rise: float = 0.8
49 surface_notch: float = 1.5
50 dog_step: float = 1.0
51
52
53class ImageEdgeConfigModel(config_loader.ConfigModel):
54 """``image`` block plus the dataclass knobs accepted as overrides."""
55
56 station_step: float = 0.25
57 offset_step: float = 0.05
58 offset_min: float = -18.0
59 offset_max: float = 18.0
60 left_band: tuple[float, float] = (1.0, 17.0)
61 right_band: tuple[float, float] = (-17.0, -1.0)
62 inner_left_band: tuple[float, float] = (0.3, 6.0)
63 inner_right_band: tuple[float, float] = (-6.0, -0.3)
64 inner_min_valid_frac: float = 0.2
65 inner_bound_margin_m: float = 1.25
66 step_win_m: float = 1.2
67 tex_win_m: float = 0.5
68 smooth_offset_m: float = 0.1
69 intensity_weight: float = 1.0
70 texture_weight: float = 0.6
71 density_weight: float = 0.0
72 roughness_weight: float = 0.0
73 z_step_weight: float = 0.0
74 zrange_weight: float = 1.5
75 zrange_win_m: float = 0.4
76 zrange_cap_m: float = 0.15
77 zrange_snap: bool = True
78 zrange_snap_window_m: float = 0.4
79 zrange_snap_max_shift_m: float = 0.25
80 zrange_snap_thresh_min_m: float = 0.012
81 zrange_snap_mad_k: float = 4.0
82 zrange_snap_rel_h: float = 0.10
83 zrange_snap_regularize: bool = True
84 zrange_snap_reg_max_dev_m: float = 0.30
85 zrange_intensity_snap: bool = True
86 zrange_isnap_grad_k: float = 3.0
87 zrange_isnap_min_step: float = 8.0
88 zrange_isnap_paint_guard: bool = True
89 zrange_onset_bonus: float = 1.2
90 zrange_onset_smooth: bool = True
91 zrange_guardrail_z_m: float = 0.30
92 zrange_guardrail_lookback_m: float = 2.0
93 zrange_guardrail_veto: bool = False
94 zrange_guardrail_veto_conf: float = 0.7
95 zrange_guardrail_veto_margin_m: float = 0.5
96 zrange_guardrail_veto_types: tuple[str, ...] = ("w_beam",)
97 zrange_guardrail_veto_walls: bool = False
98 zrange_resume_weight: float = 3.0
99 zrange_resume_zone_m: tuple[float, float] = (0.5, 2.5)
100 zrange_flat_thresh_m: float = 0.015
101 zrange_resume_tex_k: float = 1.5
102 zrange_shoulder_rule: bool = True
103 zrange_shoulder_lookback_m: float = 2.0
104 zrange_shoulder_min_gap_m: float = 0.3
105 zrange_shoulder_corridor_anchor: bool = True
106 zrange_shoulder_sustain_m: float = 1.5
107 zrange_shoulder_max_search_m: float = 8.0
108 zrange_shoulder_grad_k: float = 2.0
109 zrange_shoulder_min_step: float = 6.0
110 zrange_shoulder_min_width_m: float = 3.0
111 zrange_shoulder_support_m: float = 1.5
112 zrange_shoulder_support_tol_m: float = 0.6
113 zrange_shoulder_support_frac: float = 0.5
114 zrange_shoulder_consolidate: bool = True
115 zrange_shoulder_revert_isolated: bool = True
116 zrange_shoulder_run_smoothness_guard: bool = False
117 zrange_shoulder_run_max_step_m: float = 0.046
118 zrange_paint_veto: bool = True
119 zrange_paint_quantile: float = 0.92
120 zrange_paint_max_width_m: float = 0.5
121 zrange_paint_corridor_margin_m: float = 1.5
122 robust: bool = True
123 edge_smooth_window_m: float = 2.0
124 slope_cheap_m: float = 0.15
125 trans_lin: float = 9.0
126 trans_quad: float = 220.0
127 max_jump_m: float = 0.9
128 bridge_stiffness: float = 3.0
129 bridge_anchor_min_conf: float = 0.75
130 invalid_penalty: float = 2.5
131 min_valid_frac: float = 0.35
132 conf_gain: float = 0.7
133 outer_bound_margin_m: float = 1.5
134 outer_median_bound: bool = False
135 outer_median_bound_margin_m: float = 0.5
136 outer_median_bound_hard: bool = False
137 vehicle_mask: bool = True
138 vehicle_z_thresh_m: float = 0.02
139 vehicle_corridor_margin_m: float = 0.5
140 vehicle_mask_z_only: bool = False
141 vehicle_mask_neutral: bool = True
142 vehicle_mask_seed_guard: bool = False
143 vehicle_mask_baseline_fill: bool = False
144 edge_support_gate: bool = False
145 use_crf: bool = False
146 crf_second_order: bool = True
147 crf_lambda_parallel: float = 4.0
148 crf_lambda_curvature: float = 8.0
149 crf_lambda_prior: float = 1.5
150 crf_huber_delta_m: float = 0.10
151 crf_delta_cap_m: float = 0.6
152 crf_conf_ref: float = 1.0
153 crf_not_observable_support: float = 0.15
154 crf_support_intensity_ref_dn: float = 40.0
155 crf_support_zrange_ref_m: float = 0.05
156
157
158class GutterConfigModel(config_loader.ConfigModel):
159 """``gutter`` block of ``asphalt_edge.default.json``."""
160
161 enabled: bool = True
162 station_step: float = 1.0
163 half_window_m: float = 0.75
164 step_thr: float = 4000.0
165 rough_mult: float = 2.5
166 max_gutter_m: float = 1.5
167 min_conf: float = 0.35
168
169
170class AsphaltEdgeConfig(config_loader.ConfigModel):
171 """Root schema mirroring ``asphalt_edge.default.json``."""
172
173 station_step: float = 0.5
174 half_thickness: float = 0.5
175 bin_width: float = 0.1
176 off_range: tuple[float, float] = (-15.0, 15.0)
177 search_in: float = 4.5
178 search_out: float = 0.3
179 band_m: float = 1.0
180 weights: FeatureWeightsConfig = FeatureWeightsConfig()
181 image: ImageEdgeConfigModel = ImageEdgeConfigModel()
182 gutter: GutterConfigModel = GutterConfigModel()
183 min_confidence: float = 0.25
184 median_window_m: float = 15.0
185 mad_k: float = 3.5
186 max_interp_gap_m: float = 5.0
187 max_interp_offset_jump_m: float = 0.5
188 max_offset_rate: float = 0.6
189 min_run_m: float = 3.0
190 end_stub_m: float = 5.0
191 savgol_window_m: float = 11.0
192 savgol_order: int = 2
193 max_points: int = 7_000_000
194 hash_round_units_per_m: float = 1000.0
195 min_carriageway_m: float = 2.0
196
197
198def _to_runtime(model: AsphaltEdgeConfig) -> Config:
199 """Copy a validated model onto the mutable runtime dataclasses."""
200 data = model.model_dump()
201 return Config(
202 weights=FeatureWeights(**data.pop("weights")),
203 image=ImageEdgeConfig(**data.pop("image")),
204 gutter=GutterConfig(**data.pop("gutter")),
205 **data,
206 )
46207
47208
48def config_from_dict(raw_config: dict[str, Any]) -> Config:209def config_from_dict(raw_config: dict[str, Any]) -> Config:
49 """Build a typed :class:`Config` from a (possibly partial) mapping."""210 """Build a typed :class:`Config` from a (possibly partial) mapping."""
50 config = dict(raw_config)211 model = config_loader.validate_config(
51 validate_allowed_keys(212 AsphaltEdgeConfig,
52 config, ALLOWED_CONFIG_KEYS, context="asphalt edge config", error_cls=AsphaltEdgeConfigError213 raw_config,
214 context=_CONTEXT,
215 error_cls=AsphaltEdgeConfigError,
53 )216 )
54217 return _to_runtime(model)
55 weights_raw = config.pop("weights", None)
56 if weights_raw is not None:
57 if not isinstance(weights_raw, dict):
58 raise AsphaltEdgeConfigError("asphalt edge config field 'weights' must be a mapping")
59 validate_allowed_keys(
60 weights_raw,
61 ALLOWED_WEIGHT_KEYS,
62 context="asphalt edge weights",
63 error_cls=AsphaltEdgeConfigError,
64 )
65 weights = FeatureWeights(**weights_raw)
66 else:
67 weights = FeatureWeights()
68
69 image_raw = config.pop("image", None)
70 if image_raw is not None:
71 if not isinstance(image_raw, dict):
72 raise AsphaltEdgeConfigError("asphalt edge config field 'image' must be a mapping")
73 image_raw = dict(image_raw)
74 validate_allowed_keys(
75 image_raw,
76 ALLOWED_IMAGE_KEYS,
77 context="asphalt edge image",
78 error_cls=AsphaltEdgeConfigError,
79 )
80 for key in _IMAGE_TUPLE_KEYS:
81 if key in image_raw and image_raw[key] is not None:
82 lo, hi = image_raw[key]
83 image_raw[key] = (float(lo), float(hi))
84 image = ImageEdgeConfig(**image_raw)
85 else:
86 image = ImageEdgeConfig()
87
88 gutter_raw = config.pop("gutter", None)
89 if gutter_raw is not None:
90 if not isinstance(gutter_raw, dict):
91 raise AsphaltEdgeConfigError("asphalt edge config field 'gutter' must be a mapping")
92 validate_allowed_keys(
93 gutter_raw,
94 ALLOWED_GUTTER_KEYS,
95 context="asphalt edge gutter",
96 error_cls=AsphaltEdgeConfigError,
97 )
98 gutter = GutterConfig(**gutter_raw)
99 else:
100 gutter = GutterConfig()
101
102 if "off_range" in config and config["off_range"] is not None:
103 lo, hi = config["off_range"]
104 config["off_range"] = (float(lo), float(hi))
105
106 return Config(weights=weights, image=image, gutter=gutter, **config)
107218
108219
109def load_asphalt_edge_config(220def load_asphalt_edge_config(
110 config_path: str | Path | None = None,221 config_path: str | Path | None = None,
111 *,222 *,
112 overrides: dict[str, Any] | None = None,223 overrides: dict[str, Any] | None = None,
113) -> Config:224) -> Config:
114 """Load the packaged default config, apply optional overrides, return a Config."""225 """Load the packaged default config, apply optional overrides, return a Config."""
115 resolved = (226 model = config_loader.load_config(
116 Path(config_path)227 AsphaltEdgeConfig,
117 if config_path is not None228 package=_PACKAGE,
118 else default_config_path(_PACKAGE, _DEFAULT_CONFIG_FILENAME)229 filename=_DEFAULT_CONFIG_FILENAME,
230 overrides=overrides,
231 config_path=config_path,
232 context=_CONTEXT,
233 error_cls=AsphaltEdgeConfigError,
119 )234 )
120 with resolved.open("r", encoding="utf-8") as handle:
121 raw_config: dict[str, Any] = json.load(handle)
122 if overrides:235 if overrides:
123 raw_config = deep_merge_dicts(raw_config, dict(overrides))236 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
124 return config_from_dict(raw_config)237 return _to_runtime(model)
Importance #6: src/iolabs_point_cloud_detection_asphaltedge/detector.py @@ -34,11 +34,14 @@
34@dataclass34@dataclass
35class Config:35class Config:
36 """Tunable parameters of one asphalt-edge detection run.36 """Tunable parameters of one asphalt-edge detection run.
3737
38 Defaults mirror ``asphalt_edge.default.json``; :func:`.config_from_dict`38 Defaults of a loaded config come from ``asphalt_edge.default.json`` via
39 overlays a user config onto them. The nested rectified-image, gutter and39 :func:`.config_from_dict`. Nested rectified-image, gutter and weight
40 weight blocks live in their own dataclasses reachable from here.40 blocks live in their own dataclasses reachable from here. To add a key,
41 add the field here *and* to the matching ``ConfigModel`` in ``config.py``
42 with the same name, type and default; the packaged JSON only needs it when
43 the shipped default differs.
41 """44 """
4245
43 station_step: float = 0.546 station_step: float = 0.5
44 half_thickness: float = 0.547 half_thickness: float = 0.5
Importance #7: tests/test_config.py @@ -0,0 +1,165 @@
1"""Packaged-JSON asphalt-edge config loading and validation."""
2
3from __future__ import annotations
4
5import dataclasses
6import typing
7from pathlib import Path
8
9import pytest
10from iolabs.common import config_loader
11
12from iolabs_point_cloud_detection_asphaltedge.config import (
13 AsphaltEdgeConfig,
14 AsphaltEdgeConfigError,
15 FeatureWeightsConfig,
16 GutterConfigModel,
17 ImageEdgeConfigModel,
18 config_from_dict,
19 load_asphalt_edge_config,
20)
21from iolabs_point_cloud_detection_asphaltedge.detector import Config
22from iolabs_point_cloud_detection_asphaltedge.features import FeatureWeights
23from iolabs_point_cloud_detection_asphaltedge.gutter import GutterConfig
24from iolabs_point_cloud_detection_asphaltedge.image_edge import ImageEdgeConfig
25
26
27def test_error_class_is_config_error_and_value_error() -> None:
28 assert issubclass(AsphaltEdgeConfigError, config_loader.ConfigError)
29 assert issubclass(AsphaltEdgeConfigError, ValueError)
30
31
32def test_load_packaged_defaults_with_zero_overrides() -> None:
33 cfg = load_asphalt_edge_config()
34 assert isinstance(cfg, Config)
35 assert cfg.station_step == 0.5
36 assert cfg.off_range == (-20.0, 20.0)
37 assert cfg.mad_k == 3.0
38 assert cfg.weights.intensity_step == 2.5
39 assert cfg.image.left_band == (1.0, 17.0)
40 assert cfg.image.roughness_weight == 0.4
41 assert cfg.image.robust is True
42 assert cfg.gutter.enabled is True
43 assert cfg.gutter.step_thr == 4000.0
44 # Dataclass-only knobs (not in the packaged JSON) keep their class defaults.
45 assert cfg.image.zrange_weight == 1.5
46 assert cfg.polyline.min_confidence == cfg.min_confidence
47
48
49def test_overrides_deep_merge_nested_sections() -> None:
50 cfg = load_asphalt_edge_config(
51 overrides={
52 "min_confidence": 0.4,
53 "weights": {"z_rise": 9.0},
54 "image": {"robust": False, "zrange_weight": 2.25},
55 "gutter": {"enabled": False},
56 }
57 )
58 assert cfg.min_confidence == 0.4
59 assert cfg.weights.z_rise == 9.0
60 assert cfg.weights.intensity_step == 2.5
61 assert cfg.image.robust is False
62 assert cfg.image.zrange_weight == 2.25
63 assert cfg.image.texture_weight == 0.6
64 assert cfg.gutter.enabled is False
65 assert cfg.gutter.min_conf == 0.35
66
67
68def test_unknown_top_level_key_rejected() -> None:
69 with pytest.raises(AsphaltEdgeConfigError, match="not_a_key") as excinfo:
70 load_asphalt_edge_config(overrides={"not_a_key": 1})
71 message = str(excinfo.value)
72 assert "Unknown" in message
73 assert "station_step" in message
74
75
76def test_unknown_nested_key_rejected() -> None:
77 with pytest.raises(AsphaltEdgeConfigError, match="typo_band") as excinfo:
78 load_asphalt_edge_config(overrides={"image": {"typo_band": [0.0, 1.0]}})
79 message = str(excinfo.value)
80 assert "Unknown" in message
81 assert "left_band" in message
82
83
84def test_config_from_dict_unknown_key_rejected() -> None:
85 with pytest.raises(AsphaltEdgeConfigError, match="bogus"):
86 config_from_dict({"station_step": 0.5, "bogus": 1})
87
88
89def test_bool_string_overrides_coerce() -> None:
90 cfg = load_asphalt_edge_config(
91 overrides={"image": {"robust": "false"}, "gutter": {"enabled": "off"}}
92 )
93 assert cfg.image.robust is False
94 assert cfg.gutter.enabled is False
95
96
97def test_invalid_bool_token_rejected() -> None:
98 with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"):
99 load_asphalt_edge_config(overrides={"gutter": {"enabled": "flase"}})
100
101
102def test_invalid_float_token_rejected() -> None:
103 with pytest.raises(AsphaltEdgeConfigError, match="Invalid float"):
104 load_asphalt_edge_config(overrides={"station_step": "abc"})
105
106
107def test_off_range_list_becomes_float_tuple() -> None:
108 cfg = config_from_dict({"off_range": ["-3", "4"]})
109 assert cfg.off_range == (-3.0, 4.0)
110
111
112def test_image_band_list_becomes_float_tuple() -> None:
113 cfg = config_from_dict({"image": {"left_band": [2, 8]}})
114 assert cfg.image.left_band == (2.0, 8.0)
115
116
117def test_config_from_dict_builds_runtime_dataclasses() -> None:
118 cfg = config_from_dict({"min_confidence": 0.1})
119 assert isinstance(cfg, Config)
120 assert isinstance(cfg.weights, FeatureWeights)
121 assert isinstance(cfg.image, ImageEdgeConfig)
122 assert isinstance(cfg.gutter, GutterConfig)
123 assert cfg.min_confidence == 0.1
124 # Partial mappings keep dataclass defaults for omitted keys.
125 assert cfg.off_range == (-15.0, 15.0)
126 assert cfg.image.roughness_weight == 0.0
127
128
129def test_load_from_config_path(tmp_path: Path) -> None:
130 path = tmp_path / "custom.json"
131 path.write_text('{"station_step": 1.25, "gutter": {"enabled": false}}\n', encoding="utf-8")
132 cfg = load_asphalt_edge_config(config_path=path)
133 assert cfg.station_step == 1.25
134 assert cfg.gutter.enabled is False
135 assert cfg.band_m == 1.0
136
137
138def test_except_value_error_still_catches_config_errors() -> None:
139 with pytest.raises(ValueError):
140 load_asphalt_edge_config(overrides={"nope": True})
141
142
143@pytest.mark.parametrize(
144 ("dataclass_cls", "model_cls"),
145 [
146 (Config, AsphaltEdgeConfig),
147 (FeatureWeights, FeatureWeightsConfig),
148 (ImageEdgeConfig, ImageEdgeConfigModel),
149 (GutterConfig, GutterConfigModel),
150 ],
151)
152def test_model_mirrors_runtime_dataclass(dataclass_cls: type, model_cls: type) -> None:
153 """Every model field must exist on the dataclass with the same type and default."""
154 hints = typing.get_type_hints(dataclass_cls)
155 nested = {"weights", "image", "gutter"}
156 assert {field.name for field in dataclasses.fields(dataclass_cls)} == set(
157 model_cls.model_fields
158 )
159 defaults = dataclass_cls()
160 model = model_cls()
161 for name, field in model_cls.model_fields.items():
162 if name in nested:
163 continue
164 assert hints[name] == field.annotation, name
165 assert getattr(defaults, name) == getattr(model, name), name
0