Back to report index

verticalsigns b2c4267: AI3D-379 Review fixes: JSON-faithful config dump, packaged JSON completed to the model, validating with_overrides, docs

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

Commit #70 ยท 31 snippets

 README.md                                          |  26 +-
 .../_config.py                                     |   5 +-
 .../_config_model.py                               |   5 +-
 .../config.py                                      |  13 +-
 .../verticalsigns.default.json                     | 330 +++++++++++++++------
 tests/conftest.py                                  |   3 +
 tests/test_chroma_vegetation.py                    |   2 +-
 tests/test_config_split.py                         |  18 +-
 8 files changed, 286 insertions(+), 116 deletions(-)
Importance #1: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -107,25 +107,28 @@
107 def with_overrides(self, **overrides: Any) -> "DetectorConfig":107 def with_overrides(self, **overrides: Any) -> "DetectorConfig":
108 """Return a copy of this config with *overrides* applied.108 """Return a copy of this config with *overrides* applied.
109109
110 ``model_copy(update=...)`` skips validation, so a misspelled name would110 ``model_copy(update=...)`` skips validation, so a misspelled name would
111 be attached as a new attribute and the intended threshold would keep111 be attached as a new attribute and a wrongly typed value would be
112 its default. The names are therefore checked here, reproducing the112 stored uncoerced. The names are checked here and the values are run
113 ``TypeError`` that ``dataclasses.replace`` used to raise.113 through the model, so this validates where ``dataclasses.replace``
114 merely type-checked the call.
114115
115 Args:116 Args:
116 overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``.117 overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``.
117118
118 Returns:119 Returns:
119 A new frozen config carrying *overrides*.120 A new frozen config carrying *overrides*.
120121
121 Raises:122 Raises:
122 ValueError: An override names a field this config does not declare.123 ValueError: An override names a field this config does not declare,
124 or carries a value the field rejects (a
125 ``pydantic.ValidationError``, itself a ``ValueError``).
123 """126 """
124 unknown = sorted(set(overrides) - set(type(self).model_fields))127 unknown = sorted(set(overrides) - set(type(self).model_fields))
125 if unknown:128 if unknown:
126 raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}")129 raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}")
127 return self.model_copy(update=overrides)130 return type(self).model_validate({**self.model_dump(), **overrides})
128131
129 @classmethod132 @classmethod
130 def load(cls, config_path: str | Path | None = None) -> "DetectorConfig":133 def load(cls, config_path: str | Path | None = None) -> "DetectorConfig":
131 """Load config from the packaged defaults merged with an optional user JSON."""134 """Load config from the packaged defaults merged with an optional user JSON."""
Importance #2: src/iolabs_point_cloud_detection_verticalsigns/_config.py @@ -61,9 +62,9 @@
61 overrides=overrides,62 overrides=overrides,
62 context=_CONTEXT,63 context=_CONTEXT,
63 error_cls=VerticalSignsConfigError,64 error_cls=VerticalSignsConfigError,
64 )65 )
65 return config.model_dump()66 return config.model_dump(mode="json")
6667
6768
68def _read_user_config(config_path: str | Path) -> dict[str, Any]:69def _read_user_config(config_path: str | Path) -> dict[str, Any]:
69 """Read a user config JSON, wrapping decode errors in the package error."""70 """Read a user config JSON, wrapping decode errors in the package error."""
Importance #3: src/iolabs_point_cloud_detection_verticalsigns/_config_model.py @@ -2,9 +2,12 @@
22
3``VerticalSignsConfig`` mirrors ``verticalsigns.default.json`` section for3``VerticalSignsConfig`` mirrors ``verticalsigns.default.json`` section for
4section and key for key: it is the single source of truth for which config4section and key for key: it is the single source of truth for which config
5keys exist and what type each one has. Adding a key means adding a field to5keys exist and what type each one has. Adding a key means adding a field to
6the matching section model and a default to the packaged JSON.6the matching section model and the same default to the packaged JSON; the two
7sides must stay in lockstep, and ``tests/test_config_split.py`` fails if they
8drift. A key the detector modules read also needs its flat ``DetectorConfig``
9field and the ``*_kwargs`` line that maps it (see ``config.py``).
7"""10"""
811
9from iolabs.common import config_loader12from iolabs.common import config_loader
1013
Importance #4: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -107,25 +107,28 @@
107 def with_overrides(self, **overrides: Any) -> "DetectorConfig":107 def with_overrides(self, **overrides: Any) -> "DetectorConfig":
108 """Return a copy of this config with *overrides* applied.108 """Return a copy of this config with *overrides* applied.
109109
110 ``model_copy(update=...)`` skips validation, so a misspelled name would110 ``model_copy(update=...)`` skips validation, so a misspelled name would
111 be attached as a new attribute and the intended threshold would keep111 be attached as a new attribute and a wrongly typed value would be
112 its default. The names are therefore checked here, reproducing the112 stored uncoerced. The names are checked here and the values are run
113 ``TypeError`` that ``dataclasses.replace`` used to raise.113 through the model, so this validates where ``dataclasses.replace``
114 merely type-checked the call.
114115
115 Args:116 Args:
116 overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``.117 overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``.
117118
118 Returns:119 Returns:
119 A new frozen config carrying *overrides*.120 A new frozen config carrying *overrides*.
120121
121 Raises:122 Raises:
122 ValueError: An override names a field this config does not declare.123 ValueError: An override names a field this config does not declare,
124 or carries a value the field rejects (a
125 ``pydantic.ValidationError``, itself a ``ValueError``).
123 """126 """
124 unknown = sorted(set(overrides) - set(type(self).model_fields))127 unknown = sorted(set(overrides) - set(type(self).model_fields))
125 if unknown:128 if unknown:
126 raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}")129 raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}")
127 return self.model_copy(update=overrides)130 return type(self).model_validate({**self.model_dump(), **overrides})
128131
129 @classmethod132 @classmethod
130 def load(cls, config_path: str | Path | None = None) -> "DetectorConfig":133 def load(cls, config_path: str | Path | None = None) -> "DetectorConfig":
131 """Load config from the packaged defaults merged with an optional user JSON."""134 """Load config from the packaged defaults merged with an optional user JSON."""
Importance #5: src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json @@ -6,29 +6,29 @@
6 "occupancy": {6 "occupancy": {
7 "cell_m": 0.157 "cell_m": 0.15
8 },8 },
9 "candidates": {9 "candidates": {
10 "min_height_m": 0.30,10 "min_height_m": 0.3,
11 "max_height_m": 10.0,11 "max_height_m": 10.0,
12 "seed_min_vertical_span_m": 0.80,12 "seed_min_vertical_span_m": 0.8,
13 "seed_min_h_max_m": 0.90,13 "seed_min_h_max_m": 0.9,
14 "seed_bright_min_vertical_span_m": 0.45,14 "seed_bright_min_vertical_span_m": 0.45,
15 "seed_bright_min_h_max_m": 0.60,15 "seed_bright_min_h_max_m": 0.6,
16 "seed_bright_min_points": 316 "seed_bright_min_points": 3
17 },17 },
18 "clustering": {18 "clustering": {
19 "eps_m": 0.45,19 "eps_m": 0.45,
20 "min_samples": 1,20 "min_samples": 1,
21 "hull_margin_m": 0.2021 "hull_margin_m": 0.2
22 },22 },
23 "classification": {23 "classification": {
24 "continuity_bin_m": 0.25,24 "continuity_bin_m": 0.25,
25 "reject_len_major_m": 6.0,25 "reject_len_major_m": 6.0,
26 "reject_h_max_with_large_footprint_m": 4.5,26 "reject_h_max_with_large_footprint_m": 4.5,
27 "min_continuity": 0.50,27 "min_continuity": 0.5,
28 "min_accept_h_max_m": 0.90,28 "min_accept_h_max_m": 0.9,
29 "core_rms_bin_m": 0.25,29 "core_rms_bin_m": 0.25,
30 "core_rms_h_min_m": 0.30,30 "core_rms_h_min_m": 0.3,
31 "core_rms_h_cap_m": 3.0,31 "core_rms_h_cap_m": 3.0,
32 "hi_intensity_all_points_percentile": 98.0,32 "hi_intensity_all_points_percentile": 98.0,
33 "min_volumetric_density": 8000.0,33 "min_volumetric_density": 8000.0,
34 "pole_floating_min_h_min_m": 3.5,34 "pole_floating_min_h_min_m": 3.5,
Importance #6: src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json @@ -36,9 +36,37 @@
36 "dedup_radius_m": 0.8,36 "dedup_radius_m": 0.8,
37 "emit_trees": false,37 "emit_trees": false,
38 "ml_verifier_enabled": true,38 "ml_verifier_enabled": true,
39 "ml_veto_threshold": -1.0,39 "ml_veto_threshold": -1.0,
40 "ml_model_path": ""40 "ml_model_path": "",
41 "lattice_admission": true,
42 "lattice_max_seed_spacing_m": 60.0,
43 "lattice_max_skip": 6,
44 "lattice_max_spacing_resid": 0.15,
45 "lattice_min_anchors": 4,
46 "lattice_min_seed_spacing_m": 15.0,
47 "lattice_pool_h_max_max_m": 1.4,
48 "lattice_pool_h_max_min_m": 0.8,
49 "lattice_pool_max_len_major_m": 1.2,
50 "lattice_pool_max_plate_thickness_m": 0.05,
51 "lattice_pool_min_hi_seed_fraction": 0.15,
52 "lattice_pool_min_points": 20,
53 "lattice_pool_min_verticality": 0.85,
54 "lattice_snap_m": 3.0,
55 "ml_veto_requires_corridor": true,
56 "robust_extent_hi_percentile": 99.0,
57 "robust_extent_lo_percentile": 1.0,
58 "robust_extent_stats": true,
59 "robust_h_max_percentile": 98.0,
60 "seed_bright_percentile": 95.0,
61 "single_record_transient_veto": true,
62 "transient_max_h_max_m": 2.5,
63 "transient_max_verticality": 0.3,
64 "transient_min_len_major_m": 2.0,
65 "veg_texture_min_hi_seed_fraction": 0.668,
66 "veg_texture_min_plate_thickness_m": 0.05,
67 "veg_texture_veto": true,
68 "verticality_sentinel_fix": true
41 },69 },
42 "corridor": {70 "corridor": {
43 "max_dist_to_road_m": 10.0,71 "max_dist_to_road_m": 10.0,
44 "on_carriageway_dist_m": 0.25,72 "on_carriageway_dist_m": 0.25,
Importance #7: src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json @@ -78,23 +106,23 @@
78 "sign_post": {106 "sign_post": {
79 "max_len_minor_m": 0.8,107 "max_len_minor_m": 0.8,
80 "h_min_m": 1.5,108 "h_min_m": 1.5,
81 "h_max_m": 6.0,109 "h_max_m": 6.0,
82 "min_continuity": 0.60,110 "min_continuity": 0.6,
83 "plate_hi_intensity_fraction": 0.40,111 "plate_hi_intensity_fraction": 0.4,
84 "plate_hi_intensity_fraction_weak": 0.30,112 "plate_hi_intensity_fraction_weak": 0.3,
85 "plate_upper_surplus_ratio": 2.0,113 "plate_upper_surplus_ratio": 2.0,
86 "min_upper_half_surplus": 0.30,114 "min_upper_half_surplus": 0.3,
87 "plate_min_core_rms_m": 0.10,115 "plate_min_core_rms_m": 0.1,
88 "max_plate_thickness_m": 0.15,116 "max_plate_thickness_m": 0.15,
89 "bare_post_min_h_max_m": 4.5,117 "bare_post_min_h_max_m": 4.5,
90 "bare_post_max_core_rms_m": 0.065,118 "bare_post_max_core_rms_m": 0.065,
91 "bare_post_min_verticality": 0.90,119 "bare_post_min_verticality": 0.9,
92 "bare_post_min_points": 450120 "bare_post_min_points": 450
93 },121 },
94 "panel": {122 "panel": {
95 "min_hi": 0.40,123 "min_hi": 0.4,
96 "max_thickness_m": 0.20,124 "max_thickness_m": 0.2,
97 "h_min_m": 0.9,125 "h_min_m": 0.9,
98 "len_major_min_m": 1.5,126 "len_major_min_m": 1.5,
99 "len_major_max_m": 5.0127 "len_major_max_m": 5.0
100 },128 },
Importance #8: src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json @@ -160,9 +188,18 @@
160 "far_max_distance_m": 30.0,188 "far_max_distance_m": 30.0,
161 "far_include_lane_lines": true,189 "far_include_lane_lines": true,
162 "far_tier2_enabled": true,190 "far_tier2_enabled": true,
163 "far_tier2_distance_m": 15.0,191 "far_tier2_distance_m": 15.0,
164 "far_tier2_max_saturation": 150192 "far_tier2_max_saturation": 150,
193 "max_carriageway_width_m": 20.0,
194 "min_carriageway_width_m": 3.0,
195 "paint_fallback_enabled": false,
196 "xml_enabled": true,
197 "xml_max_distance_m": 60.0,
198 "xml_min_agreement": 0.6,
199 "xml_station_step_m": 10.0,
200 "xml_station_tolerance_m": 2.0,
201 "xml_vote_slack_m": 3.0
165 },202 },
166 "field_stake": {203 "field_stake": {
167 "row_emit": true,204 "row_emit": true,
168 "min_members": 4,205 "min_members": 4,
Importance #9: src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json @@ -212,17 +249,20 @@
212 "h_min_m": 1.5,249 "h_min_m": 1.5,
213 "h_max_m": 4.5,250 "h_max_m": 4.5,
214 "len_major_m": 2.5,251 "len_major_m": 2.5,
215 "len_minor_m": 1.5,252 "len_minor_m": 1.5,
216 "max_hi_intensity_fraction": 0.10253 "max_hi_intensity_fraction": 0.1
217 },254 },
218 "views": {255 "views": {
219 "near_radius_m": 45.0,256 "near_radius_m": 45.0,
220 "fov_deg": 55.0,257 "fov_deg": 55.0,
221 "splat": 2,258 "splat": 2,
222 "image_width": 1100,259 "image_width": 1100,
223 "image_height": 750,260 "image_height": 750,
224 "view_names": ["back", "side"]261 "view_names": [
262 "back",
263 "side"
264 ]
225 },265 },
226 "perspective": {266 "perspective": {
227 "depth_tol_m": 0.5,267 "depth_tol_m": 0.5,
228 "line_samples": 20,268 "line_samples": 20,
Importance #10: src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json @@ -237,81 +277,181 @@
237 "share_radius_m": 15.0,277 "share_radius_m": 15.0,
238 "coverage_tol_m": 0.5278 "coverage_tol_m": 0.5
239 },279 },
240 "tree_instance": {280 "tree_instance": {
241 "enabled": false,281 "enabled": false,
242 "local_ground_footprint_m": 15.0,282 "local_ground_footprint_m": 15.0,
243 "local_ground_cell_m": 2.0,283 "local_ground_cell_m": 2.0,
244 "local_ground_percentile": 5.0,284 "local_ground_percentile": 5.0,
245 "local_ground_window_m": 6.0,285 "local_ground_window_m": 6.0,
246 "crown_base_bin_m": 0.25,286 "crown_base_bin_m": 0.25,
247 "crown_base_density_frac": 0.35,287 "crown_base_density_frac": 0.35,
248 "crown_base_run_bins": 3,288 "crown_base_run_bins": 3,
249 "crown_base_min_m": 1.2,289 "crown_base_min_m": 1.2,
250 "stem_band_low_m": 0.5,290 "stem_band_low_m": 0.5,
251 "stem_band_cap_m": 4.0,291 "stem_band_cap_m": 4.0,
252 "stem_band_min_thickness_m": 0.7,292 "stem_band_min_thickness_m": 0.7,
253 "stem_eps_m": 0.35,293 "stem_eps_m": 0.35,
254 "stem_min_samples": 20,294 "stem_min_samples": 20,
255 "stem_max_diameter_m": 1.2,295 "stem_max_diameter_m": 1.2,
256 "stem_min_vertical_reach": 0.5,296 "stem_min_vertical_reach": 0.5,
257 "stem_min_verticality": 0.6,297 "stem_min_verticality": 0.6,
258 "stem_min_score": 0.45,298 "stem_min_score": 0.45,
259 "stem_exg_bonus": 0.1,299 "stem_exg_bonus": 0.1,
260 "stem_merge_dist_m": 1.2,300 "stem_merge_dist_m": 1.2,
261 "stem_uncertain_dist_m": 2.0,301 "stem_uncertain_dist_m": 2.0,
262 "apex_fallback_enabled": true,302 "apex_fallback_enabled": true,
263 "apex_cell_m": 0.5,303 "apex_cell_m": 0.5,
264 "apex_smooth_sigma_m": 0.7,304 "apex_smooth_sigma_m": 0.7,
265 "apex_min_separation_m": 2.5,305 "apex_min_separation_m": 2.5,
266 "apex_min_prominence_m": 0.8,306 "apex_min_prominence_m": 0.8,
267 "apex_min_height_m": 2.0,307 "apex_min_height_m": 2.0,
268 "apex_trigger_span_m": 8.0,308 "apex_trigger_span_m": 8.0,
269 "apex_seed_radius_m": 0.6,309 "apex_seed_radius_m": 0.6,
270 "apex_confidence_scale": 0.6,310 "apex_confidence_scale": 0.6,
271 "min_points_per_instance": 1200,311 "min_points_per_instance": 1200,
272 "seedless_single_max_footprint_m": 10.0,312 "seedless_single_max_footprint_m": 10.0,
273 "seedless_single_min_height_m": 1.5,313 "seedless_single_min_height_m": 1.5,
274 "seedless_single_max_height_m": 25.0,314 "seedless_single_max_height_m": 25.0,
275 "seedless_single_confidence": 0.35,315 "seedless_single_confidence": 0.35,
276 "seedless_min_p95_h_m": 2.0,316 "seedless_min_p95_h_m": 2.0,
277 "seedless_max_aspect": 2.5,317 "seedless_max_aspect": 2.5,
278 "seedless_min_points": 800,318 "seedless_min_points": 800,
279 "float_fragment_min_h_m": 3.0,319 "float_fragment_min_h_m": 3.0,
280 "float_fragment_p25_h_m": 4.0,320 "float_fragment_p25_h_m": 4.0,
281 "min_tree_footprint_m": 1.5,321 "min_tree_footprint_m": 1.5,
282 "max_tree_footprint_m": 60.0,322 "max_tree_footprint_m": 60.0,
283 "megacluster_points": 1000000,323 "megacluster_points": 1000000,
284 "planar_min_footprint_m": 12.0,324 "planar_min_footprint_m": 12.0,
285 "planar_cell_m": 1.0,325 "planar_cell_m": 1.0,
286 "planar_max_spread_m": 0.3,326 "planar_max_spread_m": 0.3,
287 "planar_fraction_min": 0.55,327 "planar_fraction_min": 0.55,
288 "hedge_max_ground_gap_m": 2.0,328 "hedge_max_ground_gap_m": 2.0,
289 "hedge_max_height_m": 7.5,329 "hedge_max_height_m": 7.5,
290 "hedge_min_length_m": 8.0,330 "hedge_min_length_m": 8.0,
291 "hedge_min_area_m2": 20.0,331 "hedge_min_area_m2": 20.0,
292 "hedge_min_continuity": 0.75,332 "hedge_min_continuity": 0.75,
293 "hedge_continuity_bin_m": 1.0,333 "hedge_continuity_bin_m": 1.0,
294 "hedge_max_top_relief_m": 1.5,334 "hedge_max_top_relief_m": 1.5,
295 "hedge_max_seed_per_10m": 1.0,335 "hedge_max_seed_per_10m": 1.0,
296 "hedge_stem_score_min": 0.6,336 "hedge_stem_score_min": 0.6,
297 "assign_voxel_m": 0.3,337 "assign_voxel_m": 0.3,
298 "assign_max_gap_m": 1.25,338 "assign_max_gap_m": 1.25,
299 "assign_max_graph_dist_m": 30.0,339 "assign_max_graph_dist_m": 30.0,
300 "max_claim_radius_m": 9.0,340 "max_claim_radius_m": 9.0,
301 "low_evidence_margin": 0.05,341 "low_evidence_margin": 0.05,
302 "low_evidence_abstain": false,342 "low_evidence_abstain": false,
303 "min_cluster_points": 150,343 "min_cluster_points": 150,
304 "single_tree_footprint_m": 8.0,344 "single_tree_footprint_m": 8.0,
305 "partial_abstain_fraction": 0.2,345 "partial_abstain_fraction": 0.2,
306 "min_instance_points": 120,346 "min_instance_points": 120,
307 "min_instance_fraction": 0.01,347 "min_instance_fraction": 0.01,
308 "instance_max_linearity": 0.92,348 "instance_max_linearity": 0.92,
309 "instance_min_minor_m": 1.0,349 "instance_min_minor_m": 1.0,
310 "instance_min_vertical_m": 1.5,350 "instance_min_vertical_m": 1.5,
311 "instance_min_thickness_share": 0.02,351 "instance_min_thickness_share": 0.02,
312 "confidence_seed_weight": 0.6,352 "confidence_seed_weight": 0.6,
313 "confidence_size_ref_points": 2000.0,353 "confidence_size_ref_points": 2000.0,
314 "confidence_max": 0.95,354 "confidence_max": 0.95,
315 "confidence_fallback_max": 0.9355 "confidence_fallback_max": 0.9
356 },
357 "conic_gate": {
358 "apex_deg_max": 35.0,
359 "apex_deg_min": 5.0,
360 "change_of_curvature_min": 0.06,
361 "enabled": false,
362 "h_max_min_m": 2.5,
363 "h_over_width_max": 12.0,
364 "h_over_width_min": 1.5,
365 "max_hi_intensity_fraction": 0.2,
366 "max_on_road_fraction": 0.6,
367 "min_crown_area_m2": 0.3,
368 "min_decile_fill_fraction": 0.8,
369 "omnivariance_min": 0.1,
370 "taper_slope_max": -0.4,
371 "taper_slope_robust_max": -0.3,
372 "texture_cue_enabled": true
373 },
374 "conifer_rule": {
375 "enabled": false,
376 "h_max_min_m": 2.0,
377 "h_over_width_max": 15.0,
378 "h_over_width_min": 2.0,
379 "max_apex_ratio": 0.75,
380 "max_crown_base_frac": 0.55,
381 "max_crown_taper": -0.1,
382 "max_hi_intensity_fraction": 0.2,
383 "max_on_road_fraction": 0.6,
384 "max_stem_ratio": 2.2,
385 "max_volumetric_density": 380.0,
386 "min_change_of_curvature": 0.04,
387 "min_crown_area_m2": 0.2,
388 "min_decile_fill_fraction": 0.8,
389 "min_volumetric_density": 140.0
390 },
391 "radius": {
392 "crown_lobe_coverage_target": 0.95,
393 "crown_lobe_gap_m": 0.5,
394 "crown_lobe_max_count": 8,
395 "crown_lobe_min_points": 30,
396 "crown_lobe_min_samples": 10,
397 "crown_radius_percentile": 95.0,
398 "debug_cluster_points": false,
399 "fit_bin_m": 0.25,
400 "fit_divergence_factor": 4.0,
401 "fit_min_arc_deg": 60.0,
402 "fit_min_bin_points": 8,
403 "fit_residual_abs_m": 0.03,
404 "fit_residual_frac": 0.35,
405 "pole_radius_max_m": 0.5,
406 "trunk_radius_max_m": 0.8
407 },
408 "rail_halfpost": {
409 "band_lat_m": 0.8,
410 "band_z_hi_m": 1.5,
411 "band_z_lo_m": 0.15,
412 "cluster_cell_m": 0.15,
413 "dedupe_m": 1.5,
414 "enabled": false,
415 "ground_cell_m": 2.0,
416 "ground_percentile": 10.0,
417 "h_max_m": 0.8,
418 "h_min_m": 0.2,
419 "max_lateral_m": 0.5,
420 "max_width_m": 0.2,
421 "min_emit_points": 8,
422 "min_points": 15,
423 "min_z_extent_m": 0.1,
424 "models_dir": "",
425 "prime_min_records": 2,
426 "prime_min_sat": 1,
427 "sample_step_m": 0.1,
428 "saturation_intensity": 55000.0
429 },
430 "reject_rescue": {
431 "accepted_exclusion_m": 2.0,
432 "enabled": false,
433 "h_max_m": 1.6,
434 "h_min_m": 0.85,
435 "max_core_rms_m": 0.2,
436 "merge_radius_m": 1.0,
437 "min_continuity": 0.8,
438 "min_decile_fill": 0.6,
439 "min_h_over_width": 1.4,
440 "min_points": 30,
441 "min_records": 2,
442 "min_roadctx_sat": 17,
443 "min_verticality": 0.9,
444 "per_segment_cap": 0
445 },
446 "tcs_ground": {
447 "cache_dir": "",
448 "cell_m": 0.2,
449 "elev_scalar": 0.0,
450 "enabled": false,
451 "max_elev_diff_m": 0.15,
452 "mechanism": "smrf_numpy",
453 "pit_fill_enabled": true,
454 "slope_threshold": 0.3,
455 "smrf_max_window_m": 6.0
316 }456 }
317}457}
Importance #11: tests/conftest.py @@ -1,6 +1,7 @@
1"""Shared fixtures for the vertical-sign detector tests."""1"""Shared fixtures for the vertical-sign detector tests."""
22
3import typing
3from collections.abc import Callable4from collections.abc import Callable
4from typing import Any5from typing import Any
56
6import pytest7import pytest
Importance #12: tests/conftest.py @@ -17,8 +18,10 @@
17 elif annotation is int:18 elif annotation is int:
18 values[name] = int(field.default) + 119 values[name] = int(field.default) + 1
19 elif annotation is str:20 elif annotation is str:
20 values[name] = f"{field.default}_x"21 values[name] = f"{field.default}_x"
22 elif typing.get_origin(annotation) is tuple:
23 values[name] = [f"{item}_x" for item in field.default]
21 else:24 else:
22 values[name] = 0.525 values[name] = 0.5
23 return values26 return values
2427
Importance #13: tests/test_chroma_vegetation.py @@ -278,9 +278,9 @@
278 "chroma_vegetation",278 "chroma_vegetation",
279 )279 )
280280
281281
282def test_overrides_reach_the_dataclass() -> None:282def test_overrides_reach_the_model() -> None:
283 config = DetectorConfig.from_mapping(283 config = DetectorConfig.from_mapping(
284 {"chroma_vegetation": {"enabled": True, "exg_min": 0.33}}284 {"chroma_vegetation": {"enabled": True, "exg_min": 0.33}}
285 )285 )
286 assert config.chroma_veg_enabled is True286 assert config.chroma_veg_enabled is True
Importance #14: tests/test_config_split.py @@ -95,9 +95,9 @@
9595
9696
97def test_the_packaged_defaults_round_trip() -> None:97def test_the_packaged_defaults_round_trip() -> None:
98 packaged = load_default_config()98 packaged = load_default_config()
99 assert json.dumps(packaged) # it is a plain JSON document99 assert json.loads(json.dumps(packaged)) == packaged # plain JSON types only
100 assert DetectorConfig.from_mapping(packaged) == DetectorConfig.load()100 assert DetectorConfig.from_mapping(packaged) == DetectorConfig.load()
101101
102102
103def test_the_packaged_defaults_equal_the_flat_defaults() -> None:103def test_the_packaged_defaults_equal_the_flat_defaults() -> None:
Importance #15: tests/test_config_split.py @@ -124,4 +124,20 @@
124 except ValueError as exc:124 except ValueError as exc:
125 assert "cluster_eps" in str(exc)125 assert "cluster_eps" in str(exc)
126 else: # pragma: no cover - the failure the test exists to catch126 else: # pragma: no cover - the failure the test exists to catch
127 raise AssertionError("a misspelled field name was accepted")127 raise AssertionError("a misspelled field name was accepted")
128
129
130def test_the_packaged_json_declares_exactly_the_model_keys() -> None:
131 """The packaged JSON and the model must not drift apart in SHAPE either.
132
133 ``load_verticalsigns_config`` returns the validated model dump, so a key
134 the model declares but the JSON omits would be injected into the returned
135 document (and a JSON key the model lacks would be rejected outright).
136 """
137 packaged = json.loads(
138 (CONFIG_PY.parent / "verticalsigns.default.json").read_text(encoding="utf-8")
139 )
140 model = _config_model.VerticalSignsConfig().model_dump(mode="json")
141 assert {s: sorted(keys) for s, keys in packaged.items()} == {
142 s: sorted(keys) for s, keys in model.items()
143 }
Importance #16: README.md @@ -18,28 +18,32 @@
1818
19`python -m iolabs_point_cloud_detection_verticalsigns.detect ...` works too, as19`python -m iolabs_point_cloud_detection_verticalsigns.detect ...` works too, as
20does `uv run verticalsigns-views ...` for the per-detection close-up renders.20does `uv run verticalsigns-views ...` for the per-detection close-up renders.
2121
22Thresholds live in the packaged `verticalsigns.default.json` (nested sections:22Thresholds live in the packaged `verticalsigns.default.json`, one nested
23`ground`, `occupancy`, `candidates`, `clustering`, `classification`, `corridor`,23section per detector stage (`ground`, `occupancy`, `candidates`, `clustering`,
24`context`, `delineator`, `sign_post`, `panel`, `gantry`, `repetitive_row`,24`classification`, `radius`, `corridor`, `context`, `delineator`, `sign_post`,
25`marker_extract`, `rail_halfpost`, `reject_rescue`, `tree`, `tree_detection`,25`panel`, `gantry`, `repetitive_row`, `field_stake`, `marker_extract`,
26`chroma_vegetation`, `vehicle`, `views`, `perspective`). Pass `--config`26`rail_halfpost`, `reject_rescue`, `road_context`, `edge_line`, `tree`,
27to deep-merge a partial JSON over those defaults; unknown keys and bad values27`tree_detection`, `tree_instance`, `chroma_vegetation`, `tcs_ground`,
28are rejected.28`conic_gate`, `conifer_rule`, `vehicle`, `views`, `perspective`). Pass
29`--config` to deep-merge a partial JSON over those defaults; unknown keys and
30bad values are rejected.
2931
30The schema of that JSON is the `VerticalSignsConfig` pydantic model tree32The schema of that JSON is the `VerticalSignsConfig` pydantic model tree
31(`_config_model.py` plus the `_model_<slice>.py` sections, built on33(`_config_model.py` plus the `_model_<slice>.py` sections, built on
32`iolabs.common.config_loader.ConfigModel`): one nested model per JSON section,34`iolabs.common.config_loader.ConfigModel`): one nested model per JSON section,
33one field per key. **Adding a config key = add the field to its section model35one field per key, and the two sides must agree key for key (guarded by
34and the default to `verticalsigns.default.json`, nothing else.**36`tests/test_config_split.py`). **Adding a config key = add the field to its
37section model and the same default to `verticalsigns.default.json`** โ€” plus,
38if a detector module reads it, the flat field and `*_kwargs` line below.
3539
36The 379-field `DetectorConfig` is the FLAT view the detector modules read40The 379-field `DetectorConfig` is the FLAT view the detector modules read
37(`config.ground_cell_m`): it is declared across the `_config_<section>` modules41(`config.ground_cell_m`): it is declared across the `_config_<section>` modules
38and recombined in `config.py`, which re-exports every name โ€” import from42and recombined in `config.py`, which re-exports every name โ€” import from
39`...verticalsigns.config` exactly as before. A new key that the detector reads43`...verticalsigns.config` exactly as before. A new key that the detector reads
40also needs its flat field and the `*_kwargs` line that maps the section key44needs its flat field here and the `*_kwargs` line that maps the section key
41onto it.45onto it; a key only consumed from the nested document (e.g. `views`) does not.
4246
43## QC rendering is an optional extra47## QC rendering is an optional extra
4448
45`verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need49`verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need
Importance #17: src/iolabs_point_cloud_detection_verticalsigns/_config.py @@ -46,9 +46,10 @@
46 config_path: Optional user JSON merged over the packaged defaults. Only46 config_path: Optional user JSON merged over the packaged defaults. Only
47 the keys it carries are overridden.47 the keys it carries are overridden.
4848
49 Returns:49 Returns:
50 The validated config document, every section present.50 The validated config document as plain JSON types: every section and
51 every key the model declares is present, defaults included.
5152
52 Raises:53 Raises:
53 VerticalSignsConfigError: The user JSON is malformed, or the merged54 VerticalSignsConfigError: The user JSON is malformed, or the merged
54 config holds an unknown section/key or an invalid value.55 config holds an unknown section/key or an invalid value.
Importance #18: src/iolabs_point_cloud_detection_verticalsigns/_config.py @@ -61,9 +62,9 @@
61 overrides=overrides,62 overrides=overrides,
62 context=_CONTEXT,63 context=_CONTEXT,
63 error_cls=VerticalSignsConfigError,64 error_cls=VerticalSignsConfigError,
64 )65 )
65 return config.model_dump()66 return config.model_dump(mode="json")
6667
6768
68def _read_user_config(config_path: str | Path) -> dict[str, Any]:69def _read_user_config(config_path: str | Path) -> dict[str, Any]:
69 """Read a user config JSON, wrapping decode errors in the package error."""70 """Read a user config JSON, wrapping decode errors in the package error."""
Importance #19: src/iolabs_point_cloud_detection_verticalsigns/_config_model.py @@ -2,9 +2,12 @@
22
3``VerticalSignsConfig`` mirrors ``verticalsigns.default.json`` section for3``VerticalSignsConfig`` mirrors ``verticalsigns.default.json`` section for
4section and key for key: it is the single source of truth for which config4section and key for key: it is the single source of truth for which config
5keys exist and what type each one has. Adding a key means adding a field to5keys exist and what type each one has. Adding a key means adding a field to
6the matching section model and a default to the packaged JSON.6the matching section model and the same default to the packaged JSON; the two
7sides must stay in lockstep, and ``tests/test_config_split.py`` fails if they
8drift. A key the detector modules read also needs its flat ``DetectorConfig``
9field and the ``*_kwargs`` line that maps it (see ``config.py``).
7"""10"""
811
9from iolabs.common import config_loader12from iolabs.common import config_loader
1013
Importance #20: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -107,25 +107,28 @@
107 def with_overrides(self, **overrides: Any) -> "DetectorConfig":107 def with_overrides(self, **overrides: Any) -> "DetectorConfig":
108 """Return a copy of this config with *overrides* applied.108 """Return a copy of this config with *overrides* applied.
109109
110 ``model_copy(update=...)`` skips validation, so a misspelled name would110 ``model_copy(update=...)`` skips validation, so a misspelled name would
111 be attached as a new attribute and the intended threshold would keep111 be attached as a new attribute and a wrongly typed value would be
112 its default. The names are therefore checked here, reproducing the112 stored uncoerced. The names are checked here and the values are run
113 ``TypeError`` that ``dataclasses.replace`` used to raise.113 through the model, so this validates where ``dataclasses.replace``
114 merely type-checked the call.
114115
115 Args:116 Args:
116 overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``.117 overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``.
117118
118 Returns:119 Returns:
119 A new frozen config carrying *overrides*.120 A new frozen config carrying *overrides*.
120121
121 Raises:122 Raises:
122 ValueError: An override names a field this config does not declare.123 ValueError: An override names a field this config does not declare,
124 or carries a value the field rejects (a
125 ``pydantic.ValidationError``, itself a ``ValueError``).
123 """126 """
124 unknown = sorted(set(overrides) - set(type(self).model_fields))127 unknown = sorted(set(overrides) - set(type(self).model_fields))
125 if unknown:128 if unknown:
126 raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}")129 raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}")
127 return self.model_copy(update=overrides)130 return type(self).model_validate({**self.model_dump(), **overrides})
128131
129 @classmethod132 @classmethod
130 def load(cls, config_path: str | Path | None = None) -> "DetectorConfig":133 def load(cls, config_path: str | Path | None = None) -> "DetectorConfig":
131 """Load config from the packaged defaults merged with an optional user JSON."""134 """Load config from the packaged defaults merged with an optional user JSON."""
Importance #21: src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json @@ -6,29 +6,29 @@
6 "occupancy": {6 "occupancy": {
7 "cell_m": 0.157 "cell_m": 0.15
8 },8 },
9 "candidates": {9 "candidates": {
10 "min_height_m": 0.30,10 "min_height_m": 0.3,
11 "max_height_m": 10.0,11 "max_height_m": 10.0,
12 "seed_min_vertical_span_m": 0.80,12 "seed_min_vertical_span_m": 0.8,
13 "seed_min_h_max_m": 0.90,13 "seed_min_h_max_m": 0.9,
14 "seed_bright_min_vertical_span_m": 0.45,14 "seed_bright_min_vertical_span_m": 0.45,
15 "seed_bright_min_h_max_m": 0.60,15 "seed_bright_min_h_max_m": 0.6,
16 "seed_bright_min_points": 316 "seed_bright_min_points": 3
17 },17 },
18 "clustering": {18 "clustering": {
19 "eps_m": 0.45,19 "eps_m": 0.45,
20 "min_samples": 1,20 "min_samples": 1,
21 "hull_margin_m": 0.2021 "hull_margin_m": 0.2
22 },22 },
23 "classification": {23 "classification": {
24 "continuity_bin_m": 0.25,24 "continuity_bin_m": 0.25,
25 "reject_len_major_m": 6.0,25 "reject_len_major_m": 6.0,
26 "reject_h_max_with_large_footprint_m": 4.5,26 "reject_h_max_with_large_footprint_m": 4.5,
27 "min_continuity": 0.50,27 "min_continuity": 0.5,
28 "min_accept_h_max_m": 0.90,28 "min_accept_h_max_m": 0.9,
29 "core_rms_bin_m": 0.25,29 "core_rms_bin_m": 0.25,
30 "core_rms_h_min_m": 0.30,30 "core_rms_h_min_m": 0.3,
31 "core_rms_h_cap_m": 3.0,31 "core_rms_h_cap_m": 3.0,
32 "hi_intensity_all_points_percentile": 98.0,32 "hi_intensity_all_points_percentile": 98.0,
33 "min_volumetric_density": 8000.0,33 "min_volumetric_density": 8000.0,
34 "pole_floating_min_h_min_m": 3.5,34 "pole_floating_min_h_min_m": 3.5,
Importance #22: src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json @@ -36,9 +36,37 @@
36 "dedup_radius_m": 0.8,36 "dedup_radius_m": 0.8,
37 "emit_trees": false,37 "emit_trees": false,
38 "ml_verifier_enabled": true,38 "ml_verifier_enabled": true,
39 "ml_veto_threshold": -1.0,39 "ml_veto_threshold": -1.0,
40 "ml_model_path": ""40 "ml_model_path": "",
41 "lattice_admission": true,
42 "lattice_max_seed_spacing_m": 60.0,
43 "lattice_max_skip": 6,
44 "lattice_max_spacing_resid": 0.15,
45 "lattice_min_anchors": 4,
46 "lattice_min_seed_spacing_m": 15.0,
47 "lattice_pool_h_max_max_m": 1.4,
48 "lattice_pool_h_max_min_m": 0.8,
49 "lattice_pool_max_len_major_m": 1.2,
50 "lattice_pool_max_plate_thickness_m": 0.05,
51 "lattice_pool_min_hi_seed_fraction": 0.15,
52 "lattice_pool_min_points": 20,
53 "lattice_pool_min_verticality": 0.85,
54 "lattice_snap_m": 3.0,
55 "ml_veto_requires_corridor": true,
56 "robust_extent_hi_percentile": 99.0,
57 "robust_extent_lo_percentile": 1.0,
58 "robust_extent_stats": true,
59 "robust_h_max_percentile": 98.0,
60 "seed_bright_percentile": 95.0,
61 "single_record_transient_veto": true,
62 "transient_max_h_max_m": 2.5,
63 "transient_max_verticality": 0.3,
64 "transient_min_len_major_m": 2.0,
65 "veg_texture_min_hi_seed_fraction": 0.668,
66 "veg_texture_min_plate_thickness_m": 0.05,
67 "veg_texture_veto": true,
68 "verticality_sentinel_fix": true
41 },69 },
42 "corridor": {70 "corridor": {
43 "max_dist_to_road_m": 10.0,71 "max_dist_to_road_m": 10.0,
44 "on_carriageway_dist_m": 0.25,72 "on_carriageway_dist_m": 0.25,
Importance #23: src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json @@ -78,23 +106,23 @@
78 "sign_post": {106 "sign_post": {
79 "max_len_minor_m": 0.8,107 "max_len_minor_m": 0.8,
80 "h_min_m": 1.5,108 "h_min_m": 1.5,
81 "h_max_m": 6.0,109 "h_max_m": 6.0,
82 "min_continuity": 0.60,110 "min_continuity": 0.6,
83 "plate_hi_intensity_fraction": 0.40,111 "plate_hi_intensity_fraction": 0.4,
84 "plate_hi_intensity_fraction_weak": 0.30,112 "plate_hi_intensity_fraction_weak": 0.3,
85 "plate_upper_surplus_ratio": 2.0,113 "plate_upper_surplus_ratio": 2.0,
86 "min_upper_half_surplus": 0.30,114 "min_upper_half_surplus": 0.3,
87 "plate_min_core_rms_m": 0.10,115 "plate_min_core_rms_m": 0.1,
88 "max_plate_thickness_m": 0.15,116 "max_plate_thickness_m": 0.15,
89 "bare_post_min_h_max_m": 4.5,117 "bare_post_min_h_max_m": 4.5,
90 "bare_post_max_core_rms_m": 0.065,118 "bare_post_max_core_rms_m": 0.065,
91 "bare_post_min_verticality": 0.90,119 "bare_post_min_verticality": 0.9,
92 "bare_post_min_points": 450120 "bare_post_min_points": 450
93 },121 },
94 "panel": {122 "panel": {
95 "min_hi": 0.40,123 "min_hi": 0.4,
96 "max_thickness_m": 0.20,124 "max_thickness_m": 0.2,
97 "h_min_m": 0.9,125 "h_min_m": 0.9,
98 "len_major_min_m": 1.5,126 "len_major_min_m": 1.5,
99 "len_major_max_m": 5.0127 "len_major_max_m": 5.0
100 },128 },
Importance #24: src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json @@ -160,9 +188,18 @@
160 "far_max_distance_m": 30.0,188 "far_max_distance_m": 30.0,
161 "far_include_lane_lines": true,189 "far_include_lane_lines": true,
162 "far_tier2_enabled": true,190 "far_tier2_enabled": true,
163 "far_tier2_distance_m": 15.0,191 "far_tier2_distance_m": 15.0,
164 "far_tier2_max_saturation": 150192 "far_tier2_max_saturation": 150,
193 "max_carriageway_width_m": 20.0,
194 "min_carriageway_width_m": 3.0,
195 "paint_fallback_enabled": false,
196 "xml_enabled": true,
197 "xml_max_distance_m": 60.0,
198 "xml_min_agreement": 0.6,
199 "xml_station_step_m": 10.0,
200 "xml_station_tolerance_m": 2.0,
201 "xml_vote_slack_m": 3.0
165 },202 },
166 "field_stake": {203 "field_stake": {
167 "row_emit": true,204 "row_emit": true,
168 "min_members": 4,205 "min_members": 4,
Importance #25: src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json @@ -212,17 +249,20 @@
212 "h_min_m": 1.5,249 "h_min_m": 1.5,
213 "h_max_m": 4.5,250 "h_max_m": 4.5,
214 "len_major_m": 2.5,251 "len_major_m": 2.5,
215 "len_minor_m": 1.5,252 "len_minor_m": 1.5,
216 "max_hi_intensity_fraction": 0.10253 "max_hi_intensity_fraction": 0.1
217 },254 },
218 "views": {255 "views": {
219 "near_radius_m": 45.0,256 "near_radius_m": 45.0,
220 "fov_deg": 55.0,257 "fov_deg": 55.0,
221 "splat": 2,258 "splat": 2,
222 "image_width": 1100,259 "image_width": 1100,
223 "image_height": 750,260 "image_height": 750,
224 "view_names": ["back", "side"]261 "view_names": [
262 "back",
263 "side"
264 ]
225 },265 },
226 "perspective": {266 "perspective": {
227 "depth_tol_m": 0.5,267 "depth_tol_m": 0.5,
228 "line_samples": 20,268 "line_samples": 20,
Importance #26: src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json @@ -237,81 +277,181 @@
237 "share_radius_m": 15.0,277 "share_radius_m": 15.0,
238 "coverage_tol_m": 0.5278 "coverage_tol_m": 0.5
239 },279 },
240 "tree_instance": {280 "tree_instance": {
241 "enabled": false,281 "enabled": false,
242 "local_ground_footprint_m": 15.0,282 "local_ground_footprint_m": 15.0,
243 "local_ground_cell_m": 2.0,283 "local_ground_cell_m": 2.0,
244 "local_ground_percentile": 5.0,284 "local_ground_percentile": 5.0,
245 "local_ground_window_m": 6.0,285 "local_ground_window_m": 6.0,
246 "crown_base_bin_m": 0.25,286 "crown_base_bin_m": 0.25,
247 "crown_base_density_frac": 0.35,287 "crown_base_density_frac": 0.35,
248 "crown_base_run_bins": 3,288 "crown_base_run_bins": 3,
249 "crown_base_min_m": 1.2,289 "crown_base_min_m": 1.2,
250 "stem_band_low_m": 0.5,290 "stem_band_low_m": 0.5,
251 "stem_band_cap_m": 4.0,291 "stem_band_cap_m": 4.0,
252 "stem_band_min_thickness_m": 0.7,292 "stem_band_min_thickness_m": 0.7,
253 "stem_eps_m": 0.35,293 "stem_eps_m": 0.35,
254 "stem_min_samples": 20,294 "stem_min_samples": 20,
255 "stem_max_diameter_m": 1.2,295 "stem_max_diameter_m": 1.2,
256 "stem_min_vertical_reach": 0.5,296 "stem_min_vertical_reach": 0.5,
257 "stem_min_verticality": 0.6,297 "stem_min_verticality": 0.6,
258 "stem_min_score": 0.45,298 "stem_min_score": 0.45,
259 "stem_exg_bonus": 0.1,299 "stem_exg_bonus": 0.1,
260 "stem_merge_dist_m": 1.2,300 "stem_merge_dist_m": 1.2,
261 "stem_uncertain_dist_m": 2.0,301 "stem_uncertain_dist_m": 2.0,
262 "apex_fallback_enabled": true,302 "apex_fallback_enabled": true,
263 "apex_cell_m": 0.5,303 "apex_cell_m": 0.5,
264 "apex_smooth_sigma_m": 0.7,304 "apex_smooth_sigma_m": 0.7,
265 "apex_min_separation_m": 2.5,305 "apex_min_separation_m": 2.5,
266 "apex_min_prominence_m": 0.8,306 "apex_min_prominence_m": 0.8,
267 "apex_min_height_m": 2.0,307 "apex_min_height_m": 2.0,
268 "apex_trigger_span_m": 8.0,308 "apex_trigger_span_m": 8.0,
269 "apex_seed_radius_m": 0.6,309 "apex_seed_radius_m": 0.6,
270 "apex_confidence_scale": 0.6,310 "apex_confidence_scale": 0.6,
271 "min_points_per_instance": 1200,311 "min_points_per_instance": 1200,
272 "seedless_single_max_footprint_m": 10.0,312 "seedless_single_max_footprint_m": 10.0,
273 "seedless_single_min_height_m": 1.5,313 "seedless_single_min_height_m": 1.5,
274 "seedless_single_max_height_m": 25.0,314 "seedless_single_max_height_m": 25.0,
275 "seedless_single_confidence": 0.35,315 "seedless_single_confidence": 0.35,
276 "seedless_min_p95_h_m": 2.0,316 "seedless_min_p95_h_m": 2.0,
277 "seedless_max_aspect": 2.5,317 "seedless_max_aspect": 2.5,
278 "seedless_min_points": 800,318 "seedless_min_points": 800,
279 "float_fragment_min_h_m": 3.0,319 "float_fragment_min_h_m": 3.0,
280 "float_fragment_p25_h_m": 4.0,320 "float_fragment_p25_h_m": 4.0,
281 "min_tree_footprint_m": 1.5,321 "min_tree_footprint_m": 1.5,
282 "max_tree_footprint_m": 60.0,322 "max_tree_footprint_m": 60.0,
283 "megacluster_points": 1000000,323 "megacluster_points": 1000000,
284 "planar_min_footprint_m": 12.0,324 "planar_min_footprint_m": 12.0,
285 "planar_cell_m": 1.0,325 "planar_cell_m": 1.0,
286 "planar_max_spread_m": 0.3,326 "planar_max_spread_m": 0.3,
287 "planar_fraction_min": 0.55,327 "planar_fraction_min": 0.55,
288 "hedge_max_ground_gap_m": 2.0,328 "hedge_max_ground_gap_m": 2.0,
289 "hedge_max_height_m": 7.5,329 "hedge_max_height_m": 7.5,
290 "hedge_min_length_m": 8.0,330 "hedge_min_length_m": 8.0,
291 "hedge_min_area_m2": 20.0,331 "hedge_min_area_m2": 20.0,
292 "hedge_min_continuity": 0.75,332 "hedge_min_continuity": 0.75,
293 "hedge_continuity_bin_m": 1.0,333 "hedge_continuity_bin_m": 1.0,
294 "hedge_max_top_relief_m": 1.5,334 "hedge_max_top_relief_m": 1.5,
295 "hedge_max_seed_per_10m": 1.0,335 "hedge_max_seed_per_10m": 1.0,
296 "hedge_stem_score_min": 0.6,336 "hedge_stem_score_min": 0.6,
297 "assign_voxel_m": 0.3,337 "assign_voxel_m": 0.3,
298 "assign_max_gap_m": 1.25,338 "assign_max_gap_m": 1.25,
299 "assign_max_graph_dist_m": 30.0,339 "assign_max_graph_dist_m": 30.0,
300 "max_claim_radius_m": 9.0,340 "max_claim_radius_m": 9.0,
301 "low_evidence_margin": 0.05,341 "low_evidence_margin": 0.05,
302 "low_evidence_abstain": false,342 "low_evidence_abstain": false,
303 "min_cluster_points": 150,343 "min_cluster_points": 150,
304 "single_tree_footprint_m": 8.0,344 "single_tree_footprint_m": 8.0,
305 "partial_abstain_fraction": 0.2,345 "partial_abstain_fraction": 0.2,
306 "min_instance_points": 120,346 "min_instance_points": 120,
307 "min_instance_fraction": 0.01,347 "min_instance_fraction": 0.01,
308 "instance_max_linearity": 0.92,348 "instance_max_linearity": 0.92,
309 "instance_min_minor_m": 1.0,349 "instance_min_minor_m": 1.0,
310 "instance_min_vertical_m": 1.5,350 "instance_min_vertical_m": 1.5,
311 "instance_min_thickness_share": 0.02,351 "instance_min_thickness_share": 0.02,
312 "confidence_seed_weight": 0.6,352 "confidence_seed_weight": 0.6,
313 "confidence_size_ref_points": 2000.0,353 "confidence_size_ref_points": 2000.0,
314 "confidence_max": 0.95,354 "confidence_max": 0.95,
315 "confidence_fallback_max": 0.9355 "confidence_fallback_max": 0.9
356 },
357 "conic_gate": {
358 "apex_deg_max": 35.0,
359 "apex_deg_min": 5.0,
360 "change_of_curvature_min": 0.06,
361 "enabled": false,
362 "h_max_min_m": 2.5,
363 "h_over_width_max": 12.0,
364 "h_over_width_min": 1.5,
365 "max_hi_intensity_fraction": 0.2,
366 "max_on_road_fraction": 0.6,
367 "min_crown_area_m2": 0.3,
368 "min_decile_fill_fraction": 0.8,
369 "omnivariance_min": 0.1,
370 "taper_slope_max": -0.4,
371 "taper_slope_robust_max": -0.3,
372 "texture_cue_enabled": true
373 },
374 "conifer_rule": {
375 "enabled": false,
376 "h_max_min_m": 2.0,
377 "h_over_width_max": 15.0,
378 "h_over_width_min": 2.0,
379 "max_apex_ratio": 0.75,
380 "max_crown_base_frac": 0.55,
381 "max_crown_taper": -0.1,
382 "max_hi_intensity_fraction": 0.2,
383 "max_on_road_fraction": 0.6,
384 "max_stem_ratio": 2.2,
385 "max_volumetric_density": 380.0,
386 "min_change_of_curvature": 0.04,
387 "min_crown_area_m2": 0.2,
388 "min_decile_fill_fraction": 0.8,
389 "min_volumetric_density": 140.0
390 },
391 "radius": {
392 "crown_lobe_coverage_target": 0.95,
393 "crown_lobe_gap_m": 0.5,
394 "crown_lobe_max_count": 8,
395 "crown_lobe_min_points": 30,
396 "crown_lobe_min_samples": 10,
397 "crown_radius_percentile": 95.0,
398 "debug_cluster_points": false,
399 "fit_bin_m": 0.25,
400 "fit_divergence_factor": 4.0,
401 "fit_min_arc_deg": 60.0,
402 "fit_min_bin_points": 8,
403 "fit_residual_abs_m": 0.03,
404 "fit_residual_frac": 0.35,
405 "pole_radius_max_m": 0.5,
406 "trunk_radius_max_m": 0.8
407 },
408 "rail_halfpost": {
409 "band_lat_m": 0.8,
410 "band_z_hi_m": 1.5,
411 "band_z_lo_m": 0.15,
412 "cluster_cell_m": 0.15,
413 "dedupe_m": 1.5,
414 "enabled": false,
415 "ground_cell_m": 2.0,
416 "ground_percentile": 10.0,
417 "h_max_m": 0.8,
418 "h_min_m": 0.2,
419 "max_lateral_m": 0.5,
420 "max_width_m": 0.2,
421 "min_emit_points": 8,
422 "min_points": 15,
423 "min_z_extent_m": 0.1,
424 "models_dir": "",
425 "prime_min_records": 2,
426 "prime_min_sat": 1,
427 "sample_step_m": 0.1,
428 "saturation_intensity": 55000.0
429 },
430 "reject_rescue": {
431 "accepted_exclusion_m": 2.0,
432 "enabled": false,
433 "h_max_m": 1.6,
434 "h_min_m": 0.85,
435 "max_core_rms_m": 0.2,
436 "merge_radius_m": 1.0,
437 "min_continuity": 0.8,
438 "min_decile_fill": 0.6,
439 "min_h_over_width": 1.4,
440 "min_points": 30,
441 "min_records": 2,
442 "min_roadctx_sat": 17,
443 "min_verticality": 0.9,
444 "per_segment_cap": 0
445 },
446 "tcs_ground": {
447 "cache_dir": "",
448 "cell_m": 0.2,
449 "elev_scalar": 0.0,
450 "enabled": false,
451 "max_elev_diff_m": 0.15,
452 "mechanism": "smrf_numpy",
453 "pit_fill_enabled": true,
454 "slope_threshold": 0.3,
455 "smrf_max_window_m": 6.0
316 }456 }
317}457}
Importance #27: tests/conftest.py @@ -1,6 +1,7 @@
1"""Shared fixtures for the vertical-sign detector tests."""1"""Shared fixtures for the vertical-sign detector tests."""
22
3import typing
3from collections.abc import Callable4from collections.abc import Callable
4from typing import Any5from typing import Any
56
6import pytest7import pytest
Importance #28: tests/conftest.py @@ -17,8 +18,10 @@
17 elif annotation is int:18 elif annotation is int:
18 values[name] = int(field.default) + 119 values[name] = int(field.default) + 1
19 elif annotation is str:20 elif annotation is str:
20 values[name] = f"{field.default}_x"21 values[name] = f"{field.default}_x"
22 elif typing.get_origin(annotation) is tuple:
23 values[name] = [f"{item}_x" for item in field.default]
21 else:24 else:
22 values[name] = 0.525 values[name] = 0.5
23 return values26 return values
2427
Importance #29: tests/test_chroma_vegetation.py @@ -278,9 +278,9 @@
278 "chroma_vegetation",278 "chroma_vegetation",
279 )279 )
280280
281281
282def test_overrides_reach_the_dataclass() -> None:282def test_overrides_reach_the_model() -> None:
283 config = DetectorConfig.from_mapping(283 config = DetectorConfig.from_mapping(
284 {"chroma_vegetation": {"enabled": True, "exg_min": 0.33}}284 {"chroma_vegetation": {"enabled": True, "exg_min": 0.33}}
285 )285 )
286 assert config.chroma_veg_enabled is True286 assert config.chroma_veg_enabled is True
Importance #30: tests/test_config_split.py @@ -95,9 +95,9 @@
9595
9696
97def test_the_packaged_defaults_round_trip() -> None:97def test_the_packaged_defaults_round_trip() -> None:
98 packaged = load_default_config()98 packaged = load_default_config()
99 assert json.dumps(packaged) # it is a plain JSON document99 assert json.loads(json.dumps(packaged)) == packaged # plain JSON types only
100 assert DetectorConfig.from_mapping(packaged) == DetectorConfig.load()100 assert DetectorConfig.from_mapping(packaged) == DetectorConfig.load()
101101
102102
103def test_the_packaged_defaults_equal_the_flat_defaults() -> None:103def test_the_packaged_defaults_equal_the_flat_defaults() -> None:
Importance #31: tests/test_config_split.py @@ -124,4 +124,20 @@
124 except ValueError as exc:124 except ValueError as exc:
125 assert "cluster_eps" in str(exc)125 assert "cluster_eps" in str(exc)
126 else: # pragma: no cover - the failure the test exists to catch126 else: # pragma: no cover - the failure the test exists to catch
127 raise AssertionError("a misspelled field name was accepted")127 raise AssertionError("a misspelled field name was accepted")
128
129
130def test_the_packaged_json_declares_exactly_the_model_keys() -> None:
131 """The packaged JSON and the model must not drift apart in SHAPE either.
132
133 ``load_verticalsigns_config`` returns the validated model dump, so a key
134 the model declares but the JSON omits would be injected into the returned
135 document (and a JSON key the model lacks would be rejected outright).
136 """
137 packaged = json.loads(
138 (CONFIG_PY.parent / "verticalsigns.default.json").read_text(encoding="utf-8")
139 )
140 model = _config_model.VerticalSignsConfig().model_dump(mode="json")
141 assert {s: sorted(keys) for s, keys in packaged.items()} == {
142 s: sorted(keys) for s, keys in model.items()
143 }