Back to report index

Step 8 asphaltedge d4ffca6: AI3D-379 Align config module with fleet pattern

Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:44:31+02:00

Commit #51 ยท 37 snippets

 README.md                                          |  36 ++
 .../__init__.py                                    |   6 +
 .../asphalt_edge.default.json                      | 102 +++++-
 .../config.py                                      | 383 +++++++++++++--------
 .../detector.py                                    |   9 +-
 .../image_edge.py                                  |   2 +-
 tests/test_config.py                               | 102 ++++--
 7 files changed, 451 insertions(+), 189 deletions(-)
Importance #1: src/iolabs_point_cloud_detection_asphaltedge/config.py @@ -29,171 +32,215 @@
29from .image_edge import ImageEdgeConfig32from .image_edge import ImageEdgeConfig
3033
31logger = logging.getLogger(__name__)34logger = logging.getLogger(__name__)
3235
33_PACKAGE = "iolabs_point_cloud_detection_asphaltedge"36_PACKAGE_NAME = "iolabs_point_cloud_detection_asphaltedge"
34_DEFAULT_CONFIG_FILENAME = "asphalt_edge.default.json"37_DEFAULT_FILENAME = "asphalt_edge.default.json"
35_CONTEXT = "asphalt edge config"38_CONTEXT = "asphalt edge config"
3639
3740
38class AsphaltEdgeConfigError(config_loader.ConfigError):41class AsphaltEdgeConfigError(config_loader.ConfigError):
39 """Raised when the asphalt edge config contains unsupported keys or values."""42 """Raised when asphalt edge config contains unsupported keys or values."""
43
44
45def _check_interval(name: str, interval: tuple[float, float]) -> None:
46 """Raise when a ``(low, high)`` interval is not strictly increasing."""
47 low, high = interval
48 if low >= high:
49 raise ValueError(f"{name} must be (low, high) with low < high, got ({low}, {high})")
4050
4151
42class FeatureWeightsConfig(config_loader.ConfigModel):52class AsphaltEdgeWeightsConfig(config_loader.ConfigModel):
43 """``weights`` block of ``asphalt_edge.default.json``."""53 """``weights`` block: cross-profile material-edge feature weights."""
4454
45 intensity_step: float = 2.555 intensity_step: float = pydantic.Field(default=2.5, ge=0.0)
46 density_drop: float = 2.056 density_drop: float = pydantic.Field(default=2.0, ge=0.0)
47 z_rise: float = 1.057 z_rise: float = pydantic.Field(default=1.0, ge=0.0)
48 roughness_rise: float = 0.858 roughness_rise: float = pydantic.Field(default=0.8, ge=0.0)
49 surface_notch: float = 1.559 surface_notch: float = pydantic.Field(default=1.5, ge=0.0)
50 dog_step: float = 1.060 dog_step: float = pydantic.Field(default=1.0, ge=0.0)
5161
5262
53class ImageEdgeConfigModel(config_loader.ConfigModel):63class AsphaltEdgeImageConfig(config_loader.ConfigModel):
54 """``image`` block plus the dataclass knobs accepted as overrides."""64 """``image`` block: tunables of the rectified-image edge extractor."""
5565
56 station_step: float = 0.2566 # rectification grid
57 offset_step: float = 0.0567 station_step: float = pydantic.Field(default=0.25, gt=0.0)
68 offset_step: float = pydantic.Field(default=0.05, gt=0.0)
58 offset_min: float = -18.069 offset_min: float = -18.0
59 offset_max: float = 18.070 offset_max: float = 18.0
71 # side search bands (signed offset metres; +left / -right)
60 left_band: tuple[float, float] = (1.0, 17.0)72 left_band: tuple[float, float] = (1.0, 17.0)
61 right_band: tuple[float, float] = (-17.0, -1.0)73 right_band: tuple[float, float] = (-17.0, -1.0)
62 inner_left_band: tuple[float, float] = (0.3, 6.0)74 inner_left_band: tuple[float, float] = (0.3, 6.0)
63 inner_right_band: tuple[float, float] = (-6.0, -0.3)75 inner_right_band: tuple[float, float] = (-6.0, -0.3)
64 inner_min_valid_frac: float = 0.276 # negative means "reuse min_valid_frac", so negatives stay legal here
65 inner_bound_margin_m: float = 1.2577 inner_min_valid_frac: float = pydantic.Field(default=0.2, le=1.0)
66 step_win_m: float = 1.278 inner_bound_margin_m: float = pydantic.Field(default=1.25, ge=0.0)
67 tex_win_m: float = 0.579 # step-filter windows (metres)
68 smooth_offset_m: float = 0.180 step_win_m: float = pydantic.Field(default=1.2, gt=0.0)
69 intensity_weight: float = 1.081 tex_win_m: float = pydantic.Field(default=0.5, gt=0.0)
70 texture_weight: float = 0.682 smooth_offset_m: float = pydantic.Field(default=0.1, ge=0.0)
71 density_weight: float = 0.083 # channel fusion
72 roughness_weight: float = 0.084 intensity_weight: float = pydantic.Field(default=1.0, ge=0.0)
73 z_step_weight: float = 0.085 texture_weight: float = pydantic.Field(default=0.6, ge=0.0)
74 zrange_weight: float = 1.586 density_weight: float = pydantic.Field(default=0.0, ge=0.0)
75 zrange_win_m: float = 0.487 roughness_weight: float = pydantic.Field(default=0.4, ge=0.0)
76 zrange_cap_m: float = 0.1588 z_step_weight: float = pydantic.Field(default=0.0, ge=0.0)
89 # z-range (max-min z per pixel) channel
90 zrange_weight: float = pydantic.Field(default=1.5, ge=0.0)
91 zrange_win_m: float = pydantic.Field(default=0.4, gt=0.0)
92 zrange_cap_m: float = pydantic.Field(default=0.15, gt=0.0)
77 zrange_snap: bool = True93 zrange_snap: bool = True
78 zrange_snap_window_m: float = 0.494 zrange_snap_window_m: float = pydantic.Field(default=0.4, ge=0.0)
79 zrange_snap_max_shift_m: float = 0.2595 zrange_snap_max_shift_m: float = pydantic.Field(default=0.25, ge=0.0)
80 zrange_snap_thresh_min_m: float = 0.01296 zrange_snap_thresh_min_m: float = pydantic.Field(default=0.012, ge=0.0)
81 zrange_snap_mad_k: float = 4.097 zrange_snap_mad_k: float = pydantic.Field(default=4.0, ge=0.0)
82 zrange_snap_rel_h: float = 0.1098 zrange_snap_rel_h: float = pydantic.Field(default=0.10, ge=0.0, le=1.0)
83 zrange_snap_regularize: bool = True99 zrange_snap_regularize: bool = True
84 zrange_snap_reg_max_dev_m: float = 0.30100 zrange_snap_reg_max_dev_m: float = pydantic.Field(default=0.30, ge=0.0)
101 # intensity-snap fallback
85 zrange_intensity_snap: bool = True102 zrange_intensity_snap: bool = True
86 zrange_isnap_grad_k: float = 3.0103 zrange_isnap_grad_k: float = pydantic.Field(default=3.0, ge=0.0)
87 zrange_isnap_min_step: float = 8.0104 zrange_isnap_min_step: float = pydantic.Field(default=8.0, ge=0.0)
88 zrange_isnap_paint_guard: bool = True105 zrange_isnap_paint_guard: bool = True
89 zrange_onset_bonus: float = 1.2106 # innermost-onset preference
107 zrange_onset_bonus: float = pydantic.Field(default=1.2, ge=0.0)
90 zrange_onset_smooth: bool = True108 zrange_onset_smooth: bool = True
91 zrange_guardrail_z_m: float = 0.30109 zrange_guardrail_z_m: float = pydantic.Field(default=0.30, ge=0.0)
92 zrange_guardrail_lookback_m: float = 2.0110 zrange_guardrail_lookback_m: float = pydantic.Field(default=2.0, ge=0.0)
111 # guardrail-repo hard veto
93 zrange_guardrail_veto: bool = False112 zrange_guardrail_veto: bool = False
94 zrange_guardrail_veto_conf: float = 0.7113 zrange_guardrail_veto_conf: float = pydantic.Field(default=0.7, ge=0.0, le=1.0)
95 zrange_guardrail_veto_margin_m: float = 0.5114 zrange_guardrail_veto_margin_m: float = pydantic.Field(default=0.5, ge=0.0)
115 # no Literal: the accepted instance types are the guardrail repo's
116 # vocabulary, carried in its JSON, not a choice this schema owns
96 zrange_guardrail_veto_types: tuple[str, ...] = ("w_beam",)117 zrange_guardrail_veto_types: tuple[str, ...] = ("w_beam",)
97 zrange_guardrail_veto_walls: bool = False118 zrange_guardrail_veto_walls: bool = False
98 zrange_resume_weight: float = 3.0119 # asphalt-resumes-outward penalty
120 zrange_resume_weight: float = pydantic.Field(default=3.0, ge=0.0)
99 zrange_resume_zone_m: tuple[float, float] = (0.5, 2.5)121 zrange_resume_zone_m: tuple[float, float] = (0.5, 2.5)
100 zrange_flat_thresh_m: float = 0.015122 zrange_flat_thresh_m: float = pydantic.Field(default=0.015, ge=0.0)
101 zrange_resume_tex_k: float = 1.5123 zrange_resume_tex_k: float = pydantic.Field(default=1.5, ge=0.0)
124 # shoulder rule
102 zrange_shoulder_rule: bool = True125 zrange_shoulder_rule: bool = True
103 zrange_shoulder_lookback_m: float = 2.0126 zrange_shoulder_lookback_m: float = pydantic.Field(default=2.0, ge=0.0)
104 zrange_shoulder_min_gap_m: float = 0.3127 zrange_shoulder_min_gap_m: float = pydantic.Field(default=0.3, ge=0.0)
105 zrange_shoulder_corridor_anchor: bool = True128 zrange_shoulder_corridor_anchor: bool = True
106 zrange_shoulder_sustain_m: float = 1.5129 zrange_shoulder_sustain_m: float = pydantic.Field(default=1.5, ge=0.0)
107 zrange_shoulder_max_search_m: float = 8.0130 zrange_shoulder_max_search_m: float = pydantic.Field(default=8.0, ge=0.0)
108 zrange_shoulder_grad_k: float = 2.0131 zrange_shoulder_grad_k: float = pydantic.Field(default=2.0, ge=0.0)
109 zrange_shoulder_min_step: float = 6.0132 zrange_shoulder_min_step: float = pydantic.Field(default=6.0, ge=0.0)
110 zrange_shoulder_min_width_m: float = 3.0133 zrange_shoulder_min_width_m: float = pydantic.Field(default=3.0, ge=0.0)
111 zrange_shoulder_support_m: float = 1.5134 zrange_shoulder_support_m: float = pydantic.Field(default=1.5, ge=0.0)
112 zrange_shoulder_support_tol_m: float = 0.6135 zrange_shoulder_support_tol_m: float = pydantic.Field(default=0.6, ge=0.0)
113 zrange_shoulder_support_frac: float = 0.5136 zrange_shoulder_support_frac: float = pydantic.Field(default=0.5, ge=0.0, le=1.0)
114 zrange_shoulder_consolidate: bool = True137 zrange_shoulder_consolidate: bool = True
115 zrange_shoulder_revert_isolated: bool = True138 zrange_shoulder_revert_isolated: bool = True
116 zrange_shoulder_run_smoothness_guard: bool = False139 zrange_shoulder_run_smoothness_guard: bool = False
117 zrange_shoulder_run_max_step_m: float = 0.046140 zrange_shoulder_run_max_step_m: float = pydantic.Field(default=0.046, ge=0.0)
141 # paint veto
118 zrange_paint_veto: bool = True142 zrange_paint_veto: bool = True
119 zrange_paint_quantile: float = 0.92143 zrange_paint_quantile: float = pydantic.Field(default=0.92, gt=0.0, lt=1.0)
120 zrange_paint_max_width_m: float = 0.5144 zrange_paint_max_width_m: float = pydantic.Field(default=0.5, ge=0.0)
121 zrange_paint_corridor_margin_m: float = 1.5145 zrange_paint_corridor_margin_m: float = pydantic.Field(default=1.5, ge=0.0)
146 # DP inference
122 robust: bool = True147 robust: bool = True
123 edge_smooth_window_m: float = 2.0148 edge_smooth_window_m: float = pydantic.Field(default=2.0, ge=0.0)
124 slope_cheap_m: float = 0.15149 slope_cheap_m: float = pydantic.Field(default=0.15, ge=0.0)
125 trans_lin: float = 9.0150 trans_lin: float = pydantic.Field(default=9.0, ge=0.0)
126 trans_quad: float = 220.0151 trans_quad: float = pydantic.Field(default=220.0, ge=0.0)
127 max_jump_m: float = 0.9152 max_jump_m: float = pydantic.Field(default=0.9, ge=0.0)
128 bridge_stiffness: float = 3.0153 bridge_stiffness: float = pydantic.Field(default=3.0, ge=0.0)
129 bridge_anchor_min_conf: float = 0.75154 bridge_anchor_min_conf: float = pydantic.Field(default=0.75, ge=0.0, le=1.0)
130 invalid_penalty: float = 2.5155 invalid_penalty: float = pydantic.Field(default=2.5, ge=0.0)
131 min_valid_frac: float = 0.35156 min_valid_frac: float = pydantic.Field(default=0.35, ge=0.0, le=1.0)
132 conf_gain: float = 0.7157 conf_gain: float = pydantic.Field(default=0.7, ge=0.0)
133 outer_bound_margin_m: float = 1.5158 outer_bound_margin_m: float = pydantic.Field(default=1.5, ge=0.0)
134 outer_median_bound: bool = False159 outer_median_bound: bool = False
135 outer_median_bound_margin_m: float = 0.5160 outer_median_bound_margin_m: float = pydantic.Field(default=0.5, ge=0.0)
136 outer_median_bound_hard: bool = False161 outer_median_bound_hard: bool = False
162 # vehicle mask
137 vehicle_mask: bool = True163 vehicle_mask: bool = True
138 vehicle_z_thresh_m: float = 0.02164 vehicle_z_thresh_m: float = pydantic.Field(default=0.02, ge=0.0)
139 vehicle_corridor_margin_m: float = 0.5165 vehicle_corridor_margin_m: float = pydantic.Field(default=0.5, ge=0.0)
140 vehicle_mask_z_only: bool = False166 vehicle_mask_z_only: bool = False
141 vehicle_mask_neutral: bool = True167 vehicle_mask_neutral: bool = True
142 vehicle_mask_seed_guard: bool = False168 vehicle_mask_seed_guard: bool = False
143 vehicle_mask_baseline_fill: bool = False169 vehicle_mask_baseline_fill: bool = False
144 edge_support_gate: bool = False170 edge_support_gate: bool = False
171 # CRF inference
145 use_crf: bool = False172 use_crf: bool = False
146 crf_second_order: bool = True173 crf_second_order: bool = True
147 crf_lambda_parallel: float = 4.0174 crf_lambda_parallel: float = pydantic.Field(default=4.0, ge=0.0)
148 crf_lambda_curvature: float = 8.0175 crf_lambda_curvature: float = pydantic.Field(default=8.0, ge=0.0)
149 crf_lambda_prior: float = 1.5176 crf_lambda_prior: float = pydantic.Field(default=1.5, ge=0.0)
150 crf_huber_delta_m: float = 0.10177 crf_huber_delta_m: float = pydantic.Field(default=0.10, gt=0.0)
151 crf_delta_cap_m: float = 0.6178 crf_delta_cap_m: float = pydantic.Field(default=0.6, ge=0.0)
152 crf_conf_ref: float = 1.0179 crf_conf_ref: float = pydantic.Field(default=1.0, gt=0.0)
153 crf_not_observable_support: float = 0.15180 crf_not_observable_support: float = pydantic.Field(default=0.15, ge=0.0)
154 crf_support_intensity_ref_dn: float = 40.0181 crf_support_intensity_ref_dn: float = pydantic.Field(default=40.0, gt=0.0)
155 crf_support_zrange_ref_m: float = 0.05182 crf_support_zrange_ref_m: float = pydantic.Field(default=0.05, gt=0.0)
156183
184 @pydantic.model_validator(mode="after")
185 def _check_intervals(self) -> AsphaltEdgeImageConfig:
186 """Every ``(low, high)`` interval field must be ordered."""
187 _check_interval("offset_min/offset_max", (self.offset_min, self.offset_max))
188 for name in (
189 "left_band",
190 "right_band",
191 "inner_left_band",
192 "inner_right_band",
193 "zrange_resume_zone_m",
194 ):
195 _check_interval(name, getattr(self, name))
196 return self
157197
158class GutterConfigModel(config_loader.ConfigModel):198
159 """``gutter`` block of ``asphalt_edge.default.json``."""199class AsphaltEdgeGutterConfig(config_loader.ConfigModel):
200 """``gutter`` block: thresholds of the gutter-lip cross-section scan."""
160201
161 enabled: bool = True202 enabled: bool = True
162 station_step: float = 1.0203 station_step: float = pydantic.Field(default=1.0, gt=0.0)
163 half_window_m: float = 0.75204 half_window_m: float = pydantic.Field(default=0.75, gt=0.0)
164 step_thr: float = 4000.0205 step_thr: float = pydantic.Field(default=4000.0, ge=0.0)
165 rough_mult: float = 2.5206 rough_mult: float = pydantic.Field(default=2.5, ge=0.0)
166 max_gutter_m: float = 1.5207 max_gutter_m: float = pydantic.Field(default=1.5, gt=0.0)
167 min_conf: float = 0.35208 min_conf: float = pydantic.Field(default=0.35, ge=0.0, le=1.0)
168209
169210
170class AsphaltEdgeConfig(config_loader.ConfigModel):211class AsphaltEdgeConfig(config_loader.ConfigModel):
171 """Root schema mirroring ``asphalt_edge.default.json``."""212 """Root schema mirroring ``asphalt_edge.default.json``."""
172213
173 station_step: float = 0.5214 station_step: float = pydantic.Field(default=0.5, gt=0.0)
174 half_thickness: float = 0.5215 half_thickness: float = pydantic.Field(default=0.5, gt=0.0)
175 bin_width: float = 0.1216 bin_width: float = pydantic.Field(default=0.1, gt=0.0)
176 off_range: tuple[float, float] = (-15.0, 15.0)217 off_range: tuple[float, float] = (-20.0, 20.0)
177 search_in: float = 4.5218 search_in: float = pydantic.Field(default=4.5, ge=0.0)
178 search_out: float = 0.3219 search_out: float = pydantic.Field(default=0.3, ge=0.0)
179 band_m: float = 1.0220 band_m: float = pydantic.Field(default=1.0, gt=0.0)
180 weights: FeatureWeightsConfig = FeatureWeightsConfig()221 weights: AsphaltEdgeWeightsConfig = AsphaltEdgeWeightsConfig()
181 image: ImageEdgeConfigModel = ImageEdgeConfigModel()222 image: AsphaltEdgeImageConfig = AsphaltEdgeImageConfig()
182 gutter: GutterConfigModel = GutterConfigModel()223 gutter: AsphaltEdgeGutterConfig = AsphaltEdgeGutterConfig()
183 min_confidence: float = 0.25224 min_confidence: float = pydantic.Field(default=0.25, ge=0.0, le=1.0)
184 median_window_m: float = 15.0225 median_window_m: float = pydantic.Field(default=15.0, ge=0.0)
185 mad_k: float = 3.5226 mad_k: float = pydantic.Field(default=3.0, gt=0.0)
186 max_interp_gap_m: float = 5.0227 max_interp_gap_m: float = pydantic.Field(default=5.0, ge=0.0)
187 max_interp_offset_jump_m: float = 0.5228 max_interp_offset_jump_m: float = pydantic.Field(default=0.5, ge=0.0)
188 max_offset_rate: float = 0.6229 max_offset_rate: float = pydantic.Field(default=0.6, ge=0.0)
189 min_run_m: float = 3.0230 min_run_m: float = pydantic.Field(default=3.0, ge=0.0)
190 end_stub_m: float = 5.0231 end_stub_m: float = pydantic.Field(default=5.0, ge=0.0)
191 savgol_window_m: float = 11.0232 savgol_window_m: float = pydantic.Field(default=11.0, ge=0.0)
192 savgol_order: int = 2233 savgol_order: int = pydantic.Field(default=2, ge=1)
193 max_points: int = 7_000_000234 max_points: int = pydantic.Field(default=7_000_000, gt=0)
194 hash_round_units_per_m: float = 1000.0235 hash_round_units_per_m: float = pydantic.Field(default=1000.0, gt=0.0)
195 min_carriageway_m: float = 2.0236 min_carriageway_m: float = pydantic.Field(default=2.0, ge=0.0)
237
238 @pydantic.model_validator(mode="after")
239 def _check_off_range(self) -> AsphaltEdgeConfig:
240 """``off_range`` must be ordered."""
241 _check_interval("off_range", self.off_range)
242 return self
196243
197244
198def _to_runtime(model: AsphaltEdgeConfig) -> Config:245def _to_runtime(model: AsphaltEdgeConfig) -> Config:
199 """Copy a validated model onto the mutable runtime dataclasses."""246 """Copy a validated model onto the mutable runtime dataclasses."""
Importance #2: src/iolabs_point_cloud_detection_asphaltedge/config.py @@ -205,10 +252,41 @@
205 **data,252 **data,
206 )253 )
207254
208255
209def config_from_dict(raw_config: dict[str, Any]) -> Config:256def _load_model(
210 """Build a typed :class:`Config` from a (possibly partial) mapping."""257 *,
258 overrides: Mapping[str, Any] | None = None,
259 config_path: str | Path | None = None,
260) -> AsphaltEdgeConfig:
261 """Load the packaged defaults (or ``config_path``) and apply ``overrides``."""
262 model = config_loader.load_config(
263 AsphaltEdgeConfig,
264 package=_PACKAGE_NAME,
265 filename=_DEFAULT_FILENAME,
266 overrides=overrides,
267 config_path=config_path,
268 context=_CONTEXT,
269 error_cls=AsphaltEdgeConfigError,
270 )
271 if config_path is not None:
272 logger.info("Config file applied: %s", config_path)
273 if overrides:
274 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
275 return model
276
277
278def load_default_config_dict() -> dict[str, Any]:
279 """Return the packaged default config as a plain nested ``dict``."""
280 return _load_model().model_dump()
281
282
283def config_from_dict(raw_config: Mapping[str, Any]) -> Config:
284 """Build a typed :class:`~.detector.Config` from a (possibly partial) mapping.
285
286 Keys the mapping omits fall back to the model defaults, which are identical
287 to the packaged JSON.
288 """
211 model = config_loader.validate_config(289 model = config_loader.validate_config(
212 AsphaltEdgeConfig,290 AsphaltEdgeConfig,
213 raw_config,291 raw_config,
214 context=_CONTEXT,292 context=_CONTEXT,
Importance #3: src/iolabs_point_cloud_detection_asphaltedge/config.py @@ -219,19 +297,40 @@
219297
220def load_asphalt_edge_config(298def load_asphalt_edge_config(
221 config_path: str | Path | None = None,299 config_path: str | Path | None = None,
222 *,300 *,
223 overrides: dict[str, Any] | None = None,301 overrides: Mapping[str, Any] | None = None,
224) -> Config:302) -> Config:
225 """Load the packaged default config, apply optional overrides, return a Config."""303 """Load the packaged default config, apply optional overrides, return a Config.
226 model = config_loader.load_config(304
227 AsphaltEdgeConfig,305 ``config_path`` REPLACES the packaged defaults; keys it omits fall back to
228 package=_PACKAGE,306 the model defaults.
229 filename=_DEFAULT_CONFIG_FILENAME,307 """
230 overrides=overrides,308 return _to_runtime(_load_model(overrides=overrides, config_path=config_path))
231 config_path=config_path,309
232 context=_CONTEXT,310
233 error_cls=AsphaltEdgeConfigError,311def parse_set_overrides(raw_overrides: Sequence[str] | None) -> dict[str, Any]:
312 """Parse repeatable ``--set KEY=VALUE`` strings into an override mapping.
313
314 Thin binding of :func:`iolabs.common.config_loader.parse_set_overrides` to
315 the asphalt edge error class. This config has both top-level keys and
316 sections, so both ``--set station_step=0.5`` and ``--set image.robust=false``
317 are accepted; dotted keys expand into nested mappings.
318
319 Args:
320 raw_overrides: Raw ``KEY=VALUE`` strings from ``--set``, or ``None``.
321
322 Returns:
323 A mapping ready to pass as ``overrides``; later duplicates win.
324
325 Raises:
326 AsphaltEdgeConfigError: An override is missing its ``=``, or two
327 overrides disagree about whether a path segment is a section.
328 """
329 dotted = [item for item in raw_overrides or [] if "." in item.split("=", 1)[0]]
330 flat = [item for item in raw_overrides or [] if "." not in item.split("=", 1)[0]]
331 return config_loader.deep_merge_dicts(
332 config_loader.parse_set_overrides(flat, error_cls=AsphaltEdgeConfigError),
333 config_loader.parse_set_overrides(
334 dotted, nested=True, error_cls=AsphaltEdgeConfigError
335 ),
234 )336 )
235 if overrides:
236 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
237 return _to_runtime(model)
Importance #4: src/iolabs_point_cloud_detection_asphaltedge/config.py @@ -1,27 +1,30 @@
1"""Packaged-default configuration loader for asphalt edge detection.1"""Packaged-default configuration for asphalt edge detection.
22
3Defaults live in the force-included ``asphalt_edge.default.json``. A pydantic3The schema is `AsphaltEdgeConfig` (a `config_loader.ConfigModel`), mirroring
4model tree derived from :class:`iolabs.common.config_loader.ConfigModel` is4`asphalt_edge.default.json` key for key.
5the schema: nested JSON sections are nested models, field names are JSON keys,5
6and unknown keys are rejected. Validated values are copied onto the runtime6Adding a config key means adding the field to the model and the same key to
7dataclasses (:class:`~.detector.Config`, :class:`~.features.FeatureWeights`,7`asphalt_edge.default.json` โ€” plus, here, the same field to the matching
8:class:`~.image_edge.ImageEdgeConfig`, :class:`~.gutter.GutterConfig`) because8runtime dataclass, because :func:`_to_runtime` copies every model field onto
9the algorithm, tests and scripts mutate those carriers.9the mutable carriers the algorithm, scripts and tests pass around
1010(:class:`~.detector.Config`, :class:`~.features.FeatureWeights`,
11To add a config key: add the field to the runtime dataclass *and* to the11:class:`~.image_edge.ImageEdgeConfig`, :class:`~.gutter.GutterConfig`).
12matching ``ConfigModel`` subclass here, with the same name, type and default12Unknown keys are rejected.
13(``_to_runtime`` passes every model field on to the dataclass, so a field on13
14only one side raises). Add it to the packaged JSON only when the shipped14:func:`load_asphalt_edge_config` and :func:`config_from_dict` return that
15default differs from the field default; ``test_config`` guards the parity.15runtime :class:`~.detector.Config` dataclass; :func:`load_default_config_dict`
16returns the packaged defaults as a plain ``dict``.
16"""17"""
1718
18from __future__ import annotations19from __future__ import annotations
1920
20import logging21import logging
22from collections.abc import Mapping, Sequence
21from pathlib import Path23from pathlib import Path
22from typing import Any24from typing import Any
2325
26import pydantic
24from iolabs.common import config_loader27from iolabs.common import config_loader
2528
26from .detector import Config29from .detector import Config
27from .features import FeatureWeights30from .features import FeatureWeights
Importance #5: src/iolabs_point_cloud_detection_asphaltedge/__init__.py @@ -10,11 +10,14 @@
1010
11from importlib.metadata import PackageNotFoundError, version11from importlib.metadata import PackageNotFoundError, version
1212
13from .config import (13from .config import (
14 AsphaltEdgeConfig,
14 AsphaltEdgeConfigError,15 AsphaltEdgeConfigError,
15 config_from_dict,16 config_from_dict,
16 load_asphalt_edge_config,17 load_asphalt_edge_config,
18 load_default_config_dict,
19 parse_set_overrides,
17)20)
18from .detector import Config, EdgeResult, detect_edges21from .detector import Config, EdgeResult, detect_edges
19from .polyline import FLAG_GAP, FLAG_INTERPOLATED, FLAG_MEASURED22from .polyline import FLAG_GAP, FLAG_INTERPOLATED, FLAG_MEASURED
2023
Importance #6: src/iolabs_point_cloud_detection_asphaltedge/__init__.py @@ -26,12 +29,15 @@
26__all__ = [29__all__ = [
27 "FLAG_GAP",30 "FLAG_GAP",
28 "FLAG_INTERPOLATED",31 "FLAG_INTERPOLATED",
29 "FLAG_MEASURED",32 "FLAG_MEASURED",
33 "AsphaltEdgeConfig",
30 "AsphaltEdgeConfigError",34 "AsphaltEdgeConfigError",
31 "Config",35 "Config",
32 "EdgeResult",36 "EdgeResult",
33 "__version__",37 "__version__",
34 "config_from_dict",38 "config_from_dict",
35 "detect_edges",39 "detect_edges",
36 "load_asphalt_edge_config",40 "load_asphalt_edge_config",
41 "load_default_config_dict",
42 "parse_set_overrides",
37]43]
Importance #7: src/iolabs_point_cloud_detection_asphaltedge/detector.py @@ -37,17 +37,16 @@
3737
38 Defaults of a loaded config come from ``asphalt_edge.default.json`` via38 Defaults of a loaded config come from ``asphalt_edge.default.json`` via
39 :func:`.config_from_dict`. Nested rectified-image, gutter and weight39 :func:`.config_from_dict`. Nested rectified-image, gutter and weight
40 blocks live in their own dataclasses reachable from here. To add a key,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``41 add the field here *and* to `AsphaltEdgeConfig` in ``config.py`` *and* to
42 with the same name, type and default; the packaged JSON only needs it when42 the packaged JSON, with the same name, type and default.
43 the shipped default differs.
44 """43 """
4544
46 station_step: float = 0.545 station_step: float = 0.5
47 half_thickness: float = 0.546 half_thickness: float = 0.5
48 bin_width: float = 0.147 bin_width: float = 0.1
49 off_range: tuple[float, float] = (-15.0, 15.0)48 off_range: tuple[float, float] = (-20.0, 20.0)
50 # Search band relative to the surface-extent outer bound: the material edge49 # Search band relative to the surface-extent outer bound: the material edge
51 # lies up to ``search_in`` metres inside the data extent (dark shoulder +50 # lies up to ``search_in`` metres inside the data extent (dark shoulder +
52 # bright verge), with a small ``search_out`` slack for prior jitter.51 # bright verge), with a small ``search_out`` slack for prior jitter.
53 # (Fallback point scorer, used only when no tile is supplied.)52 # (Fallback point scorer, used only when no tile is supplied.)
Importance #8: src/iolabs_point_cloud_detection_asphaltedge/detector.py @@ -62,9 +61,9 @@
62 # this dataset (no confirmed concrete gutter); never drives the edge.61 # this dataset (no confirmed concrete gutter); never drives the edge.
63 gutter: GutterConfig = field(default_factory=GutterConfig)62 gutter: GutterConfig = field(default_factory=GutterConfig)
64 min_confidence: float = 0.2563 min_confidence: float = 0.25
65 median_window_m: float = 15.064 median_window_m: float = 15.0
66 mad_k: float = 3.565 mad_k: float = 3.0
67 max_interp_gap_m: float = 5.066 max_interp_gap_m: float = 5.0
68 max_interp_offset_jump_m: float = 0.567 max_interp_offset_jump_m: float = 0.5
69 max_offset_rate: float = 0.668 max_offset_rate: float = 0.6
70 min_run_m: float = 3.069 min_run_m: float = 3.0
Importance #9: src/iolabs_point_cloud_detection_asphaltedge/image_edge.py @@ -98,9 +98,9 @@
98 # density/roughness change and a detrended-z kink rather than a bright verge.98 # density/roughness change and a detrended-z kink rather than a bright verge.
99 intensity_weight: float = 1.099 intensity_weight: float = 1.0
100 texture_weight: float = 0.6100 texture_weight: float = 0.6
101 density_weight: float = 0.0 # point density drop (asphalt dense -> sparse)101 density_weight: float = 0.0 # point density drop (asphalt dense -> sparse)
102 roughness_weight: float = 0.0 # outward rise in per-cell z-roughness102 roughness_weight: float = 0.4 # outward rise in per-cell z-roughness
103 z_step_weight: float = 0.0 # outward rise in per-cell mean elevation (kink)103 z_step_weight: float = 0.0 # outward rise in per-cell mean elevation (kink)
104 # z-range (maxโˆ’min z per pixel) channel: sharp pavement-edge ridge.104 # z-range (maxโˆ’min z per pixel) channel: sharp pavement-edge ridge.
105 zrange_weight: float = 1.5105 zrange_weight: float = 1.5
106 zrange_win_m: float = 0.4 # outward-step window; z transition is sharp106 zrange_win_m: float = 0.4 # outward-step window; z transition is sharp
Importance #10: src/iolabs_point_cloud_detection_asphaltedge/asphalt_edge.default.json @@ -5,21 +5,8 @@
5 "off_range": [-20.0, 20.0],5 "off_range": [-20.0, 20.0],
6 "search_in": 4.5,6 "search_in": 4.5,
7 "search_out": 0.3,7 "search_out": 0.3,
8 "band_m": 1.0,8 "band_m": 1.0,
9 "min_confidence": 0.25,
10 "median_window_m": 15.0,
11 "mad_k": 3.0,
12 "max_interp_gap_m": 5.0,
13 "max_interp_offset_jump_m": 0.5,
14 "max_offset_rate": 0.6,
15 "min_run_m": 3.0,
16 "end_stub_m": 5.0,
17 "savgol_window_m": 11.0,
18 "savgol_order": 2,
19 "max_points": 7000000,
20 "hash_round_units_per_m": 1000.0,
21 "min_carriageway_m": 2.0,
22 "weights": {9 "weights": {
23 "intensity_step": 2.5,10 "intensity_step": 2.5,
24 "density_drop": 2.0,11 "density_drop": 2.0,
25 "z_rise": 1.0,12 "z_rise": 1.0,
Importance #11: src/iolabs_point_cloud_detection_asphaltedge/asphalt_edge.default.json @@ -45,18 +32,90 @@
45 "texture_weight": 0.6,32 "texture_weight": 0.6,
46 "density_weight": 0.0,33 "density_weight": 0.0,
47 "roughness_weight": 0.4,34 "roughness_weight": 0.4,
48 "z_step_weight": 0.0,35 "z_step_weight": 0.0,
36 "zrange_weight": 1.5,
37 "zrange_win_m": 0.4,
38 "zrange_cap_m": 0.15,
39 "zrange_snap": true,
40 "zrange_snap_window_m": 0.4,
41 "zrange_snap_max_shift_m": 0.25,
42 "zrange_snap_thresh_min_m": 0.012,
43 "zrange_snap_mad_k": 4.0,
44 "zrange_snap_rel_h": 0.1,
45 "zrange_snap_regularize": true,
46 "zrange_snap_reg_max_dev_m": 0.3,
47 "zrange_intensity_snap": true,
48 "zrange_isnap_grad_k": 3.0,
49 "zrange_isnap_min_step": 8.0,
50 "zrange_isnap_paint_guard": true,
51 "zrange_onset_bonus": 1.2,
52 "zrange_onset_smooth": true,
53 "zrange_guardrail_z_m": 0.3,
54 "zrange_guardrail_lookback_m": 2.0,
55 "zrange_guardrail_veto": false,
56 "zrange_guardrail_veto_conf": 0.7,
57 "zrange_guardrail_veto_margin_m": 0.5,
58 "zrange_guardrail_veto_types": ["w_beam"],
59 "zrange_guardrail_veto_walls": false,
60 "zrange_resume_weight": 3.0,
61 "zrange_resume_zone_m": [0.5, 2.5],
62 "zrange_flat_thresh_m": 0.015,
63 "zrange_resume_tex_k": 1.5,
64 "zrange_shoulder_rule": true,
65 "zrange_shoulder_lookback_m": 2.0,
66 "zrange_shoulder_min_gap_m": 0.3,
67 "zrange_shoulder_corridor_anchor": true,
68 "zrange_shoulder_sustain_m": 1.5,
69 "zrange_shoulder_max_search_m": 8.0,
70 "zrange_shoulder_grad_k": 2.0,
71 "zrange_shoulder_min_step": 6.0,
72 "zrange_shoulder_min_width_m": 3.0,
73 "zrange_shoulder_support_m": 1.5,
74 "zrange_shoulder_support_tol_m": 0.6,
75 "zrange_shoulder_support_frac": 0.5,
76 "zrange_shoulder_consolidate": true,
77 "zrange_shoulder_revert_isolated": true,
78 "zrange_shoulder_run_smoothness_guard": false,
79 "zrange_shoulder_run_max_step_m": 0.046,
80 "zrange_paint_veto": true,
81 "zrange_paint_quantile": 0.92,
82 "zrange_paint_max_width_m": 0.5,
83 "zrange_paint_corridor_margin_m": 1.5,
49 "robust": true,84 "robust": true,
50 "edge_smooth_window_m": 2.0,85 "edge_smooth_window_m": 2.0,
51 "slope_cheap_m": 0.15,86 "slope_cheap_m": 0.15,
52 "trans_lin": 9.0,87 "trans_lin": 9.0,
53 "trans_quad": 220.0,88 "trans_quad": 220.0,
54 "max_jump_m": 0.9,89 "max_jump_m": 0.9,
90 "bridge_stiffness": 3.0,
91 "bridge_anchor_min_conf": 0.75,
55 "invalid_penalty": 2.5,92 "invalid_penalty": 2.5,
56 "min_valid_frac": 0.35,93 "min_valid_frac": 0.35,
57 "conf_gain": 0.7,94 "conf_gain": 0.7,
58 "outer_bound_margin_m": 1.595 "outer_bound_margin_m": 1.5,
96 "outer_median_bound": false,
97 "outer_median_bound_margin_m": 0.5,
98 "outer_median_bound_hard": false,
99 "vehicle_mask": true,
100 "vehicle_z_thresh_m": 0.02,
101 "vehicle_corridor_margin_m": 0.5,
102 "vehicle_mask_z_only": false,
103 "vehicle_mask_neutral": true,
104 "vehicle_mask_seed_guard": false,
105 "vehicle_mask_baseline_fill": false,
106 "edge_support_gate": false,
107 "use_crf": false,
108 "crf_second_order": true,
109 "crf_lambda_parallel": 4.0,
110 "crf_lambda_curvature": 8.0,
111 "crf_lambda_prior": 1.5,
112 "crf_huber_delta_m": 0.1,
113 "crf_delta_cap_m": 0.6,
114 "crf_conf_ref": 1.0,
115 "crf_not_observable_support": 0.15,
116 "crf_support_intensity_ref_dn": 40.0,
117 "crf_support_zrange_ref_m": 0.05
59 },118 },
60 "gutter": {119 "gutter": {
61 "enabled": true,120 "enabled": true,
62 "station_step": 1.0,121 "station_step": 1.0,
Importance #12: src/iolabs_point_cloud_detection_asphaltedge/asphalt_edge.default.json @@ -64,6 +123,19 @@
64 "step_thr": 4000.0,123 "step_thr": 4000.0,
65 "rough_mult": 2.5,124 "rough_mult": 2.5,
66 "max_gutter_m": 1.5,125 "max_gutter_m": 1.5,
67 "min_conf": 0.35126 "min_conf": 0.35
68 }127 },
128 "min_confidence": 0.25,
129 "median_window_m": 15.0,
130 "mad_k": 3.0,
131 "max_interp_gap_m": 5.0,
132 "max_interp_offset_jump_m": 0.5,
133 "max_offset_rate": 0.6,
134 "min_run_m": 3.0,
135 "end_stub_m": 5.0,
136 "savgol_window_m": 11.0,
137 "savgol_order": 2,
138 "max_points": 7000000,
139 "hash_round_units_per_m": 1000.0,
140 "min_carriageway_m": 2.0
69}141}
Importance #13: tests/test_config.py @@ -2,8 +2,9 @@
22
3from __future__ import annotations3from __future__ import annotations
44
5import dataclasses5import dataclasses
6import json
6import typing7import typing
7from pathlib import Path8from pathlib import Path
89
9import pytest10import pytest
Importance #14: tests/test_config.py @@ -11,43 +12,65 @@
1112
12from iolabs_point_cloud_detection_asphaltedge.config import (13from iolabs_point_cloud_detection_asphaltedge.config import (
13 AsphaltEdgeConfig,14 AsphaltEdgeConfig,
14 AsphaltEdgeConfigError,15 AsphaltEdgeConfigError,
15 FeatureWeightsConfig,16 AsphaltEdgeGutterConfig,
16 GutterConfigModel,17 AsphaltEdgeImageConfig,
17 ImageEdgeConfigModel,18 AsphaltEdgeWeightsConfig,
18 config_from_dict,19 config_from_dict,
19 load_asphalt_edge_config,20 load_asphalt_edge_config,
21 load_default_config_dict,
22 parse_set_overrides,
20)23)
21from iolabs_point_cloud_detection_asphaltedge.detector import Config24from iolabs_point_cloud_detection_asphaltedge.detector import Config
22from iolabs_point_cloud_detection_asphaltedge.features import FeatureWeights25from iolabs_point_cloud_detection_asphaltedge.features import FeatureWeights
23from iolabs_point_cloud_detection_asphaltedge.gutter import GutterConfig26from iolabs_point_cloud_detection_asphaltedge.gutter import GutterConfig
24from iolabs_point_cloud_detection_asphaltedge.image_edge import ImageEdgeConfig27from iolabs_point_cloud_detection_asphaltedge.image_edge import ImageEdgeConfig
2528
2629
27def test_error_class_is_config_error_and_value_error() -> None:30def _packaged_json() -> dict:
28 assert issubclass(AsphaltEdgeConfigError, config_loader.ConfigError)31 path = config_loader.default_config_path(
29 assert issubclass(AsphaltEdgeConfigError, ValueError)32 "iolabs_point_cloud_detection_asphaltedge", "asphalt_edge.default.json"
33 )
34 return json.loads(path.read_text(encoding="utf-8"))
35
3036
37def _as_json(config: dict) -> dict:
38 """Normalise a ``model_dump`` (tuples) to JSON types (lists)."""
39 return json.loads(json.dumps(config))
3140
32def test_load_packaged_defaults_with_zero_overrides() -> None:41
42def test_model_defaults_match_packaged_json() -> None:
43 assert _as_json(AsphaltEdgeConfig().model_dump()) == _packaged_json()
44
45
46def test_load_asphalt_edge_config_returns_packaged_defaults() -> None:
33 cfg = load_asphalt_edge_config()47 cfg = load_asphalt_edge_config()
34 assert isinstance(cfg, Config)48 assert isinstance(cfg, Config)
49 assert dataclasses.asdict(cfg) == AsphaltEdgeConfig().model_dump()
35 assert cfg.station_step == 0.550 assert cfg.station_step == 0.5
36 assert cfg.off_range == (-20.0, 20.0)51 assert cfg.off_range == (-20.0, 20.0)
37 assert cfg.mad_k == 3.052 assert cfg.mad_k == 3.0
38 assert cfg.weights.intensity_step == 2.553 assert cfg.weights.intensity_step == 2.5
39 assert cfg.image.left_band == (1.0, 17.0)54 assert cfg.image.left_band == (1.0, 17.0)
40 assert cfg.image.roughness_weight == 0.455 assert cfg.image.roughness_weight == 0.4
56 assert cfg.image.zrange_weight == 1.5
41 assert cfg.image.robust is True57 assert cfg.image.robust is True
42 assert cfg.gutter.enabled is True58 assert cfg.gutter.enabled is True
43 assert cfg.gutter.step_thr == 4000.059 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_confidence60 assert cfg.polyline.min_confidence == cfg.min_confidence
4761
4862
49def test_overrides_deep_merge_nested_sections() -> None:63def test_load_default_config_dict_matches_packaged_json() -> None:
64 assert _as_json(load_default_config_dict()) == _packaged_json()
65
66
67def test_error_class_is_config_error() -> None:
68 assert issubclass(AsphaltEdgeConfigError, config_loader.ConfigError)
69 assert issubclass(AsphaltEdgeConfigError, ValueError)
70
71
72def test_overrides_deep_merge_onto_defaults() -> None:
50 cfg = load_asphalt_edge_config(73 cfg = load_asphalt_edge_config(
51 overrides={74 overrides={
52 "min_confidence": 0.4,75 "min_confidence": 0.4,
53 "weights": {"z_rise": 9.0},76 "weights": {"z_rise": 9.0},
Importance #15: tests/test_config.py @@ -64,37 +87,48 @@
64 assert cfg.gutter.enabled is False87 assert cfg.gutter.enabled is False
65 assert cfg.gutter.min_conf == 0.3588 assert cfg.gutter.min_conf == 0.35
6689
6790
68def test_unknown_top_level_key_rejected() -> None:91def test_unknown_top_level_key_is_rejected() -> None:
69 with pytest.raises(AsphaltEdgeConfigError, match="not_a_key") as excinfo:92 with pytest.raises(AsphaltEdgeConfigError, match="not_a_key") as excinfo:
70 load_asphalt_edge_config(overrides={"not_a_key": 1})93 load_asphalt_edge_config(overrides={"not_a_key": 1})
71 message = str(excinfo.value)94 message = str(excinfo.value)
72 assert "Unknown" in message95 assert "Unknown" in message
73 assert "station_step" in message96 assert "station_step" in message
7497
7598
76def test_unknown_nested_key_rejected() -> None:99def test_unknown_nested_key_is_rejected() -> None:
77 with pytest.raises(AsphaltEdgeConfigError, match="typo_band") as excinfo:100 with pytest.raises(AsphaltEdgeConfigError, match="typo_band") as excinfo:
78 load_asphalt_edge_config(overrides={"image": {"typo_band": [0.0, 1.0]}})101 load_asphalt_edge_config(overrides={"image": {"typo_band": [0.0, 1.0]}})
79 message = str(excinfo.value)102 message = str(excinfo.value)
80 assert "Unknown" in message103 assert "Unknown" in message
81 assert "left_band" in message104 assert "left_band" in message
82105
83106
107def test_set_override_coercion_and_rejection() -> None:
108 overrides = parse_set_overrides(
109 ["station_step=1e3", "image.robust=on", "gutter.min_conf=0.5"]
110 )
111 assert overrides == {
112 "station_step": 1000.0,
113 "image": {"robust": "on"},
114 "gutter": {"min_conf": 0.5},
115 }
116 cfg = load_asphalt_edge_config(overrides=overrides)
117 assert cfg.station_step == 1000.0
118 assert cfg.image.robust is True
119 assert cfg.gutter.min_conf == 0.5
120 with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"):
121 load_asphalt_edge_config(overrides=parse_set_overrides(["image.robust=flase"]))
122 with pytest.raises(AsphaltEdgeConfigError, match="Expected"):
123 parse_set_overrides(["station_step"])
124
125
84def test_config_from_dict_unknown_key_rejected() -> None:126def test_config_from_dict_unknown_key_rejected() -> None:
85 with pytest.raises(AsphaltEdgeConfigError, match="bogus"):127 with pytest.raises(AsphaltEdgeConfigError, match="bogus"):
86 config_from_dict({"station_step": 0.5, "bogus": 1})128 config_from_dict({"station_step": 0.5, "bogus": 1})
87129
88130
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:131def test_invalid_bool_token_rejected() -> None:
98 with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"):132 with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"):
99 load_asphalt_edge_config(overrides={"gutter": {"enabled": "flase"}})133 load_asphalt_edge_config(overrides={"gutter": {"enabled": "flase"}})
100134
Importance #16: tests/test_config.py @@ -103,8 +137,24 @@
103 with pytest.raises(AsphaltEdgeConfigError, match="Invalid float"):137 with pytest.raises(AsphaltEdgeConfigError, match="Invalid float"):
104 load_asphalt_edge_config(overrides={"station_step": "abc"})138 load_asphalt_edge_config(overrides={"station_step": "abc"})
105139
106140
141def test_out_of_range_value_rejected() -> None:
142 with pytest.raises(AsphaltEdgeConfigError, match="station_step"):
143 load_asphalt_edge_config(overrides={"station_step": 0.0})
144 with pytest.raises(AsphaltEdgeConfigError, match="min_confidence"):
145 load_asphalt_edge_config(overrides={"min_confidence": 1.5})
146 with pytest.raises(AsphaltEdgeConfigError, match="roughness_weight"):
147 load_asphalt_edge_config(overrides={"image": {"roughness_weight": -1.0}})
148
149
150def test_unordered_interval_rejected() -> None:
151 with pytest.raises(AsphaltEdgeConfigError, match="off_range"):
152 config_from_dict({"off_range": [5.0, -5.0]})
153 with pytest.raises(AsphaltEdgeConfigError, match="left_band"):
154 config_from_dict({"image": {"left_band": [17.0, 1.0]}})
155
156
107def test_off_range_list_becomes_float_tuple() -> None:157def test_off_range_list_becomes_float_tuple() -> None:
108 cfg = config_from_dict({"off_range": ["-3", "4"]})158 cfg = config_from_dict({"off_range": ["-3", "4"]})
109 assert cfg.off_range == (-3.0, 4.0)159 assert cfg.off_range == (-3.0, 4.0)
110160
Importance #17: tests/test_config.py @@ -120,11 +170,11 @@
120 assert isinstance(cfg.weights, FeatureWeights)170 assert isinstance(cfg.weights, FeatureWeights)
121 assert isinstance(cfg.image, ImageEdgeConfig)171 assert isinstance(cfg.image, ImageEdgeConfig)
122 assert isinstance(cfg.gutter, GutterConfig)172 assert isinstance(cfg.gutter, GutterConfig)
123 assert cfg.min_confidence == 0.1173 assert cfg.min_confidence == 0.1
124 # Partial mappings keep dataclass defaults for omitted keys.174 # Partial mappings keep the shipped defaults for omitted keys.
125 assert cfg.off_range == (-15.0, 15.0)175 assert cfg.off_range == (-20.0, 20.0)
126 assert cfg.image.roughness_weight == 0.0176 assert cfg.image.roughness_weight == 0.4
127177
128178
129def test_load_from_config_path(tmp_path: Path) -> None:179def test_load_from_config_path(tmp_path: Path) -> None:
130 path = tmp_path / "custom.json"180 path = tmp_path / "custom.json"
Importance #18: tests/test_config.py @@ -143,11 +193,11 @@
143@pytest.mark.parametrize(193@pytest.mark.parametrize(
144 ("dataclass_cls", "model_cls"),194 ("dataclass_cls", "model_cls"),
145 [195 [
146 (Config, AsphaltEdgeConfig),196 (Config, AsphaltEdgeConfig),
147 (FeatureWeights, FeatureWeightsConfig),197 (FeatureWeights, AsphaltEdgeWeightsConfig),
148 (ImageEdgeConfig, ImageEdgeConfigModel),198 (ImageEdgeConfig, AsphaltEdgeImageConfig),
149 (GutterConfig, GutterConfigModel),199 (GutterConfig, AsphaltEdgeGutterConfig),
150 ],200 ],
151)201)
152def test_model_mirrors_runtime_dataclass(dataclass_cls: type, model_cls: type) -> None:202def 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."""203 """Every model field must exist on the dataclass with the same type and default."""
Importance #19: README.md @@ -0,0 +1,36 @@
1# iolabs-point-cloud-detection-asphaltedge
2
3Asphalt edge detection from highway LIDAR road-surface point clouds: per segment
4it rectifies the run7 top-down tile into a `(station, offset)` image, tracks the
5pavementโ†’verge edge with a DP/CRF localizer over the intensity, texture and
6z-range channels, and returns cleaned per-side polylines with per-station flags.
7
8- `detect_edges` โ€” the detector entry point (`detector.py`)
9- `image_edge.py` โ€” rectification, evidence channels, DP/CRF edge tracking
10- `features.py` / `gutter.py` / `polyline.py` โ€” cross-profile scoring, optional
11 gutter-lip annotation, polyline hygiene
12- `io.py` / `overlay.py` โ€” segment/tile/zrange loading, QC overlays
13- `scripts/run_segments.py` โ€” the standalone multi-segment runner; the pipeline
14 wrapper is lanefinder's `scripts/pipeline/run_8_asphalt_edges.py`
15
16Tests: `uv run --extra dev pytest tests/`.
17
18## Configuration
19
20Defaults live in `src/iolabs_point_cloud_detection_asphaltedge/asphalt_edge.default.json`.
21The schema is `AsphaltEdgeConfig` in `iolabs_point_cloud_detection_asphaltedge.config`
22(a `config_loader.ConfigModel`); nested JSON sections (`weights`, `image`,
23`gutter`) are nested models and unknown keys are rejected. **To add a config key:
24add the field (with its type, default and any `Field` range) to the model, the
25same key with the same default to the JSON, and the same field to the matching
26runtime dataclass โ€” nothing else.** The third step is specific to this package:
27`config._to_runtime` copies every model field onto the mutable carriers the
28algorithm passes around (`detector.Config`, `features.FeatureWeights`,
29`image_edge.ImageEdgeConfig`, `gutter.GutterConfig`), so a field present on only
30one side raises, and `tests/test_config.py` guards both parities.
31
32`load_asphalt_edge_config()` and `config_from_dict()` return the runtime
33`detector.Config` dataclass; `load_default_config_dict()` returns the packaged
34defaults as a plain `dict`. Runtime overrides come from repeatable
35`--set KEY=VALUE` (`parse_set_overrides`, which accepts both top-level keys and
36dotted `section.key` paths), never repo-local JSON.
0
Importance #20: src/iolabs_point_cloud_detection_asphaltedge/__init__.py @@ -10,11 +10,14 @@
1010
11from importlib.metadata import PackageNotFoundError, version11from importlib.metadata import PackageNotFoundError, version
1212
13from .config import (13from .config import (
14 AsphaltEdgeConfig,
14 AsphaltEdgeConfigError,15 AsphaltEdgeConfigError,
15 config_from_dict,16 config_from_dict,
16 load_asphalt_edge_config,17 load_asphalt_edge_config,
18 load_default_config_dict,
19 parse_set_overrides,
17)20)
18from .detector import Config, EdgeResult, detect_edges21from .detector import Config, EdgeResult, detect_edges
19from .polyline import FLAG_GAP, FLAG_INTERPOLATED, FLAG_MEASURED22from .polyline import FLAG_GAP, FLAG_INTERPOLATED, FLAG_MEASURED
2023
Importance #21: src/iolabs_point_cloud_detection_asphaltedge/__init__.py @@ -26,12 +29,15 @@
26__all__ = [29__all__ = [
27 "FLAG_GAP",30 "FLAG_GAP",
28 "FLAG_INTERPOLATED",31 "FLAG_INTERPOLATED",
29 "FLAG_MEASURED",32 "FLAG_MEASURED",
33 "AsphaltEdgeConfig",
30 "AsphaltEdgeConfigError",34 "AsphaltEdgeConfigError",
31 "Config",35 "Config",
32 "EdgeResult",36 "EdgeResult",
33 "__version__",37 "__version__",
34 "config_from_dict",38 "config_from_dict",
35 "detect_edges",39 "detect_edges",
36 "load_asphalt_edge_config",40 "load_asphalt_edge_config",
41 "load_default_config_dict",
42 "parse_set_overrides",
37]43]
Importance #22: src/iolabs_point_cloud_detection_asphaltedge/asphalt_edge.default.json @@ -5,21 +5,8 @@
5 "off_range": [-20.0, 20.0],5 "off_range": [-20.0, 20.0],
6 "search_in": 4.5,6 "search_in": 4.5,
7 "search_out": 0.3,7 "search_out": 0.3,
8 "band_m": 1.0,8 "band_m": 1.0,
9 "min_confidence": 0.25,
10 "median_window_m": 15.0,
11 "mad_k": 3.0,
12 "max_interp_gap_m": 5.0,
13 "max_interp_offset_jump_m": 0.5,
14 "max_offset_rate": 0.6,
15 "min_run_m": 3.0,
16 "end_stub_m": 5.0,
17 "savgol_window_m": 11.0,
18 "savgol_order": 2,
19 "max_points": 7000000,
20 "hash_round_units_per_m": 1000.0,
21 "min_carriageway_m": 2.0,
22 "weights": {9 "weights": {
23 "intensity_step": 2.5,10 "intensity_step": 2.5,
24 "density_drop": 2.0,11 "density_drop": 2.0,
25 "z_rise": 1.0,12 "z_rise": 1.0,
Importance #23: src/iolabs_point_cloud_detection_asphaltedge/asphalt_edge.default.json @@ -45,18 +32,90 @@
45 "texture_weight": 0.6,32 "texture_weight": 0.6,
46 "density_weight": 0.0,33 "density_weight": 0.0,
47 "roughness_weight": 0.4,34 "roughness_weight": 0.4,
48 "z_step_weight": 0.0,35 "z_step_weight": 0.0,
36 "zrange_weight": 1.5,
37 "zrange_win_m": 0.4,
38 "zrange_cap_m": 0.15,
39 "zrange_snap": true,
40 "zrange_snap_window_m": 0.4,
41 "zrange_snap_max_shift_m": 0.25,
42 "zrange_snap_thresh_min_m": 0.012,
43 "zrange_snap_mad_k": 4.0,
44 "zrange_snap_rel_h": 0.1,
45 "zrange_snap_regularize": true,
46 "zrange_snap_reg_max_dev_m": 0.3,
47 "zrange_intensity_snap": true,
48 "zrange_isnap_grad_k": 3.0,
49 "zrange_isnap_min_step": 8.0,
50 "zrange_isnap_paint_guard": true,
51 "zrange_onset_bonus": 1.2,
52 "zrange_onset_smooth": true,
53 "zrange_guardrail_z_m": 0.3,
54 "zrange_guardrail_lookback_m": 2.0,
55 "zrange_guardrail_veto": false,
56 "zrange_guardrail_veto_conf": 0.7,
57 "zrange_guardrail_veto_margin_m": 0.5,
58 "zrange_guardrail_veto_types": ["w_beam"],
59 "zrange_guardrail_veto_walls": false,
60 "zrange_resume_weight": 3.0,
61 "zrange_resume_zone_m": [0.5, 2.5],
62 "zrange_flat_thresh_m": 0.015,
63 "zrange_resume_tex_k": 1.5,
64 "zrange_shoulder_rule": true,
65 "zrange_shoulder_lookback_m": 2.0,
66 "zrange_shoulder_min_gap_m": 0.3,
67 "zrange_shoulder_corridor_anchor": true,
68 "zrange_shoulder_sustain_m": 1.5,
69 "zrange_shoulder_max_search_m": 8.0,
70 "zrange_shoulder_grad_k": 2.0,
71 "zrange_shoulder_min_step": 6.0,
72 "zrange_shoulder_min_width_m": 3.0,
73 "zrange_shoulder_support_m": 1.5,
74 "zrange_shoulder_support_tol_m": 0.6,
75 "zrange_shoulder_support_frac": 0.5,
76 "zrange_shoulder_consolidate": true,
77 "zrange_shoulder_revert_isolated": true,
78 "zrange_shoulder_run_smoothness_guard": false,
79 "zrange_shoulder_run_max_step_m": 0.046,
80 "zrange_paint_veto": true,
81 "zrange_paint_quantile": 0.92,
82 "zrange_paint_max_width_m": 0.5,
83 "zrange_paint_corridor_margin_m": 1.5,
49 "robust": true,84 "robust": true,
50 "edge_smooth_window_m": 2.0,85 "edge_smooth_window_m": 2.0,
51 "slope_cheap_m": 0.15,86 "slope_cheap_m": 0.15,
52 "trans_lin": 9.0,87 "trans_lin": 9.0,
53 "trans_quad": 220.0,88 "trans_quad": 220.0,
54 "max_jump_m": 0.9,89 "max_jump_m": 0.9,
90 "bridge_stiffness": 3.0,
91 "bridge_anchor_min_conf": 0.75,
55 "invalid_penalty": 2.5,92 "invalid_penalty": 2.5,
56 "min_valid_frac": 0.35,93 "min_valid_frac": 0.35,
57 "conf_gain": 0.7,94 "conf_gain": 0.7,
58 "outer_bound_margin_m": 1.595 "outer_bound_margin_m": 1.5,
96 "outer_median_bound": false,
97 "outer_median_bound_margin_m": 0.5,
98 "outer_median_bound_hard": false,
99 "vehicle_mask": true,
100 "vehicle_z_thresh_m": 0.02,
101 "vehicle_corridor_margin_m": 0.5,
102 "vehicle_mask_z_only": false,
103 "vehicle_mask_neutral": true,
104 "vehicle_mask_seed_guard": false,
105 "vehicle_mask_baseline_fill": false,
106 "edge_support_gate": false,
107 "use_crf": false,
108 "crf_second_order": true,
109 "crf_lambda_parallel": 4.0,
110 "crf_lambda_curvature": 8.0,
111 "crf_lambda_prior": 1.5,
112 "crf_huber_delta_m": 0.1,
113 "crf_delta_cap_m": 0.6,
114 "crf_conf_ref": 1.0,
115 "crf_not_observable_support": 0.15,
116 "crf_support_intensity_ref_dn": 40.0,
117 "crf_support_zrange_ref_m": 0.05
59 },118 },
60 "gutter": {119 "gutter": {
61 "enabled": true,120 "enabled": true,
62 "station_step": 1.0,121 "station_step": 1.0,
Importance #24: src/iolabs_point_cloud_detection_asphaltedge/asphalt_edge.default.json @@ -64,6 +123,19 @@
64 "step_thr": 4000.0,123 "step_thr": 4000.0,
65 "rough_mult": 2.5,124 "rough_mult": 2.5,
66 "max_gutter_m": 1.5,125 "max_gutter_m": 1.5,
67 "min_conf": 0.35126 "min_conf": 0.35
68 }127 },
128 "min_confidence": 0.25,
129 "median_window_m": 15.0,
130 "mad_k": 3.0,
131 "max_interp_gap_m": 5.0,
132 "max_interp_offset_jump_m": 0.5,
133 "max_offset_rate": 0.6,
134 "min_run_m": 3.0,
135 "end_stub_m": 5.0,
136 "savgol_window_m": 11.0,
137 "savgol_order": 2,
138 "max_points": 7000000,
139 "hash_round_units_per_m": 1000.0,
140 "min_carriageway_m": 2.0
69}141}
Importance #25: src/iolabs_point_cloud_detection_asphaltedge/config.py @@ -1,27 +1,30 @@
1"""Packaged-default configuration loader for asphalt edge detection.1"""Packaged-default configuration for asphalt edge detection.
22
3Defaults live in the force-included ``asphalt_edge.default.json``. A pydantic3The schema is `AsphaltEdgeConfig` (a `config_loader.ConfigModel`), mirroring
4model tree derived from :class:`iolabs.common.config_loader.ConfigModel` is4`asphalt_edge.default.json` key for key.
5the schema: nested JSON sections are nested models, field names are JSON keys,5
6and unknown keys are rejected. Validated values are copied onto the runtime6Adding a config key means adding the field to the model and the same key to
7dataclasses (:class:`~.detector.Config`, :class:`~.features.FeatureWeights`,7`asphalt_edge.default.json` โ€” plus, here, the same field to the matching
8:class:`~.image_edge.ImageEdgeConfig`, :class:`~.gutter.GutterConfig`) because8runtime dataclass, because :func:`_to_runtime` copies every model field onto
9the algorithm, tests and scripts mutate those carriers.9the mutable carriers the algorithm, scripts and tests pass around
1010(:class:`~.detector.Config`, :class:`~.features.FeatureWeights`,
11To add a config key: add the field to the runtime dataclass *and* to the11:class:`~.image_edge.ImageEdgeConfig`, :class:`~.gutter.GutterConfig`).
12matching ``ConfigModel`` subclass here, with the same name, type and default12Unknown keys are rejected.
13(``_to_runtime`` passes every model field on to the dataclass, so a field on13
14only one side raises). Add it to the packaged JSON only when the shipped14:func:`load_asphalt_edge_config` and :func:`config_from_dict` return that
15default differs from the field default; ``test_config`` guards the parity.15runtime :class:`~.detector.Config` dataclass; :func:`load_default_config_dict`
16returns the packaged defaults as a plain ``dict``.
16"""17"""
1718
18from __future__ import annotations19from __future__ import annotations
1920
20import logging21import logging
22from collections.abc import Mapping, Sequence
21from pathlib import Path23from pathlib import Path
22from typing import Any24from typing import Any
2325
26import pydantic
24from iolabs.common import config_loader27from iolabs.common import config_loader
2528
26from .detector import Config29from .detector import Config
27from .features import FeatureWeights30from .features import FeatureWeights
Importance #26: src/iolabs_point_cloud_detection_asphaltedge/config.py @@ -29,171 +32,215 @@
29from .image_edge import ImageEdgeConfig32from .image_edge import ImageEdgeConfig
3033
31logger = logging.getLogger(__name__)34logger = logging.getLogger(__name__)
3235
33_PACKAGE = "iolabs_point_cloud_detection_asphaltedge"36_PACKAGE_NAME = "iolabs_point_cloud_detection_asphaltedge"
34_DEFAULT_CONFIG_FILENAME = "asphalt_edge.default.json"37_DEFAULT_FILENAME = "asphalt_edge.default.json"
35_CONTEXT = "asphalt edge config"38_CONTEXT = "asphalt edge config"
3639
3740
38class AsphaltEdgeConfigError(config_loader.ConfigError):41class AsphaltEdgeConfigError(config_loader.ConfigError):
39 """Raised when the asphalt edge config contains unsupported keys or values."""42 """Raised when asphalt edge config contains unsupported keys or values."""
43
44
45def _check_interval(name: str, interval: tuple[float, float]) -> None:
46 """Raise when a ``(low, high)`` interval is not strictly increasing."""
47 low, high = interval
48 if low >= high:
49 raise ValueError(f"{name} must be (low, high) with low < high, got ({low}, {high})")
4050
4151
42class FeatureWeightsConfig(config_loader.ConfigModel):52class AsphaltEdgeWeightsConfig(config_loader.ConfigModel):
43 """``weights`` block of ``asphalt_edge.default.json``."""53 """``weights`` block: cross-profile material-edge feature weights."""
4454
45 intensity_step: float = 2.555 intensity_step: float = pydantic.Field(default=2.5, ge=0.0)
46 density_drop: float = 2.056 density_drop: float = pydantic.Field(default=2.0, ge=0.0)
47 z_rise: float = 1.057 z_rise: float = pydantic.Field(default=1.0, ge=0.0)
48 roughness_rise: float = 0.858 roughness_rise: float = pydantic.Field(default=0.8, ge=0.0)
49 surface_notch: float = 1.559 surface_notch: float = pydantic.Field(default=1.5, ge=0.0)
50 dog_step: float = 1.060 dog_step: float = pydantic.Field(default=1.0, ge=0.0)
5161
5262
53class ImageEdgeConfigModel(config_loader.ConfigModel):63class AsphaltEdgeImageConfig(config_loader.ConfigModel):
54 """``image`` block plus the dataclass knobs accepted as overrides."""64 """``image`` block: tunables of the rectified-image edge extractor."""
5565
56 station_step: float = 0.2566 # rectification grid
57 offset_step: float = 0.0567 station_step: float = pydantic.Field(default=0.25, gt=0.0)
68 offset_step: float = pydantic.Field(default=0.05, gt=0.0)
58 offset_min: float = -18.069 offset_min: float = -18.0
59 offset_max: float = 18.070 offset_max: float = 18.0
71 # side search bands (signed offset metres; +left / -right)
60 left_band: tuple[float, float] = (1.0, 17.0)72 left_band: tuple[float, float] = (1.0, 17.0)
61 right_band: tuple[float, float] = (-17.0, -1.0)73 right_band: tuple[float, float] = (-17.0, -1.0)
62 inner_left_band: tuple[float, float] = (0.3, 6.0)74 inner_left_band: tuple[float, float] = (0.3, 6.0)
63 inner_right_band: tuple[float, float] = (-6.0, -0.3)75 inner_right_band: tuple[float, float] = (-6.0, -0.3)
64 inner_min_valid_frac: float = 0.276 # negative means "reuse min_valid_frac", so negatives stay legal here
65 inner_bound_margin_m: float = 1.2577 inner_min_valid_frac: float = pydantic.Field(default=0.2, le=1.0)
66 step_win_m: float = 1.278 inner_bound_margin_m: float = pydantic.Field(default=1.25, ge=0.0)
67 tex_win_m: float = 0.579 # step-filter windows (metres)
68 smooth_offset_m: float = 0.180 step_win_m: float = pydantic.Field(default=1.2, gt=0.0)
69 intensity_weight: float = 1.081 tex_win_m: float = pydantic.Field(default=0.5, gt=0.0)
70 texture_weight: float = 0.682 smooth_offset_m: float = pydantic.Field(default=0.1, ge=0.0)
71 density_weight: float = 0.083 # channel fusion
72 roughness_weight: float = 0.084 intensity_weight: float = pydantic.Field(default=1.0, ge=0.0)
73 z_step_weight: float = 0.085 texture_weight: float = pydantic.Field(default=0.6, ge=0.0)
74 zrange_weight: float = 1.586 density_weight: float = pydantic.Field(default=0.0, ge=0.0)
75 zrange_win_m: float = 0.487 roughness_weight: float = pydantic.Field(default=0.4, ge=0.0)
76 zrange_cap_m: float = 0.1588 z_step_weight: float = pydantic.Field(default=0.0, ge=0.0)
89 # z-range (max-min z per pixel) channel
90 zrange_weight: float = pydantic.Field(default=1.5, ge=0.0)
91 zrange_win_m: float = pydantic.Field(default=0.4, gt=0.0)
92 zrange_cap_m: float = pydantic.Field(default=0.15, gt=0.0)
77 zrange_snap: bool = True93 zrange_snap: bool = True
78 zrange_snap_window_m: float = 0.494 zrange_snap_window_m: float = pydantic.Field(default=0.4, ge=0.0)
79 zrange_snap_max_shift_m: float = 0.2595 zrange_snap_max_shift_m: float = pydantic.Field(default=0.25, ge=0.0)
80 zrange_snap_thresh_min_m: float = 0.01296 zrange_snap_thresh_min_m: float = pydantic.Field(default=0.012, ge=0.0)
81 zrange_snap_mad_k: float = 4.097 zrange_snap_mad_k: float = pydantic.Field(default=4.0, ge=0.0)
82 zrange_snap_rel_h: float = 0.1098 zrange_snap_rel_h: float = pydantic.Field(default=0.10, ge=0.0, le=1.0)
83 zrange_snap_regularize: bool = True99 zrange_snap_regularize: bool = True
84 zrange_snap_reg_max_dev_m: float = 0.30100 zrange_snap_reg_max_dev_m: float = pydantic.Field(default=0.30, ge=0.0)
101 # intensity-snap fallback
85 zrange_intensity_snap: bool = True102 zrange_intensity_snap: bool = True
86 zrange_isnap_grad_k: float = 3.0103 zrange_isnap_grad_k: float = pydantic.Field(default=3.0, ge=0.0)
87 zrange_isnap_min_step: float = 8.0104 zrange_isnap_min_step: float = pydantic.Field(default=8.0, ge=0.0)
88 zrange_isnap_paint_guard: bool = True105 zrange_isnap_paint_guard: bool = True
89 zrange_onset_bonus: float = 1.2106 # innermost-onset preference
107 zrange_onset_bonus: float = pydantic.Field(default=1.2, ge=0.0)
90 zrange_onset_smooth: bool = True108 zrange_onset_smooth: bool = True
91 zrange_guardrail_z_m: float = 0.30109 zrange_guardrail_z_m: float = pydantic.Field(default=0.30, ge=0.0)
92 zrange_guardrail_lookback_m: float = 2.0110 zrange_guardrail_lookback_m: float = pydantic.Field(default=2.0, ge=0.0)
111 # guardrail-repo hard veto
93 zrange_guardrail_veto: bool = False112 zrange_guardrail_veto: bool = False
94 zrange_guardrail_veto_conf: float = 0.7113 zrange_guardrail_veto_conf: float = pydantic.Field(default=0.7, ge=0.0, le=1.0)
95 zrange_guardrail_veto_margin_m: float = 0.5114 zrange_guardrail_veto_margin_m: float = pydantic.Field(default=0.5, ge=0.0)
115 # no Literal: the accepted instance types are the guardrail repo's
116 # vocabulary, carried in its JSON, not a choice this schema owns
96 zrange_guardrail_veto_types: tuple[str, ...] = ("w_beam",)117 zrange_guardrail_veto_types: tuple[str, ...] = ("w_beam",)
97 zrange_guardrail_veto_walls: bool = False118 zrange_guardrail_veto_walls: bool = False
98 zrange_resume_weight: float = 3.0119 # asphalt-resumes-outward penalty
120 zrange_resume_weight: float = pydantic.Field(default=3.0, ge=0.0)
99 zrange_resume_zone_m: tuple[float, float] = (0.5, 2.5)121 zrange_resume_zone_m: tuple[float, float] = (0.5, 2.5)
100 zrange_flat_thresh_m: float = 0.015122 zrange_flat_thresh_m: float = pydantic.Field(default=0.015, ge=0.0)
101 zrange_resume_tex_k: float = 1.5123 zrange_resume_tex_k: float = pydantic.Field(default=1.5, ge=0.0)
124 # shoulder rule
102 zrange_shoulder_rule: bool = True125 zrange_shoulder_rule: bool = True
103 zrange_shoulder_lookback_m: float = 2.0126 zrange_shoulder_lookback_m: float = pydantic.Field(default=2.0, ge=0.0)
104 zrange_shoulder_min_gap_m: float = 0.3127 zrange_shoulder_min_gap_m: float = pydantic.Field(default=0.3, ge=0.0)
105 zrange_shoulder_corridor_anchor: bool = True128 zrange_shoulder_corridor_anchor: bool = True
106 zrange_shoulder_sustain_m: float = 1.5129 zrange_shoulder_sustain_m: float = pydantic.Field(default=1.5, ge=0.0)
107 zrange_shoulder_max_search_m: float = 8.0130 zrange_shoulder_max_search_m: float = pydantic.Field(default=8.0, ge=0.0)
108 zrange_shoulder_grad_k: float = 2.0131 zrange_shoulder_grad_k: float = pydantic.Field(default=2.0, ge=0.0)
109 zrange_shoulder_min_step: float = 6.0132 zrange_shoulder_min_step: float = pydantic.Field(default=6.0, ge=0.0)
110 zrange_shoulder_min_width_m: float = 3.0133 zrange_shoulder_min_width_m: float = pydantic.Field(default=3.0, ge=0.0)
111 zrange_shoulder_support_m: float = 1.5134 zrange_shoulder_support_m: float = pydantic.Field(default=1.5, ge=0.0)
112 zrange_shoulder_support_tol_m: float = 0.6135 zrange_shoulder_support_tol_m: float = pydantic.Field(default=0.6, ge=0.0)
113 zrange_shoulder_support_frac: float = 0.5136 zrange_shoulder_support_frac: float = pydantic.Field(default=0.5, ge=0.0, le=1.0)
114 zrange_shoulder_consolidate: bool = True137 zrange_shoulder_consolidate: bool = True
115 zrange_shoulder_revert_isolated: bool = True138 zrange_shoulder_revert_isolated: bool = True
116 zrange_shoulder_run_smoothness_guard: bool = False139 zrange_shoulder_run_smoothness_guard: bool = False
117 zrange_shoulder_run_max_step_m: float = 0.046140 zrange_shoulder_run_max_step_m: float = pydantic.Field(default=0.046, ge=0.0)
141 # paint veto
118 zrange_paint_veto: bool = True142 zrange_paint_veto: bool = True
119 zrange_paint_quantile: float = 0.92143 zrange_paint_quantile: float = pydantic.Field(default=0.92, gt=0.0, lt=1.0)
120 zrange_paint_max_width_m: float = 0.5144 zrange_paint_max_width_m: float = pydantic.Field(default=0.5, ge=0.0)
121 zrange_paint_corridor_margin_m: float = 1.5145 zrange_paint_corridor_margin_m: float = pydantic.Field(default=1.5, ge=0.0)
146 # DP inference
122 robust: bool = True147 robust: bool = True
123 edge_smooth_window_m: float = 2.0148 edge_smooth_window_m: float = pydantic.Field(default=2.0, ge=0.0)
124 slope_cheap_m: float = 0.15149 slope_cheap_m: float = pydantic.Field(default=0.15, ge=0.0)
125 trans_lin: float = 9.0150 trans_lin: float = pydantic.Field(default=9.0, ge=0.0)
126 trans_quad: float = 220.0151 trans_quad: float = pydantic.Field(default=220.0, ge=0.0)
127 max_jump_m: float = 0.9152 max_jump_m: float = pydantic.Field(default=0.9, ge=0.0)
128 bridge_stiffness: float = 3.0153 bridge_stiffness: float = pydantic.Field(default=3.0, ge=0.0)
129 bridge_anchor_min_conf: float = 0.75154 bridge_anchor_min_conf: float = pydantic.Field(default=0.75, ge=0.0, le=1.0)
130 invalid_penalty: float = 2.5155 invalid_penalty: float = pydantic.Field(default=2.5, ge=0.0)
131 min_valid_frac: float = 0.35156 min_valid_frac: float = pydantic.Field(default=0.35, ge=0.0, le=1.0)
132 conf_gain: float = 0.7157 conf_gain: float = pydantic.Field(default=0.7, ge=0.0)
133 outer_bound_margin_m: float = 1.5158 outer_bound_margin_m: float = pydantic.Field(default=1.5, ge=0.0)
134 outer_median_bound: bool = False159 outer_median_bound: bool = False
135 outer_median_bound_margin_m: float = 0.5160 outer_median_bound_margin_m: float = pydantic.Field(default=0.5, ge=0.0)
136 outer_median_bound_hard: bool = False161 outer_median_bound_hard: bool = False
162 # vehicle mask
137 vehicle_mask: bool = True163 vehicle_mask: bool = True
138 vehicle_z_thresh_m: float = 0.02164 vehicle_z_thresh_m: float = pydantic.Field(default=0.02, ge=0.0)
139 vehicle_corridor_margin_m: float = 0.5165 vehicle_corridor_margin_m: float = pydantic.Field(default=0.5, ge=0.0)
140 vehicle_mask_z_only: bool = False166 vehicle_mask_z_only: bool = False
141 vehicle_mask_neutral: bool = True167 vehicle_mask_neutral: bool = True
142 vehicle_mask_seed_guard: bool = False168 vehicle_mask_seed_guard: bool = False
143 vehicle_mask_baseline_fill: bool = False169 vehicle_mask_baseline_fill: bool = False
144 edge_support_gate: bool = False170 edge_support_gate: bool = False
171 # CRF inference
145 use_crf: bool = False172 use_crf: bool = False
146 crf_second_order: bool = True173 crf_second_order: bool = True
147 crf_lambda_parallel: float = 4.0174 crf_lambda_parallel: float = pydantic.Field(default=4.0, ge=0.0)
148 crf_lambda_curvature: float = 8.0175 crf_lambda_curvature: float = pydantic.Field(default=8.0, ge=0.0)
149 crf_lambda_prior: float = 1.5176 crf_lambda_prior: float = pydantic.Field(default=1.5, ge=0.0)
150 crf_huber_delta_m: float = 0.10177 crf_huber_delta_m: float = pydantic.Field(default=0.10, gt=0.0)
151 crf_delta_cap_m: float = 0.6178 crf_delta_cap_m: float = pydantic.Field(default=0.6, ge=0.0)
152 crf_conf_ref: float = 1.0179 crf_conf_ref: float = pydantic.Field(default=1.0, gt=0.0)
153 crf_not_observable_support: float = 0.15180 crf_not_observable_support: float = pydantic.Field(default=0.15, ge=0.0)
154 crf_support_intensity_ref_dn: float = 40.0181 crf_support_intensity_ref_dn: float = pydantic.Field(default=40.0, gt=0.0)
155 crf_support_zrange_ref_m: float = 0.05182 crf_support_zrange_ref_m: float = pydantic.Field(default=0.05, gt=0.0)
156183
184 @pydantic.model_validator(mode="after")
185 def _check_intervals(self) -> AsphaltEdgeImageConfig:
186 """Every ``(low, high)`` interval field must be ordered."""
187 _check_interval("offset_min/offset_max", (self.offset_min, self.offset_max))
188 for name in (
189 "left_band",
190 "right_band",
191 "inner_left_band",
192 "inner_right_band",
193 "zrange_resume_zone_m",
194 ):
195 _check_interval(name, getattr(self, name))
196 return self
157197
158class GutterConfigModel(config_loader.ConfigModel):198
159 """``gutter`` block of ``asphalt_edge.default.json``."""199class AsphaltEdgeGutterConfig(config_loader.ConfigModel):
200 """``gutter`` block: thresholds of the gutter-lip cross-section scan."""
160201
161 enabled: bool = True202 enabled: bool = True
162 station_step: float = 1.0203 station_step: float = pydantic.Field(default=1.0, gt=0.0)
163 half_window_m: float = 0.75204 half_window_m: float = pydantic.Field(default=0.75, gt=0.0)
164 step_thr: float = 4000.0205 step_thr: float = pydantic.Field(default=4000.0, ge=0.0)
165 rough_mult: float = 2.5206 rough_mult: float = pydantic.Field(default=2.5, ge=0.0)
166 max_gutter_m: float = 1.5207 max_gutter_m: float = pydantic.Field(default=1.5, gt=0.0)
167 min_conf: float = 0.35208 min_conf: float = pydantic.Field(default=0.35, ge=0.0, le=1.0)
168209
169210
170class AsphaltEdgeConfig(config_loader.ConfigModel):211class AsphaltEdgeConfig(config_loader.ConfigModel):
171 """Root schema mirroring ``asphalt_edge.default.json``."""212 """Root schema mirroring ``asphalt_edge.default.json``."""
172213
173 station_step: float = 0.5214 station_step: float = pydantic.Field(default=0.5, gt=0.0)
174 half_thickness: float = 0.5215 half_thickness: float = pydantic.Field(default=0.5, gt=0.0)
175 bin_width: float = 0.1216 bin_width: float = pydantic.Field(default=0.1, gt=0.0)
176 off_range: tuple[float, float] = (-15.0, 15.0)217 off_range: tuple[float, float] = (-20.0, 20.0)
177 search_in: float = 4.5218 search_in: float = pydantic.Field(default=4.5, ge=0.0)
178 search_out: float = 0.3219 search_out: float = pydantic.Field(default=0.3, ge=0.0)
179 band_m: float = 1.0220 band_m: float = pydantic.Field(default=1.0, gt=0.0)
180 weights: FeatureWeightsConfig = FeatureWeightsConfig()221 weights: AsphaltEdgeWeightsConfig = AsphaltEdgeWeightsConfig()
181 image: ImageEdgeConfigModel = ImageEdgeConfigModel()222 image: AsphaltEdgeImageConfig = AsphaltEdgeImageConfig()
182 gutter: GutterConfigModel = GutterConfigModel()223 gutter: AsphaltEdgeGutterConfig = AsphaltEdgeGutterConfig()
183 min_confidence: float = 0.25224 min_confidence: float = pydantic.Field(default=0.25, ge=0.0, le=1.0)
184 median_window_m: float = 15.0225 median_window_m: float = pydantic.Field(default=15.0, ge=0.0)
185 mad_k: float = 3.5226 mad_k: float = pydantic.Field(default=3.0, gt=0.0)
186 max_interp_gap_m: float = 5.0227 max_interp_gap_m: float = pydantic.Field(default=5.0, ge=0.0)
187 max_interp_offset_jump_m: float = 0.5228 max_interp_offset_jump_m: float = pydantic.Field(default=0.5, ge=0.0)
188 max_offset_rate: float = 0.6229 max_offset_rate: float = pydantic.Field(default=0.6, ge=0.0)
189 min_run_m: float = 3.0230 min_run_m: float = pydantic.Field(default=3.0, ge=0.0)
190 end_stub_m: float = 5.0231 end_stub_m: float = pydantic.Field(default=5.0, ge=0.0)
191 savgol_window_m: float = 11.0232 savgol_window_m: float = pydantic.Field(default=11.0, ge=0.0)
192 savgol_order: int = 2233 savgol_order: int = pydantic.Field(default=2, ge=1)
193 max_points: int = 7_000_000234 max_points: int = pydantic.Field(default=7_000_000, gt=0)
194 hash_round_units_per_m: float = 1000.0235 hash_round_units_per_m: float = pydantic.Field(default=1000.0, gt=0.0)
195 min_carriageway_m: float = 2.0236 min_carriageway_m: float = pydantic.Field(default=2.0, ge=0.0)
237
238 @pydantic.model_validator(mode="after")
239 def _check_off_range(self) -> AsphaltEdgeConfig:
240 """``off_range`` must be ordered."""
241 _check_interval("off_range", self.off_range)
242 return self
196243
197244
198def _to_runtime(model: AsphaltEdgeConfig) -> Config:245def _to_runtime(model: AsphaltEdgeConfig) -> Config:
199 """Copy a validated model onto the mutable runtime dataclasses."""246 """Copy a validated model onto the mutable runtime dataclasses."""
Importance #27: src/iolabs_point_cloud_detection_asphaltedge/config.py @@ -205,10 +252,41 @@
205 **data,252 **data,
206 )253 )
207254
208255
209def config_from_dict(raw_config: dict[str, Any]) -> Config:256def _load_model(
210 """Build a typed :class:`Config` from a (possibly partial) mapping."""257 *,
258 overrides: Mapping[str, Any] | None = None,
259 config_path: str | Path | None = None,
260) -> AsphaltEdgeConfig:
261 """Load the packaged defaults (or ``config_path``) and apply ``overrides``."""
262 model = config_loader.load_config(
263 AsphaltEdgeConfig,
264 package=_PACKAGE_NAME,
265 filename=_DEFAULT_FILENAME,
266 overrides=overrides,
267 config_path=config_path,
268 context=_CONTEXT,
269 error_cls=AsphaltEdgeConfigError,
270 )
271 if config_path is not None:
272 logger.info("Config file applied: %s", config_path)
273 if overrides:
274 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
275 return model
276
277
278def load_default_config_dict() -> dict[str, Any]:
279 """Return the packaged default config as a plain nested ``dict``."""
280 return _load_model().model_dump()
281
282
283def config_from_dict(raw_config: Mapping[str, Any]) -> Config:
284 """Build a typed :class:`~.detector.Config` from a (possibly partial) mapping.
285
286 Keys the mapping omits fall back to the model defaults, which are identical
287 to the packaged JSON.
288 """
211 model = config_loader.validate_config(289 model = config_loader.validate_config(
212 AsphaltEdgeConfig,290 AsphaltEdgeConfig,
213 raw_config,291 raw_config,
214 context=_CONTEXT,292 context=_CONTEXT,
Importance #28: src/iolabs_point_cloud_detection_asphaltedge/config.py @@ -219,19 +297,40 @@
219297
220def load_asphalt_edge_config(298def load_asphalt_edge_config(
221 config_path: str | Path | None = None,299 config_path: str | Path | None = None,
222 *,300 *,
223 overrides: dict[str, Any] | None = None,301 overrides: Mapping[str, Any] | None = None,
224) -> Config:302) -> Config:
225 """Load the packaged default config, apply optional overrides, return a Config."""303 """Load the packaged default config, apply optional overrides, return a Config.
226 model = config_loader.load_config(304
227 AsphaltEdgeConfig,305 ``config_path`` REPLACES the packaged defaults; keys it omits fall back to
228 package=_PACKAGE,306 the model defaults.
229 filename=_DEFAULT_CONFIG_FILENAME,307 """
230 overrides=overrides,308 return _to_runtime(_load_model(overrides=overrides, config_path=config_path))
231 config_path=config_path,309
232 context=_CONTEXT,310
233 error_cls=AsphaltEdgeConfigError,311def parse_set_overrides(raw_overrides: Sequence[str] | None) -> dict[str, Any]:
312 """Parse repeatable ``--set KEY=VALUE`` strings into an override mapping.
313
314 Thin binding of :func:`iolabs.common.config_loader.parse_set_overrides` to
315 the asphalt edge error class. This config has both top-level keys and
316 sections, so both ``--set station_step=0.5`` and ``--set image.robust=false``
317 are accepted; dotted keys expand into nested mappings.
318
319 Args:
320 raw_overrides: Raw ``KEY=VALUE`` strings from ``--set``, or ``None``.
321
322 Returns:
323 A mapping ready to pass as ``overrides``; later duplicates win.
324
325 Raises:
326 AsphaltEdgeConfigError: An override is missing its ``=``, or two
327 overrides disagree about whether a path segment is a section.
328 """
329 dotted = [item for item in raw_overrides or [] if "." in item.split("=", 1)[0]]
330 flat = [item for item in raw_overrides or [] if "." not in item.split("=", 1)[0]]
331 return config_loader.deep_merge_dicts(
332 config_loader.parse_set_overrides(flat, error_cls=AsphaltEdgeConfigError),
333 config_loader.parse_set_overrides(
334 dotted, nested=True, error_cls=AsphaltEdgeConfigError
335 ),
234 )336 )
235 if overrides:
236 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
237 return _to_runtime(model)
Importance #29: src/iolabs_point_cloud_detection_asphaltedge/detector.py @@ -37,17 +37,16 @@
3737
38 Defaults of a loaded config come from ``asphalt_edge.default.json`` via38 Defaults of a loaded config come from ``asphalt_edge.default.json`` via
39 :func:`.config_from_dict`. Nested rectified-image, gutter and weight39 :func:`.config_from_dict`. Nested rectified-image, gutter and weight
40 blocks live in their own dataclasses reachable from here. To add a key,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``41 add the field here *and* to `AsphaltEdgeConfig` in ``config.py`` *and* to
42 with the same name, type and default; the packaged JSON only needs it when42 the packaged JSON, with the same name, type and default.
43 the shipped default differs.
44 """43 """
4544
46 station_step: float = 0.545 station_step: float = 0.5
47 half_thickness: float = 0.546 half_thickness: float = 0.5
48 bin_width: float = 0.147 bin_width: float = 0.1
49 off_range: tuple[float, float] = (-15.0, 15.0)48 off_range: tuple[float, float] = (-20.0, 20.0)
50 # Search band relative to the surface-extent outer bound: the material edge49 # Search band relative to the surface-extent outer bound: the material edge
51 # lies up to ``search_in`` metres inside the data extent (dark shoulder +50 # lies up to ``search_in`` metres inside the data extent (dark shoulder +
52 # bright verge), with a small ``search_out`` slack for prior jitter.51 # bright verge), with a small ``search_out`` slack for prior jitter.
53 # (Fallback point scorer, used only when no tile is supplied.)52 # (Fallback point scorer, used only when no tile is supplied.)
Importance #30: src/iolabs_point_cloud_detection_asphaltedge/detector.py @@ -62,9 +61,9 @@
62 # this dataset (no confirmed concrete gutter); never drives the edge.61 # this dataset (no confirmed concrete gutter); never drives the edge.
63 gutter: GutterConfig = field(default_factory=GutterConfig)62 gutter: GutterConfig = field(default_factory=GutterConfig)
64 min_confidence: float = 0.2563 min_confidence: float = 0.25
65 median_window_m: float = 15.064 median_window_m: float = 15.0
66 mad_k: float = 3.565 mad_k: float = 3.0
67 max_interp_gap_m: float = 5.066 max_interp_gap_m: float = 5.0
68 max_interp_offset_jump_m: float = 0.567 max_interp_offset_jump_m: float = 0.5
69 max_offset_rate: float = 0.668 max_offset_rate: float = 0.6
70 min_run_m: float = 3.069 min_run_m: float = 3.0
Importance #31: src/iolabs_point_cloud_detection_asphaltedge/image_edge.py @@ -98,9 +98,9 @@
98 # density/roughness change and a detrended-z kink rather than a bright verge.98 # density/roughness change and a detrended-z kink rather than a bright verge.
99 intensity_weight: float = 1.099 intensity_weight: float = 1.0
100 texture_weight: float = 0.6100 texture_weight: float = 0.6
101 density_weight: float = 0.0 # point density drop (asphalt dense -> sparse)101 density_weight: float = 0.0 # point density drop (asphalt dense -> sparse)
102 roughness_weight: float = 0.0 # outward rise in per-cell z-roughness102 roughness_weight: float = 0.4 # outward rise in per-cell z-roughness
103 z_step_weight: float = 0.0 # outward rise in per-cell mean elevation (kink)103 z_step_weight: float = 0.0 # outward rise in per-cell mean elevation (kink)
104 # z-range (maxโˆ’min z per pixel) channel: sharp pavement-edge ridge.104 # z-range (maxโˆ’min z per pixel) channel: sharp pavement-edge ridge.
105 zrange_weight: float = 1.5105 zrange_weight: float = 1.5
106 zrange_win_m: float = 0.4 # outward-step window; z transition is sharp106 zrange_win_m: float = 0.4 # outward-step window; z transition is sharp
Importance #32: tests/test_config.py @@ -2,8 +2,9 @@
22
3from __future__ import annotations3from __future__ import annotations
44
5import dataclasses5import dataclasses
6import json
6import typing7import typing
7from pathlib import Path8from pathlib import Path
89
9import pytest10import pytest
Importance #33: tests/test_config.py @@ -11,43 +12,65 @@
1112
12from iolabs_point_cloud_detection_asphaltedge.config import (13from iolabs_point_cloud_detection_asphaltedge.config import (
13 AsphaltEdgeConfig,14 AsphaltEdgeConfig,
14 AsphaltEdgeConfigError,15 AsphaltEdgeConfigError,
15 FeatureWeightsConfig,16 AsphaltEdgeGutterConfig,
16 GutterConfigModel,17 AsphaltEdgeImageConfig,
17 ImageEdgeConfigModel,18 AsphaltEdgeWeightsConfig,
18 config_from_dict,19 config_from_dict,
19 load_asphalt_edge_config,20 load_asphalt_edge_config,
21 load_default_config_dict,
22 parse_set_overrides,
20)23)
21from iolabs_point_cloud_detection_asphaltedge.detector import Config24from iolabs_point_cloud_detection_asphaltedge.detector import Config
22from iolabs_point_cloud_detection_asphaltedge.features import FeatureWeights25from iolabs_point_cloud_detection_asphaltedge.features import FeatureWeights
23from iolabs_point_cloud_detection_asphaltedge.gutter import GutterConfig26from iolabs_point_cloud_detection_asphaltedge.gutter import GutterConfig
24from iolabs_point_cloud_detection_asphaltedge.image_edge import ImageEdgeConfig27from iolabs_point_cloud_detection_asphaltedge.image_edge import ImageEdgeConfig
2528
2629
27def test_error_class_is_config_error_and_value_error() -> None:30def _packaged_json() -> dict:
28 assert issubclass(AsphaltEdgeConfigError, config_loader.ConfigError)31 path = config_loader.default_config_path(
29 assert issubclass(AsphaltEdgeConfigError, ValueError)32 "iolabs_point_cloud_detection_asphaltedge", "asphalt_edge.default.json"
33 )
34 return json.loads(path.read_text(encoding="utf-8"))
35
3036
37def _as_json(config: dict) -> dict:
38 """Normalise a ``model_dump`` (tuples) to JSON types (lists)."""
39 return json.loads(json.dumps(config))
3140
32def test_load_packaged_defaults_with_zero_overrides() -> None:41
42def test_model_defaults_match_packaged_json() -> None:
43 assert _as_json(AsphaltEdgeConfig().model_dump()) == _packaged_json()
44
45
46def test_load_asphalt_edge_config_returns_packaged_defaults() -> None:
33 cfg = load_asphalt_edge_config()47 cfg = load_asphalt_edge_config()
34 assert isinstance(cfg, Config)48 assert isinstance(cfg, Config)
49 assert dataclasses.asdict(cfg) == AsphaltEdgeConfig().model_dump()
35 assert cfg.station_step == 0.550 assert cfg.station_step == 0.5
36 assert cfg.off_range == (-20.0, 20.0)51 assert cfg.off_range == (-20.0, 20.0)
37 assert cfg.mad_k == 3.052 assert cfg.mad_k == 3.0
38 assert cfg.weights.intensity_step == 2.553 assert cfg.weights.intensity_step == 2.5
39 assert cfg.image.left_band == (1.0, 17.0)54 assert cfg.image.left_band == (1.0, 17.0)
40 assert cfg.image.roughness_weight == 0.455 assert cfg.image.roughness_weight == 0.4
56 assert cfg.image.zrange_weight == 1.5
41 assert cfg.image.robust is True57 assert cfg.image.robust is True
42 assert cfg.gutter.enabled is True58 assert cfg.gutter.enabled is True
43 assert cfg.gutter.step_thr == 4000.059 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_confidence60 assert cfg.polyline.min_confidence == cfg.min_confidence
4761
4862
49def test_overrides_deep_merge_nested_sections() -> None:63def test_load_default_config_dict_matches_packaged_json() -> None:
64 assert _as_json(load_default_config_dict()) == _packaged_json()
65
66
67def test_error_class_is_config_error() -> None:
68 assert issubclass(AsphaltEdgeConfigError, config_loader.ConfigError)
69 assert issubclass(AsphaltEdgeConfigError, ValueError)
70
71
72def test_overrides_deep_merge_onto_defaults() -> None:
50 cfg = load_asphalt_edge_config(73 cfg = load_asphalt_edge_config(
51 overrides={74 overrides={
52 "min_confidence": 0.4,75 "min_confidence": 0.4,
53 "weights": {"z_rise": 9.0},76 "weights": {"z_rise": 9.0},
Importance #34: tests/test_config.py @@ -64,37 +87,48 @@
64 assert cfg.gutter.enabled is False87 assert cfg.gutter.enabled is False
65 assert cfg.gutter.min_conf == 0.3588 assert cfg.gutter.min_conf == 0.35
6689
6790
68def test_unknown_top_level_key_rejected() -> None:91def test_unknown_top_level_key_is_rejected() -> None:
69 with pytest.raises(AsphaltEdgeConfigError, match="not_a_key") as excinfo:92 with pytest.raises(AsphaltEdgeConfigError, match="not_a_key") as excinfo:
70 load_asphalt_edge_config(overrides={"not_a_key": 1})93 load_asphalt_edge_config(overrides={"not_a_key": 1})
71 message = str(excinfo.value)94 message = str(excinfo.value)
72 assert "Unknown" in message95 assert "Unknown" in message
73 assert "station_step" in message96 assert "station_step" in message
7497
7598
76def test_unknown_nested_key_rejected() -> None:99def test_unknown_nested_key_is_rejected() -> None:
77 with pytest.raises(AsphaltEdgeConfigError, match="typo_band") as excinfo:100 with pytest.raises(AsphaltEdgeConfigError, match="typo_band") as excinfo:
78 load_asphalt_edge_config(overrides={"image": {"typo_band": [0.0, 1.0]}})101 load_asphalt_edge_config(overrides={"image": {"typo_band": [0.0, 1.0]}})
79 message = str(excinfo.value)102 message = str(excinfo.value)
80 assert "Unknown" in message103 assert "Unknown" in message
81 assert "left_band" in message104 assert "left_band" in message
82105
83106
107def test_set_override_coercion_and_rejection() -> None:
108 overrides = parse_set_overrides(
109 ["station_step=1e3", "image.robust=on", "gutter.min_conf=0.5"]
110 )
111 assert overrides == {
112 "station_step": 1000.0,
113 "image": {"robust": "on"},
114 "gutter": {"min_conf": 0.5},
115 }
116 cfg = load_asphalt_edge_config(overrides=overrides)
117 assert cfg.station_step == 1000.0
118 assert cfg.image.robust is True
119 assert cfg.gutter.min_conf == 0.5
120 with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"):
121 load_asphalt_edge_config(overrides=parse_set_overrides(["image.robust=flase"]))
122 with pytest.raises(AsphaltEdgeConfigError, match="Expected"):
123 parse_set_overrides(["station_step"])
124
125
84def test_config_from_dict_unknown_key_rejected() -> None:126def test_config_from_dict_unknown_key_rejected() -> None:
85 with pytest.raises(AsphaltEdgeConfigError, match="bogus"):127 with pytest.raises(AsphaltEdgeConfigError, match="bogus"):
86 config_from_dict({"station_step": 0.5, "bogus": 1})128 config_from_dict({"station_step": 0.5, "bogus": 1})
87129
88130
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:131def test_invalid_bool_token_rejected() -> None:
98 with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"):132 with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"):
99 load_asphalt_edge_config(overrides={"gutter": {"enabled": "flase"}})133 load_asphalt_edge_config(overrides={"gutter": {"enabled": "flase"}})
100134
Importance #35: tests/test_config.py @@ -103,8 +137,24 @@
103 with pytest.raises(AsphaltEdgeConfigError, match="Invalid float"):137 with pytest.raises(AsphaltEdgeConfigError, match="Invalid float"):
104 load_asphalt_edge_config(overrides={"station_step": "abc"})138 load_asphalt_edge_config(overrides={"station_step": "abc"})
105139
106140
141def test_out_of_range_value_rejected() -> None:
142 with pytest.raises(AsphaltEdgeConfigError, match="station_step"):
143 load_asphalt_edge_config(overrides={"station_step": 0.0})
144 with pytest.raises(AsphaltEdgeConfigError, match="min_confidence"):
145 load_asphalt_edge_config(overrides={"min_confidence": 1.5})
146 with pytest.raises(AsphaltEdgeConfigError, match="roughness_weight"):
147 load_asphalt_edge_config(overrides={"image": {"roughness_weight": -1.0}})
148
149
150def test_unordered_interval_rejected() -> None:
151 with pytest.raises(AsphaltEdgeConfigError, match="off_range"):
152 config_from_dict({"off_range": [5.0, -5.0]})
153 with pytest.raises(AsphaltEdgeConfigError, match="left_band"):
154 config_from_dict({"image": {"left_band": [17.0, 1.0]}})
155
156
107def test_off_range_list_becomes_float_tuple() -> None:157def test_off_range_list_becomes_float_tuple() -> None:
108 cfg = config_from_dict({"off_range": ["-3", "4"]})158 cfg = config_from_dict({"off_range": ["-3", "4"]})
109 assert cfg.off_range == (-3.0, 4.0)159 assert cfg.off_range == (-3.0, 4.0)
110160
Importance #36: tests/test_config.py @@ -120,11 +170,11 @@
120 assert isinstance(cfg.weights, FeatureWeights)170 assert isinstance(cfg.weights, FeatureWeights)
121 assert isinstance(cfg.image, ImageEdgeConfig)171 assert isinstance(cfg.image, ImageEdgeConfig)
122 assert isinstance(cfg.gutter, GutterConfig)172 assert isinstance(cfg.gutter, GutterConfig)
123 assert cfg.min_confidence == 0.1173 assert cfg.min_confidence == 0.1
124 # Partial mappings keep dataclass defaults for omitted keys.174 # Partial mappings keep the shipped defaults for omitted keys.
125 assert cfg.off_range == (-15.0, 15.0)175 assert cfg.off_range == (-20.0, 20.0)
126 assert cfg.image.roughness_weight == 0.0176 assert cfg.image.roughness_weight == 0.4
127177
128178
129def test_load_from_config_path(tmp_path: Path) -> None:179def test_load_from_config_path(tmp_path: Path) -> None:
130 path = tmp_path / "custom.json"180 path = tmp_path / "custom.json"
Importance #37: tests/test_config.py @@ -143,11 +193,11 @@
143@pytest.mark.parametrize(193@pytest.mark.parametrize(
144 ("dataclass_cls", "model_cls"),194 ("dataclass_cls", "model_cls"),
145 [195 [
146 (Config, AsphaltEdgeConfig),196 (Config, AsphaltEdgeConfig),
147 (FeatureWeights, FeatureWeightsConfig),197 (FeatureWeights, AsphaltEdgeWeightsConfig),
148 (ImageEdgeConfig, ImageEdgeConfigModel),198 (ImageEdgeConfig, AsphaltEdgeImageConfig),
149 (GutterConfig, GutterConfigModel),199 (GutterConfig, AsphaltEdgeGutterConfig),
150 ],200 ],
151)201)
152def test_model_mirrors_runtime_dataclass(dataclass_cls: type, model_cls: type) -> None:202def 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."""203 """Every model field must exist on the dataclass with the same type and default."""