Back to report index

verticalsigns d8ae433: AI3D-379 Pydantic config models via iolabs-common ConfigModel

Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:51:14+02:00

Commit #69 ยท 123 snippets

 BRIEF.md                                           |   6 +-
 README.md                                          |  20 +-
 dev/out_eval/pass8/p8_edgeline_diag.py             |   5 +-
 dev/out_eval/pass9/p10/p10_yield.py                |  14 +-
 pyproject.toml                                     |   5 +-
 .../_config.py                                     | 657 ++-------------------
 .../_config_conic.py                               |   8 +-
 .../_config_corridor.py                            |   8 +-
 .../_config_devices.py                             |   8 +-
 .../_config_evidence.py                            |   8 +-
 .../_config_grid.py                                |   8 +-
 .../_config_model.py                               |  45 ++
 .../_config_perspective.py                         |   8 +-
 .../_config_roadcontext.py                         |   8 +-
 .../_config_stages.py                              |   8 +-
 .../_config_treedetect.py                          |   8 +-
 .../_config_treeinstance.py                        |   8 +-
 .../_config_vegetation.py                          |   8 +-
 .../_model_devices.py                              | 140 +++++
 .../_model_grid.py                                 | 152 +++++
 .../_model_road.py                                 |  99 ++++
 .../_model_tree.py                                 | 180 ++++++
 .../config.py                                      |  44 +-
 .../ml.py                                          |   6 +-
 .../trees.py                                       |   5 +-
 tests/conftest.py                                  |  29 +
 tests/test_chroma_vegetation.py                    |  19 +-
 tests/test_config_split.py                         |  48 +-
 tests/test_detect.py                               |   3 +-
 tests/test_edgeline.py                             |   8 +-
 tests/test_tcs_ground.py                           |   3 +-
 tests/test_tree_instances.py                       |  45 +-
 32 files changed, 887 insertions(+), 734 deletions(-)
Importance #1: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -1,13 +1,16 @@
1"""Detector configuration.1"""Detector configuration.
22
3The 378-field :class:`DetectorConfig` and its ``from_mapping`` flattener are3The 379-field :class:`DetectorConfig` and its ``from_mapping`` flattener are
4split by section across the ``_config_<section>`` modules; this module4split by section across the ``_config_<section>`` modules; this module
5recombines them and re-exports every piece, so ``from .config import X``5recombines them and re-exports every piece, so ``from .config import X``
6keeps working for every name that used to live here.6keeps working for every name that used to live here.
7
8``DetectorConfig`` is the FLAT view the detector modules read
9(``config.ground_cell_m``); the NESTED document it is built from is validated
10by the :class:`VerticalSignsConfig` model tree in ``_config_model``.
7"""11"""
812
9from dataclasses import dataclass
10from pathlib import Path13from pathlib import Path
11from typing import Any14from typing import Any
1215
13from ._config import load_verticalsigns_config16from ._config import load_verticalsigns_config
Importance #2: src/iolabs_point_cloud_detection_verticalsigns/_config_model.py @@ -0,0 +1,45 @@
1"""The nested pydantic config model for the vertical-sign detector.
2
3``VerticalSignsConfig`` mirrors ``verticalsigns.default.json`` section for
4section 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 to
6the matching section model and a default to the packaged JSON.
7"""
8
9from iolabs.common import config_loader
10
11from . import _model_devices, _model_grid, _model_road, _model_tree
12
13
14class VerticalSignsConfig(config_loader.ConfigModel):
15 """Every configuration section of the vertical-sign detector."""
16
17 ground: _model_grid.GroundConfig = _model_grid.GroundConfig()
18 occupancy: _model_grid.OccupancyConfig = _model_grid.OccupancyConfig()
19 candidates: _model_grid.CandidatesConfig = _model_grid.CandidatesConfig()
20 clustering: _model_grid.ClusteringConfig = _model_grid.ClusteringConfig()
21 classification: _model_grid.ClassificationConfig = _model_grid.ClassificationConfig()
22 corridor: _model_grid.CorridorConfig = _model_grid.CorridorConfig()
23 context: _model_grid.ContextConfig = _model_grid.ContextConfig()
24 delineator: _model_devices.DelineatorConfig = _model_devices.DelineatorConfig()
25 sign_post: _model_devices.SignPostConfig = _model_devices.SignPostConfig()
26 panel: _model_devices.PanelConfig = _model_devices.PanelConfig()
27 gantry: _model_devices.GantryConfig = _model_devices.GantryConfig()
28 repetitive_row: _model_devices.RepetitiveRowConfig = _model_devices.RepetitiveRowConfig()
29 road_context: _model_road.RoadContextConfig = _model_road.RoadContextConfig()
30 edge_line: _model_road.EdgeLineConfig = _model_road.EdgeLineConfig()
31 field_stake: _model_devices.FieldStakeConfig = _model_devices.FieldStakeConfig()
32 marker_extract: _model_devices.MarkerExtractConfig = _model_devices.MarkerExtractConfig()
33 tree: _model_tree.TreeConfig = _model_tree.TreeConfig()
34 tree_detection: _model_tree.TreeDetectionConfig = _model_tree.TreeDetectionConfig()
35 chroma_vegetation: _model_tree.ChromaVegetationConfig = _model_tree.ChromaVegetationConfig()
36 vehicle: _model_grid.VehicleConfig = _model_grid.VehicleConfig()
37 views: _model_road.ViewsConfig = _model_road.ViewsConfig()
38 perspective: _model_road.PerspectiveConfig = _model_road.PerspectiveConfig()
39 tree_instance: _model_tree.TreeInstanceConfig = _model_tree.TreeInstanceConfig()
40 conic_gate: _model_tree.ConicGateConfig = _model_tree.ConicGateConfig()
41 conifer_rule: _model_tree.ConiferRuleConfig = _model_tree.ConiferRuleConfig()
42 radius: _model_grid.RadiusConfig = _model_grid.RadiusConfig()
43 rail_halfpost: _model_devices.RailHalfpostConfig = _model_devices.RailHalfpostConfig()
44 reject_rescue: _model_devices.RejectRescueConfig = _model_devices.RejectRescueConfig()
45 tcs_ground: _model_tree.TcsGroundConfig = _model_tree.TcsGroundConfig()
0
Importance #3: src/iolabs_point_cloud_detection_verticalsigns/_model_devices.py @@ -0,0 +1,140 @@
1"""Per-device acceptance gates and the two probe stages.
2
3One slice of the nested :class:`VerticalSignsConfig` model tree; the sections
4mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines
5the slices.
6"""
7
8from iolabs.common import config_loader
9
10
11class DelineatorConfig(config_loader.ConfigModel):
12 """Delineator (Leitpfosten) acceptance gates."""
13
14 h_min_m: float = 0.7
15 h_max_m: float = 1.5
16 max_footprint_m: float = 0.45
17 relaxed_footprint_m: float = 0.85
18 relaxed_min_verticality: float = 0.85
19 relaxed_max_ring_fill_ratio: float = 1.0
20 relaxed_min_hi_intensity_fraction: float = 0.15
21 min_hi_intensity_fraction: float = 0.08
22 min_points: int = 300
23
24
25class SignPostConfig(config_loader.ConfigModel):
26 """Sign-post and plate acceptance gates."""
27
28 max_len_minor_m: float = 0.8
29 h_min_m: float = 1.5
30 h_max_m: float = 6.0
31 min_continuity: float = 0.6
32 plate_hi_intensity_fraction: float = 0.4
33 plate_hi_intensity_fraction_weak: float = 0.3
34 plate_upper_surplus_ratio: float = 2.0
35 min_upper_half_surplus: float = 0.3
36 plate_min_core_rms_m: float = 0.1
37 max_plate_thickness_m: float = 0.15
38 bare_post_min_h_max_m: float = 4.5
39 bare_post_max_core_rms_m: float = 0.065
40 bare_post_min_verticality: float = 0.9
41 bare_post_min_points: int = 450
42
43
44class PanelConfig(config_loader.ConfigModel):
45 """Large panel acceptance gates."""
46
47 min_hi: float = 0.4
48 max_thickness_m: float = 0.2
49 h_min_m: float = 0.9
50 len_major_min_m: float = 1.5
51 len_major_max_m: float = 5.0
52
53
54class GantryConfig(config_loader.ConfigModel):
55 """Gantry leg and pairing gates."""
56
57 h_min_m: float = 4.5
58 len_major_m: float = 8.0
59 max_len_minor_m: float = 6.0
60 pair_station_tolerance_m: float = 5.0
61 pair_min_separation_m: float = 3.0
62 overhead_h_min_m: float = 4.5
63 pair_isolation_radius_m: float = 8.0
64
65
66class RepetitiveRowConfig(config_loader.ConfigModel):
67 """Repetitive-row (guardrail post series) grouping."""
68
69 min_members: int = 4
70 max_spacing_m: float = 5.0
71 max_perp_spread_m: float = 1.5
72 max_h_max_range_m: float = 0.7
73 member_max_len_major_m: float = 2.0
74 member_max_len_minor_m: float = 0.8
75
76
77class FieldStakeConfig(config_loader.ConfigModel):
78 """Field-stake row emission gates."""
79
80 row_emit: bool = True
81 min_members: int = 4
82 min_spacing_m: float = 2.0
83 max_spacing_m: float = 10.0
84 max_spacing_cv: float = 0.35
85
86
87class MarkerExtractConfig(config_loader.ConfigModel):
88 """Bright marker extraction from rejected clusters."""
89
90 min_len_major_m: float = 6.0
91 bright_h_min_m: float = 1.5
92 min_bright_points: int = 400
93 window_m: float = 2.5
94 min_bright_fraction: float = 0.45
95 min_h_max_m: float = 1.6
96 min_vertical_span_m: float = 0.5
97
98
99class RailHalfpostConfig(config_loader.ConfigModel):
100 """Guardrail half-post probe stage."""
101
102 band_lat_m: float = 0.8
103 band_z_hi_m: float = 1.5
104 band_z_lo_m: float = 0.15
105 cluster_cell_m: float = 0.15
106 dedupe_m: float = 1.5
107 enabled: bool = False
108 ground_cell_m: float = 2.0
109 ground_percentile: float = 10.0
110 h_max_m: float = 0.8
111 h_min_m: float = 0.2
112 max_lateral_m: float = 0.5
113 max_width_m: float = 0.2
114 min_emit_points: int = 8
115 min_points: int = 15
116 min_z_extent_m: float = 0.1
117 models_dir: str = ""
118 prime_min_records: int = 2
119 prime_min_sat: int = 1
120 sample_step_m: float = 0.1
121 saturation_intensity: float = 55000.0
122
123
124class RejectRescueConfig(config_loader.ConfigModel):
125 """Reject-rescue stage gates."""
126
127 accepted_exclusion_m: float = 2.0
128 enabled: bool = False
129 h_max_m: float = 1.6
130 h_min_m: float = 0.85
131 max_core_rms_m: float = 0.2
132 merge_radius_m: float = 1.0
133 min_continuity: float = 0.8
134 min_decile_fill: float = 0.6
135 min_h_over_width: float = 1.4
136 min_points: int = 30
137 min_records: int = 2
138 min_roadctx_sat: int = 17
139 min_verticality: float = 0.9
140 per_segment_cap: int = 0
0
Importance #4: src/iolabs_point_cloud_detection_verticalsigns/_model_grid.py @@ -0,0 +1,152 @@
1"""Grid, candidate, classification, radius and corridor config sections.
2
3One slice of the nested :class:`VerticalSignsConfig` model tree; the sections
4mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines
5the slices.
6"""
7
8from iolabs.common import config_loader
9
10
11class GroundConfig(config_loader.ConfigModel):
12 """Ground-model raster cell size and percentile."""
13
14 cell_m: float = 0.75
15 percentile: float = 8.0
16
17
18class OccupancyConfig(config_loader.ConfigModel):
19 """Occupancy grid used to find candidate cells."""
20
21 cell_m: float = 0.15
22
23
24class CandidatesConfig(config_loader.ConfigModel):
25 """Height band and seed-cell gates for candidate points."""
26
27 min_height_m: float = 0.3
28 max_height_m: float = 10.0
29 seed_min_vertical_span_m: float = 0.8
30 seed_min_h_max_m: float = 0.9
31 seed_bright_min_vertical_span_m: float = 0.45
32 seed_bright_min_h_max_m: float = 0.6
33 seed_bright_min_points: int = 3
34
35
36class ClusteringConfig(config_loader.ConfigModel):
37 """DBSCAN clustering of seed-cell centres."""
38
39 eps_m: float = 0.45
40 min_samples: int = 1
41 hull_margin_m: float = 0.2
42
43
44class ClassificationConfig(config_loader.ConfigModel):
45 """Cluster-level accept/reject gates and ML verifier wiring."""
46
47 continuity_bin_m: float = 0.25
48 reject_len_major_m: float = 6.0
49 reject_h_max_with_large_footprint_m: float = 4.5
50 min_continuity: float = 0.5
51 min_accept_h_max_m: float = 0.9
52 core_rms_bin_m: float = 0.25
53 core_rms_h_min_m: float = 0.3
54 core_rms_h_cap_m: float = 3.0
55 hi_intensity_all_points_percentile: float = 98.0
56 min_volumetric_density: float = 8000.0
57 pole_floating_min_h_min_m: float = 3.5
58 pole_isolated_radius_m: float = 8.0
59 dedup_radius_m: float = 0.8
60 emit_trees: bool = False
61 ml_verifier_enabled: bool = True
62 ml_veto_threshold: float = -1.0
63 ml_model_path: str = ""
64 lattice_admission: bool = True
65 lattice_max_seed_spacing_m: float = 60.0
66 lattice_max_skip: int = 6
67 lattice_max_spacing_resid: float = 0.15
68 lattice_min_anchors: int = 4
69 lattice_min_seed_spacing_m: float = 15.0
70 lattice_pool_h_max_max_m: float = 1.4
71 lattice_pool_h_max_min_m: float = 0.8
72 lattice_pool_max_len_major_m: float = 1.2
73 lattice_pool_max_plate_thickness_m: float = 0.05
74 lattice_pool_min_hi_seed_fraction: float = 0.15
75 lattice_pool_min_points: int = 20
76 lattice_pool_min_verticality: float = 0.85
77 lattice_snap_m: float = 3.0
78 ml_veto_requires_corridor: bool = True
79 robust_extent_hi_percentile: float = 99.0
80 robust_extent_lo_percentile: float = 1.0
81 robust_extent_stats: bool = True
82 robust_h_max_percentile: float = 98.0
83 seed_bright_percentile: float | None = 95.0
84 single_record_transient_veto: bool = True
85 transient_max_h_max_m: float = 2.5
86 transient_max_verticality: float = 0.3
87 transient_min_len_major_m: float = 2.0
88 veg_texture_min_hi_seed_fraction: float = 0.668
89 veg_texture_min_plate_thickness_m: float = 0.05
90 veg_texture_veto: bool = True
91 verticality_sentinel_fix: bool = True
92
93
94class RadiusConfig(config_loader.ConfigModel):
95 """Cylinder-radius fitting and crown-lobe estimation."""
96
97 crown_lobe_coverage_target: float = 0.95
98 crown_lobe_gap_m: float = 0.5
99 crown_lobe_max_count: int = 8
100 crown_lobe_min_points: int = 30
101 crown_lobe_min_samples: int = 10
102 crown_radius_percentile: float = 95.0
103 debug_cluster_points: bool = False
104 fit_bin_m: float = 0.25
105 fit_divergence_factor: float = 4.0
106 fit_min_arc_deg: float = 60.0
107 fit_min_bin_points: int = 8
108 fit_residual_abs_m: float = 0.03
109 fit_residual_frac: float = 0.35
110 pole_radius_max_m: float = 0.5
111 trunk_radius_max_m: float = 0.8
112
113
114class CorridorConfig(config_loader.ConfigModel):
115 """Road-corridor raster and on-carriageway gates."""
116
117 max_dist_to_road_m: float = 10.0
118 on_carriageway_dist_m: float = 0.25
119 on_carriageway_exempt_h_max_m: float = 4.5
120 density_min_points: float = 8.0
121 density_frac_p95: float = 0.06
122 density_max_points: float = 150.0
123 component_min_area_frac: float = 0.15
124 component_min_area_cells: int = 40
125 on_carriageway_road_fraction: float = 0.7
126 on_carriageway_bright_frac: float = 0.5
127 on_carriageway_delineator_max_len_major_m: float = 0.65
128 on_carriageway_delineator_min_verticality: float = 0.95
129
130
131class ContextConfig(config_loader.ConfigModel):
132 """Ring and forest neighbourhood context features."""
133
134 ring_r_inner_m: float = 0.5
135 ring_r_outer_m: float = 1.5
136 ring_h_min_m: float = 0.5
137 ring_h_max_m: float = 2.5
138 ring_max_fill_ratio: float = 2.0
139 ring_min_points: int = 40
140 forest_min_neighbors: int = 3
141 forest_radius_m: float = 8.0
142 forest_neighbor_min_h_max_m: float = 2.0
143
144
145class VehicleConfig(config_loader.ConfigModel):
146 """Vehicle-rejection envelope."""
147
148 h_min_m: float = 1.5
149 h_max_m: float = 4.5
150 len_major_m: float = 2.5
151 len_minor_m: float = 1.5
152 max_hi_intensity_fraction: float = 0.1
0
Importance #5: src/iolabs_point_cloud_detection_verticalsigns/_model_road.py @@ -0,0 +1,99 @@
1"""Road-context, edge-line and QC rendering config sections.
2
3One slice of the nested :class:`VerticalSignsConfig` model tree; the sections
4mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines
5the slices.
6"""
7
8from iolabs.common import config_loader
9
10
11class RoadContextConfig(config_loader.ConfigModel):
12 """Road-context saturation raster and XML carriageway votes."""
13
14 gate_enabled: bool = True
15 xml_enabled: bool = True
16 xml_min_agreement: float = 0.6
17 xml_vote_slack_m: float = 3.0
18 xml_max_distance_m: float = 60.0
19 xml_station_tolerance_m: float = 2.0
20 xml_station_step_m: float = 10.0
21 min_carriageway_width_m: float = 3.0
22 max_carriageway_width_m: float = 20.0
23 paint_fallback_enabled: bool = False
24 saturation_intensity: float = 55000.0
25 radius_m: float = 15.0
26 neighbour_span: int = 1
27 cache_dir: str = ""
28 min_neighbourhood_saturated: int = 1000
29
30
31class EdgeLineConfig(config_loader.ConfigModel):
32 """Edge-line paint detection and far-distance filtering."""
33
34 gate_enabled: bool = True
35 paint_max_height_m: float = 0.35
36 paint_min_height_m: float = -0.25
37 paint_intensity_percentile: float = 95.0
38 paint_subsample: int = 20
39 station_len_m: float = 10.0
40 min_window_returns: int = 2000
41 lateral_bin_m: float = 0.1
42 min_line_points: int = 40
43 max_line_width_m: float = 1.5
44 min_line_along_fill: float = 0.4
45 drive_line_bin_m: float = 0.5
46 min_band_width_m: float = 2.0
47 max_band_width_m: float = 9.0
48 inward_margin_m: float = 0.3
49 min_coverage_frac: float = 0.6
50 min_axis_contrast: float = 3.0
51 axis_search_radius_m: float = 40.0
52 axis_max_angle_cos: float = 0.8
53 axis_max_distance_m: float = 150.0
54 exempt_h_max_m: float = 4.5
55 reject_requires_transient: bool = True
56 transient_max_records: int = 1
57 far_filter_enabled: bool = True
58 far_max_distance_m: float = 30.0
59 far_include_lane_lines: bool = True
60 far_tier2_enabled: bool = True
61 far_tier2_distance_m: float = 15.0
62 far_tier2_max_saturation: int = 150
63 max_carriageway_width_m: float = 20.0
64 min_carriageway_width_m: float = 3.0
65 paint_fallback_enabled: bool = False
66 xml_enabled: bool = True
67 xml_max_distance_m: float = 60.0
68 xml_min_agreement: float = 0.6
69 xml_station_step_m: float = 10.0
70 xml_station_tolerance_m: float = 2.0
71 xml_vote_slack_m: float = 3.0
72
73
74class ViewsConfig(config_loader.ConfigModel):
75 """Rendered QC view cameras and image size."""
76
77 near_radius_m: float = 45.0
78 fov_deg: float = 55.0
79 splat: int = 2
80 image_width: int = 1100
81 image_height: int = 750
82 view_names: tuple[str, ...] = ("back", "side")
83
84
85class PerspectiveConfig(config_loader.ConfigModel):
86 """Perspective-projection QC overlay cameras and tolerances."""
87
88 depth_tol_m: float = 0.5
89 line_samples: int = 20
90 occluded_alpha: int = 90
91 solid_width_px: int = 3
92 halo_width_px: int = 6
93 base_marker_radius_px: int = 6
94 back_distance_m: float = 22.0
95 back_height_m: float = 4.0
96 context_distance_m: float = 40.0
97 context_height_m: float = 6.0
98 share_radius_m: float = 15.0
99 coverage_tol_m: float = 0.5
0
Importance #6: src/iolabs_point_cloud_detection_verticalsigns/_model_tree.py @@ -0,0 +1,180 @@
1"""Tree, vegetation and ground-filter config sections.
2
3One slice of the nested :class:`VerticalSignsConfig` model tree; the sections
4mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines
5the slices.
6"""
7
8from iolabs.common import config_loader
9
10
11class TreeConfig(config_loader.ConfigModel):
12 """Legacy tree crown hints."""
13
14 crown_h_min_m: float = 2.0
15 crown_max_area_m2: float = 4.0
16 isotropy_ratio: float = 0.75
17 greenness_hint: float = 0.45
18
19
20class TreeDetectionConfig(config_loader.ConfigModel):
21 """Tree detection stage: which blobs are emitted as trees."""
22
23 enabled: bool = False
24 max_dist_to_road_m: float = 20.0
25 seed_min_vertical_span_m: float = 1.5
26 seed_points_above_m: float = 2.0
27 eps_m: float = 1.5
28 min_samples: int = 3
29 hull_margin_m: float = 0.5
30 min_points: int = 60
31 bridge_max_on_road_fraction: float = 0.6
32 dedup_radius_m: float = 2.0
33 min_confidence: float = -1.0
34 model_path: str = ""
35 hedge_split_enabled: bool = False
36
37
38class TreeInstanceConfig(config_loader.ConfigModel):
39 """Tree instance splitting: how one blob is cut into instances."""
40
41 enabled: bool = False
42 local_ground_footprint_m: float = 15.0
43 local_ground_cell_m: float = 2.0
44 local_ground_percentile: float = 5.0
45 local_ground_window_m: float = 6.0
46 crown_base_bin_m: float = 0.25
47 crown_base_density_frac: float = 0.35
48 crown_base_run_bins: int = 3
49 crown_base_min_m: float = 1.2
50 stem_band_low_m: float = 0.5
51 stem_band_cap_m: float = 4.0
52 stem_band_min_thickness_m: float = 0.7
53 stem_eps_m: float = 0.35
54 stem_min_samples: int = 20
55 stem_max_diameter_m: float = 1.2
56 stem_min_vertical_reach: float = 0.5
57 stem_min_verticality: float = 0.6
58 stem_min_score: float = 0.45
59 stem_exg_bonus: float = 0.1
60 stem_merge_dist_m: float = 1.2
61 stem_uncertain_dist_m: float = 2.0
62 apex_fallback_enabled: bool = True
63 apex_cell_m: float = 0.5
64 apex_smooth_sigma_m: float = 0.7
65 apex_min_separation_m: float = 2.5
66 apex_min_prominence_m: float = 0.8
67 apex_min_height_m: float = 2.0
68 apex_trigger_span_m: float = 8.0
69 apex_seed_radius_m: float = 0.6
70 apex_confidence_scale: float = 0.6
71 min_points_per_instance: int = 1200
72 seedless_single_max_footprint_m: float = 10.0
73 seedless_single_min_height_m: float = 1.5
74 seedless_single_max_height_m: float = 25.0
75 seedless_single_confidence: float = 0.35
76 seedless_min_p95_h_m: float = 2.0
77 seedless_max_aspect: float = 2.5
78 seedless_min_points: int = 800
79 float_fragment_min_h_m: float = 3.0
80 float_fragment_p25_h_m: float = 4.0
81 min_tree_footprint_m: float = 1.5
82 max_tree_footprint_m: float = 60.0
83 megacluster_points: int = 1000000
84 planar_min_footprint_m: float = 12.0
85 planar_cell_m: float = 1.0
86 planar_max_spread_m: float = 0.3
87 planar_fraction_min: float = 0.55
88 hedge_max_ground_gap_m: float = 2.0
89 hedge_max_height_m: float = 7.5
90 hedge_min_length_m: float = 8.0
91 hedge_min_area_m2: float = 20.0
92 hedge_min_continuity: float = 0.75
93 hedge_continuity_bin_m: float = 1.0
94 hedge_max_top_relief_m: float = 1.5
95 hedge_max_seed_per_10m: float = 1.0
96 hedge_stem_score_min: float = 0.6
97 assign_voxel_m: float = 0.3
98 assign_max_gap_m: float = 1.25
99 assign_max_graph_dist_m: float = 30.0
100 max_claim_radius_m: float = 9.0
101 low_evidence_margin: float = 0.05
102 low_evidence_abstain: bool = False
103 min_cluster_points: int = 150
104 single_tree_footprint_m: float = 8.0
105 partial_abstain_fraction: float = 0.2
106 min_instance_points: int = 120
107 min_instance_fraction: float = 0.01
108 instance_max_linearity: float = 0.92
109 instance_min_minor_m: float = 1.0
110 instance_min_vertical_m: float = 1.5
111 instance_min_thickness_share: float = 0.02
112 confidence_seed_weight: float = 0.6
113 confidence_size_ref_points: float = 2000.0
114 confidence_max: float = 0.95
115 confidence_fallback_max: float = 0.9
116
117
118class ChromaVegetationConfig(config_loader.ConfigModel):
119 """ExG chromaticity vegetation veto."""
120
121 enabled: bool = False
122 exg_min: float = 0.155
123 exg_iqr_min: float = 0.21
124 max_hi_intensity_fraction: float = 0.08
125 min_change_of_curvature: float = 0.2
126 min_plate_thickness_m: float = 0.175
127
128
129class TcsGroundConfig(config_loader.ConfigModel):
130 """Tablecloth (TCS) ground pre-filter."""
131
132 cache_dir: str = ""
133 cell_m: float = 0.2
134 elev_scalar: float = 0.0
135 enabled: bool = False
136 max_elev_diff_m: float = 0.15
137 mechanism: str = "smrf_numpy"
138 pit_fill_enabled: bool = True
139 slope_threshold: float = 0.3
140 smrf_max_window_m: float = 6.0
141
142
143class ConicGateConfig(config_loader.ConfigModel):
144 """Conic-shape gate for cone/tree separation."""
145
146 apex_deg_max: float = 35.0
147 apex_deg_min: float = 5.0
148 change_of_curvature_min: float = 0.06
149 enabled: bool = False
150 h_max_min_m: float = 2.5
151 h_over_width_max: float = 12.0
152 h_over_width_min: float = 1.5
153 max_hi_intensity_fraction: float = 0.2
154 max_on_road_fraction: float = 0.6
155 min_crown_area_m2: float = 0.3
156 min_decile_fill_fraction: float = 0.8
157 omnivariance_min: float = 0.1
158 taper_slope_max: float = -0.4
159 taper_slope_robust_max: float = -0.3
160 texture_cue_enabled: bool = True
161
162
163class ConiferRuleConfig(config_loader.ConfigModel):
164 """Conifer acceptance rule."""
165
166 enabled: bool = False
167 h_max_min_m: float = 2.0
168 h_over_width_max: float = 15.0
169 h_over_width_min: float = 2.0
170 max_apex_ratio: float = 0.75
171 max_crown_base_frac: float = 0.55
172 max_crown_taper: float = -0.1
173 max_hi_intensity_fraction: float = 0.2
174 max_on_road_fraction: float = 0.6
175 max_stem_ratio: float = 2.2
176 max_volumetric_density: float = 380.0
177 min_change_of_curvature: float = 0.04
178 min_crown_area_m2: float = 0.2
179 min_decile_fill_fraction: float = 0.8
180 min_volumetric_density: float = 140.0
0
Importance #7: src/iolabs_point_cloud_detection_verticalsigns/_config_conic.py @@ -1,17 +1,17 @@
1"""The colour-free conic gate and the conifer rule that rides on it.1"""The colour-free conic gate and the conifer rule that rides on it.
22
3One slice of the flat 372-field ``DetectorConfig``, moved out of3One slice of the flat ``DetectorConfig``, moved out of
4``config.py`` verbatim. ``config.py`` recombines the slices and4``config.py`` verbatim. ``config.py`` recombines the slices and
5re-exports both names defined here.5re-exports both names defined here.
6"""6"""
77
8from dataclasses import dataclass
9from typing import Any8from typing import Any
109
10from iolabs.common import config_loader
1111
12@dataclass(frozen=True)12
13class ConicFields:13class ConicFields(config_loader.ConfigModel):
14 """The colour-free conic gate and the conifer rule that rides on it.14 """The colour-free conic gate and the conifer rule that rides on it.
1515
16 Metres unless stated otherwise.16 Metres unless stated otherwise.
17 """17 """
Importance #8: src/iolabs_point_cloud_detection_verticalsigns/_config_corridor.py @@ -1,19 +1,19 @@
1"""Road corridor rasterization and on-carriageway rejection.1"""Road corridor rasterization and on-carriageway rejection.
22
3Also plate planarity, the bright-panel class and the free-space ring.3Also plate planarity, the bright-panel class and the free-space ring.
44
5One slice of the flat 372-field ``DetectorConfig``, moved out of5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.7re-exports both names defined here.
8"""8"""
99
10from dataclasses import dataclass
11from typing import Any10from typing import Any
1211
12from iolabs.common import config_loader
1313
14@dataclass(frozen=True)14
15class CorridorFields:15class CorridorFields(config_loader.ConfigModel):
16 """Road corridor rasterization and on-carriageway rejection.16 """Road corridor rasterization and on-carriageway rejection.
1717
18 Also plate planarity, the bright-panel class and the free-space ring.18 Also plate planarity, the bright-panel class and the free-space ring.
1919
Importance #9: src/iolabs_point_cloud_detection_verticalsigns/_config_devices.py @@ -1,19 +1,19 @@
1"""Per-device thresholds for delineators, sign posts and gantries.1"""Per-device thresholds for delineators, sign posts and gantries.
22
3Also isolated-floating-pole rejection and duplicate suppression.3Also isolated-floating-pole rejection and duplicate suppression.
44
5One slice of the flat 372-field ``DetectorConfig``, moved out of5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.7re-exports both names defined here.
8"""8"""
99
10from dataclasses import dataclass
11from typing import Any10from typing import Any
1211
12from iolabs.common import config_loader
1313
14@dataclass(frozen=True)14
15class DeviceFields:15class DeviceFields(config_loader.ConfigModel):
16 """Per-device thresholds for delineators, sign posts and gantries.16 """Per-device thresholds for delineators, sign posts and gantries.
1717
18 Also isolated-floating-pole rejection and duplicate suppression.18 Also isolated-floating-pole rejection and duplicate suppression.
1919
Importance #10: src/iolabs_point_cloud_detection_verticalsigns/_config_evidence.py @@ -3,19 +3,19 @@
3Covers the verticality sentinel, tier-2 robust extent statistics,3Covers the verticality sentinel, tier-2 robust extent statistics,
4retroreflectivity references, the single-record transient and4retroreflectivity references, the single-record transient and
5vegetation-texture vetoes, the delineator lattice and tree emission.5vegetation-texture vetoes, the delineator lattice and tree emission.
66
7One slice of the flat 372-field ``DetectorConfig``, moved out of7One slice of the flat ``DetectorConfig``, moved out of
8``config.py`` verbatim. ``config.py`` recombines the slices and8``config.py`` verbatim. ``config.py`` recombines the slices and
9re-exports both names defined here.9re-exports both names defined here.
10"""10"""
1111
12from dataclasses import dataclass
13from typing import Any12from typing import Any
1413
14from iolabs.common import config_loader
1515
16@dataclass(frozen=True)16
17class EvidenceFields:17class EvidenceFields(config_loader.ConfigModel):
18 """Evidence-level thresholds: sentinels, vetoes and reference percentiles.18 """Evidence-level thresholds: sentinels, vetoes and reference percentiles.
1919
20 Covers the verticality sentinel, tier-2 robust extent statistics,20 Covers the verticality sentinel, tier-2 robust extent statistics,
21 retroreflectivity references, the single-record transient and21 retroreflectivity references, the single-record transient and
Importance #11: src/iolabs_point_cloud_detection_verticalsigns/_config_grid.py @@ -1,19 +1,19 @@
1"""Ground, occupancy grid, candidate band and clustering thresholds.1"""Ground, occupancy grid, candidate band and clustering thresholds.
22
3Also the first classification gates and vehicle rejection.3Also the first classification gates and vehicle rejection.
44
5One slice of the flat 372-field ``DetectorConfig``, moved out of5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.7re-exports both names defined here.
8"""8"""
99
10from dataclasses import dataclass
11from typing import Any10from typing import Any
1211
12from iolabs.common import config_loader
1313
14@dataclass(frozen=True)14
15class GridFields:15class GridFields(config_loader.ConfigModel):
16 """Ground, occupancy grid, candidate band and clustering thresholds.16 """Ground, occupancy grid, candidate band and clustering thresholds.
1717
18 Also the first classification gates and vehicle rejection.18 Also the first classification gates and vehicle rejection.
1919
Importance #12: src/iolabs_point_cloud_detection_verticalsigns/_config_perspective.py @@ -1,17 +1,17 @@
1"""Perspective-projection QC overlay cameras and coverage tolerances.1"""Perspective-projection QC overlay cameras and coverage tolerances.
22
3One slice of the flat 372-field ``DetectorConfig``, moved out of3One slice of the flat ``DetectorConfig``, moved out of
4``config.py`` verbatim. ``config.py`` recombines the slices and4``config.py`` verbatim. ``config.py`` recombines the slices and
5re-exports both names defined here.5re-exports both names defined here.
6"""6"""
77
8from dataclasses import dataclass
9from typing import Any8from typing import Any
109
10from iolabs.common import config_loader
1111
12@dataclass(frozen=True)12
13class PerspectiveFields:13class PerspectiveFields(config_loader.ConfigModel):
14 """Perspective-projection QC overlay cameras and coverage tolerances.14 """Perspective-projection QC overlay cameras and coverage tolerances.
1515
16 Metres unless stated otherwise.16 Metres unless stated otherwise.
17 """17 """
Importance #13: src/iolabs_point_cloud_detection_verticalsigns/_config_roadcontext.py @@ -1,19 +1,19 @@
1"""Road-context gate, driven-lane band and repetitive-row rejection.1"""Road-context gate, driven-lane band and repetitive-row rejection.
22
3Also field-stake rows and embedded-marker extraction.3Also field-stake rows and embedded-marker extraction.
44
5One slice of the flat 378-field ``DetectorConfig``, moved out of5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.7re-exports both names defined here.
8"""8"""
99
10from dataclasses import dataclass
11from typing import Any10from typing import Any
1211
12from iolabs.common import config_loader
1313
14@dataclass(frozen=True)14
15class RoadContextFields:15class RoadContextFields(config_loader.ConfigModel):
16 """Road-context gate, driven-lane band and repetitive-row rejection.16 """Road-context gate, driven-lane band and repetitive-row rejection.
1717
18 Also field-stake rows and embedded-marker extraction.18 Also field-stake rows and embedded-marker extraction.
1919
Importance #14: src/iolabs_point_cloud_detection_verticalsigns/_config_stages.py @@ -2,19 +2,19 @@
22
3The rail-relative half-post pass, the reject-rescue second look and3The rail-relative half-post pass, the reject-rescue second look and
4the ML verifier.4the ML verifier.
55
6One slice of the flat 372-field ``DetectorConfig``, moved out of6One slice of the flat ``DetectorConfig``, moved out of
7``config.py`` verbatim. ``config.py`` recombines the slices and7``config.py`` verbatim. ``config.py`` recombines the slices and
8re-exports both names defined here.8re-exports both names defined here.
9"""9"""
1010
11from dataclasses import dataclass
12from typing import Any11from typing import Any
1312
13from iolabs.common import config_loader
1414
15@dataclass(frozen=True)15
16class StageFields:16class StageFields(config_loader.ConfigModel):
17 """Opt-in post-classification stages.17 """Opt-in post-classification stages.
1818
19 The rail-relative half-post pass, the reject-rescue second look and19 The rail-relative half-post pass, the reject-rescue second look and
20 the ML verifier.20 the ML verifier.
Importance #15: src/iolabs_point_cloud_detection_verticalsigns/_config_treedetect.py @@ -1,17 +1,17 @@
1"""Experimental tree detection and TCS ground filtering of the DEM input.1"""Experimental tree detection and TCS ground filtering of the DEM input.
22
3One slice of the flat 372-field ``DetectorConfig``, moved out of3One slice of the flat ``DetectorConfig``, moved out of
4``config.py`` verbatim. ``config.py`` recombines the slices and4``config.py`` verbatim. ``config.py`` recombines the slices and
5re-exports both names defined here.5re-exports both names defined here.
6"""6"""
77
8from dataclasses import dataclass
9from typing import Any8from typing import Any
109
10from iolabs.common import config_loader
1111
12@dataclass(frozen=True)12
13class TreeDetectionFields:13class TreeDetectionFields(config_loader.ConfigModel):
14 """Experimental tree detection and TCS ground filtering of the DEM input.14 """Experimental tree detection and TCS ground filtering of the DEM input.
1515
16 Metres unless stated otherwise.16 Metres unless stated otherwise.
17 """17 """
Importance #16: src/iolabs_point_cloud_detection_verticalsigns/_config_treeinstance.py @@ -1,16 +1,16 @@
1"""Per-point tree instance splitting of merged canopy blobs.1"""Per-point tree instance splitting of merged canopy blobs.
22
3One slice of the flat 372-field ``DetectorConfig``. ``config.py``3One slice of the flat ``DetectorConfig``. ``config.py``
4recombines the slices and re-exports both names defined here.4recombines the slices and re-exports both names defined here.
5"""5"""
66
7from dataclasses import dataclass
8from typing import Any7from typing import Any
98
9from iolabs.common import config_loader
1010
11@dataclass(frozen=True)11
12class TreeInstanceFields:12class TreeInstanceFields(config_loader.ConfigModel):
13 """Stem-seeded instance splitting of a single ``type: "tree"`` detection.13 """Stem-seeded instance splitting of a single ``type: "tree"`` detection.
1414
15 Metres unless stated otherwise.15 Metres unless stated otherwise.
16 """16 """
Importance #17: src/iolabs_point_cloud_detection_verticalsigns/_config_vegetation.py @@ -1,19 +1,19 @@
1"""Tree rejection, chromaticity vegetation reject and radius fitting.1"""Tree rejection, chromaticity vegetation reject and radius fitting.
22
3Also core compactness and the crown-circle overlay knobs.3Also core compactness and the crown-circle overlay knobs.
44
5One slice of the flat 372-field ``DetectorConfig``, moved out of5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.7re-exports both names defined here.
8"""8"""
99
10from dataclasses import dataclass
11from typing import Any10from typing import Any
1211
12from iolabs.common import config_loader
1313
14@dataclass(frozen=True)14
15class VegetationFields:15class VegetationFields(config_loader.ConfigModel):
16 """Tree rejection, chromaticity vegetation reject and radius fitting.16 """Tree rejection, chromaticity vegetation reject and radius fitting.
1717
18 Also core compactness and the crown-circle overlay knobs.18 Also core compactness and the crown-circle overlay knobs.
1919
Importance #18: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -1,13 +1,16 @@
1"""Detector configuration.1"""Detector configuration.
22
3The 378-field :class:`DetectorConfig` and its ``from_mapping`` flattener are3The 379-field :class:`DetectorConfig` and its ``from_mapping`` flattener are
4split by section across the ``_config_<section>`` modules; this module4split by section across the ``_config_<section>`` modules; this module
5recombines them and re-exports every piece, so ``from .config import X``5recombines them and re-exports every piece, so ``from .config import X``
6keeps working for every name that used to live here.6keeps working for every name that used to live here.
7
8``DetectorConfig`` is the FLAT view the detector modules read
9(``config.ground_cell_m``); the NESTED document it is built from is validated
10by the :class:`VerticalSignsConfig` model tree in ``_config_model``.
7"""11"""
812
9from dataclasses import dataclass
10from pathlib import Path13from pathlib import Path
11from typing import Any14from typing import Any
1215
13from ._config import load_verticalsigns_config16from ._config import load_verticalsigns_config
Importance #19: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -49,16 +52,15 @@
49 "perspective_kwargs",52 "perspective_kwargs",
50]53]
5154
5255
53@dataclass(frozen=True)
54class DetectorConfig( # noqa: D101 - docstring below, after the base list56class DetectorConfig( # noqa: D101 - docstring below, after the base list
55 # The bases are listed in REVERSE section order ON PURPOSE: dataclasses57 # The bases are listed in REVERSE section order ON PURPOSE: both
56 # collects fields by walking the MRO backwards, so this ordering58 # dataclasses and pydantic collect fields by walking the MRO backwards, so
57 # reproduces the original single-class field order exactly (ground first,59 # this ordering reproduces the original single-class field order exactly
58 # then perspective, then the slices added since). Reordering these lines60 # (ground first, then perspective, then the slices added since).
59 # reorders the fields, so a NEW slice goes at the TOP of this list to have61 # Reordering these lines reorders the fields, so a NEW slice goes at the
60 # its fields appended at the end.62 # TOP of this list to have its fields appended at the end.
61 TreeInstanceFields,63 TreeInstanceFields,
62 PerspectiveFields,64 PerspectiveFields,
63 ConicFields,65 ConicFields,
64 TreeDetectionFields,66 TreeDetectionFields,
Importance #20: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -75,9 +77,9 @@
75 @classmethod77 @classmethod
76 def from_mapping(cls, config: dict[str, Any]) -> "DetectorConfig":78 def from_mapping(cls, config: dict[str, Any]) -> "DetectorConfig":
77 """Builds a DetectorConfig by flattening the nested config sections.79 """Builds a DetectorConfig by flattening the nested config sections.
7880
79 Only keys present in a section override the corresponding dataclass81 Only keys present in a section override the corresponding model
80 default, so a partial (or default) config reproduces the built-in82 default, so a partial (or default) config reproduces the built-in
81 thresholds exactly.83 thresholds exactly.
8284
83 Args:85 Args:
Importance #21: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -101,8 +103,30 @@
101 **perspective_kwargs(config, defaults),103 **perspective_kwargs(config, defaults),
102 **tree_instance_kwargs(config, defaults),104 **tree_instance_kwargs(config, defaults),
103 )105 )
104106
107 def with_overrides(self, **overrides: Any) -> "DetectorConfig":
108 """Return a copy of this config with *overrides* applied.
109
110 ``model_copy(update=...)`` skips validation, so a misspelled name would
111 be attached as a new attribute and the intended threshold would keep
112 its default. The names are therefore checked here, reproducing the
113 ``TypeError`` that ``dataclasses.replace`` used to raise.
114
115 Args:
116 overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``.
117
118 Returns:
119 A new frozen config carrying *overrides*.
120
121 Raises:
122 ValueError: An override names a field this config does not declare.
123 """
124 unknown = sorted(set(overrides) - set(type(self).model_fields))
125 if unknown:
126 raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}")
127 return self.model_copy(update=overrides)
128
105 @classmethod129 @classmethod
106 def load(cls, config_path: str | Path | None = None) -> "DetectorConfig":130 def load(cls, config_path: str | Path | None = None) -> "DetectorConfig":
107 """Load config from the packaged defaults merged with an optional user JSON."""131 """Load config from the packaged defaults merged with an optional user JSON."""
108 return cls.from_mapping(load_verticalsigns_config(config_path))132 return cls.from_mapping(load_verticalsigns_config(config_path))
Importance #22: dev/out_eval/pass8/p8_edgeline_diag.py @@ -11,9 +11,8 @@
1111
12from __future__ import annotations12from __future__ import annotations
1313
14import argparse14import argparse
15import dataclasses
16from pathlib import Path15from pathlib import Path
1716
18import numpy as np17import numpy as np
1918
Importance #23: dev/out_eval/pass8/p8_edgeline_diag.py @@ -217,10 +216,10 @@
217 header = "variant".ljust(20) + "".join(s.rjust(9) for s in segments)216 header = "variant".ljust(20) + "".join(s.rjust(9) for s in segments)
218 print(header)217 print(header)
219 for name, overrides in variants.items():218 for name, overrides in variants.items():
220 try:219 try:
221 cfg = dataclasses.replace(config, **overrides)220 cfg = config.with_overrides(**overrides)
222 except TypeError as exc:221 except ValueError as exc:
223 print(f"{name.ljust(20)} SKIP ({exc})")222 print(f"{name.ljust(20)} SKIP ({exc})")
224 continue223 continue
225 cells = []224 cells = []
226 for segment in segments:225 for segment in segments:
Importance #24: dev/out_eval/pass9/p10/p10_yield.py @@ -80,40 +80,36 @@
8080
8181
82def fix_delineator_h_ceiling(h):82def fix_delineator_h_ceiling(h):
83 def _f(f, cfg):83 def _f(f, cfg):
84 return f, dataclasses.replace(cfg, delineator_h_max_m=h)84 return f, cfg.with_overrides(delineator_h_max_m=h)
8585
86 return _f86 return _f
8787
8888
89def fix_min_points(n):89def fix_min_points(n):
90 def _f(f, cfg):90 def _f(f, cfg):
91 return f, dataclasses.replace(cfg, delineator_min_points=n)91 return f, cfg.with_overrides(delineator_min_points=n)
9292
93 return _f93 return _f
9494
9595
96def fix_relaxed_footprint(m):96def fix_relaxed_footprint(m):
97 def _f(f, cfg):97 def _f(f, cfg):
98 return f, dataclasses.replace(cfg, delineator_relaxed_footprint_m=m)98 return f, cfg.with_overrides(delineator_relaxed_footprint_m=m)
9999
100 return _f100 return _f
101101
102102
103def fix_combo_sentinel_hceiling(f, cfg):103def fix_combo_sentinel_hceiling(f, cfg):
104 cfg2 = dataclasses.replace(cfg, delineator_h_max_m=1.8)104 cfg2 = cfg.with_overrides(delineator_h_max_m=1.8)
105 if f.len_minor > SENTINEL and f.verticality == 0.0:105 if f.len_minor > SENTINEL and f.verticality == 0.0:
106 return dataclasses.replace(f, verticality=1.0), cfg2106 return dataclasses.replace(f, verticality=1.0), cfg2
107 return f, cfg2107 return f, cfg2
108108
109109
110def fix_all(f, cfg):110def fix_all(f, cfg):
111 cfg2 = dataclasses.replace(111 cfg2 = cfg.with_overrides(delineator_h_max_m=1.8, delineator_min_points=150)
112 cfg,
113 delineator_h_max_m=1.8,
114 delineator_min_points=150,
115 )
116 if f.len_minor > SENTINEL and f.verticality == 0.0:112 if f.len_minor > SENTINEL and f.verticality == 0.0:
117 return dataclasses.replace(f, verticality=1.0), cfg2113 return dataclasses.replace(f, verticality=1.0), cfg2
118 return f, cfg2114 return f, cfg2
119115
Importance #25: src/iolabs_point_cloud_detection_verticalsigns/ml.py @@ -680,10 +680,8 @@
680 forest stand merges into one large low-curvature canopy blob that the680 forest stand merges into one large low-curvature canopy blob that the
681 fine-DBSCAN sign-path trees never produce, so the classifier must see it.681 fine-DBSCAN sign-path trees never produce, so the classifier must see it.
682 Lazy imports avoid a heavy import chain at module load.682 Lazy imports avoid a heavy import chain at module load.
683 """683 """
684 from dataclasses import replace
685
686 import numpy as np684 import numpy as np
687685
688 from . import detect as _detect686 from . import detect as _detect
689 from .corridor import build_road_corridor687 from .corridor import build_road_corridor
Importance #26: src/iolabs_point_cloud_detection_verticalsigns/ml.py @@ -715,10 +713,10 @@
715 )713 )
716 mask = select_tree_seed_cells(counts, vspan, hmax, config)714 mask = select_tree_seed_cells(counts, vspan, hmax, config)
717 if not np.any(mask):715 if not np.any(mask):
718 return []716 return []
719 tcfg = replace(717 tcfg = config.with_overrides(
720 config, cluster_eps_m=config.tree_eps_m,718 cluster_eps_m=config.tree_eps_m,
721 cluster_min_samples=config.tree_min_samples,719 cluster_min_samples=config.tree_min_samples,
722 cluster_hull_margin_m=config.tree_hull_margin_m,720 cluster_hull_margin_m=config.tree_hull_margin_m,
723 )721 )
724 clusters = cluster_candidates(722 clusters = cluster_candidates(
Importance #27: src/iolabs_point_cloud_detection_verticalsigns/trees.py @@ -19,10 +19,8 @@
19"""19"""
2020
21from __future__ import annotations21from __future__ import annotations
2222
23from dataclasses import replace
24
25import numpy as np23import numpy as np
26from iolabs.logstash import get_props_logger24from iolabs.logstash import get_props_logger
2725
28from ._log_props import LOG_PROPS26from ._log_props import LOG_PROPS
Importance #28: src/iolabs_point_cloud_detection_verticalsigns/trees.py @@ -117,10 +115,9 @@
117 if not np.any(seed_mask):115 if not np.any(seed_mask):
118 return []116 return []
119117
120 # Coarser DBSCAN via a shallow config clone (own eps / min_samples / margin).118 # Coarser DBSCAN via a shallow config clone (own eps / min_samples / margin).
121 tree_cfg = replace(119 tree_cfg = config.with_overrides(
122 config,
123 cluster_eps_m=config.tree_eps_m,120 cluster_eps_m=config.tree_eps_m,
124 cluster_min_samples=config.tree_min_samples,121 cluster_min_samples=config.tree_min_samples,
125 cluster_hull_margin_m=config.tree_hull_margin_m,122 cluster_hull_margin_m=config.tree_hull_margin_m,
126 )123 )
Importance #29: tests/conftest.py @@ -0,0 +1,29 @@
1"""Shared fixtures for the vertical-sign detector tests."""
2
3from collections.abc import Callable
4from typing import Any
5
6import pytest
7from iolabs.common import config_loader
8
9
10def _section_values(model: type[config_loader.ConfigModel]) -> dict[str, Any]:
11 """Return one valid non-default value per field of *model*."""
12 values: dict[str, Any] = {}
13 for name, field in model.model_fields.items():
14 annotation = field.annotation
15 if annotation is bool:
16 values[name] = not field.default
17 elif annotation is int:
18 values[name] = int(field.default) + 1
19 elif annotation is str:
20 values[name] = f"{field.default}_x"
21 else:
22 values[name] = 0.5
23 return values
24
25
26@pytest.fixture
27def section_values() -> Callable[[type[config_loader.ConfigModel]], dict[str, Any]]:
28 """Return a builder for a full override of one config section."""
29 return _section_values
0
Importance #30: tests/test_chroma_vegetation.py @@ -8,15 +8,14 @@
8"""8"""
99
10from __future__ import annotations10from __future__ import annotations
1111
12import dataclasses
13import json12import json
1413
15import numpy as np14import numpy as np
16import pytest15import pytest
1716
18from iolabs_point_cloud_detection_verticalsigns import _config17from iolabs_point_cloud_detection_verticalsigns import _config, _model_tree
19from iolabs_point_cloud_detection_verticalsigns.classify import (18from iolabs_point_cloud_detection_verticalsigns.classify import (
20 CHROMA_VETOABLE_TYPES,19 CHROMA_VETOABLE_TYPES,
21 apply_tree_emission,20 apply_tree_emission,
22 classify_cluster,21 classify_cluster,
Importance #31: tests/test_chroma_vegetation.py @@ -77,9 +76,9 @@
77 return ClusterFeatures(**base)76 return ClusterFeatures(**base)
7877
7978
80def _enabled(**overrides) -> DetectorConfig:79def _enabled(**overrides) -> DetectorConfig:
81 return dataclasses.replace(DetectorConfig(), chroma_veg_enabled=True, **overrides)80 return DetectorConfig().with_overrides(chroma_veg_enabled=True, **overrides)
8281
8382
84# --- the rule does its job -------------------------------------------------83# --- the rule does its job -------------------------------------------------
8584
Importance #32: tests/test_chroma_vegetation.py @@ -272,9 +271,9 @@
272# --- wiring ----------------------------------------------------------------271# --- wiring ----------------------------------------------------------------
273272
274273
275def test_emit_trees_promotes_the_reason() -> None:274def test_emit_trees_promotes_the_reason() -> None:
276 config = dataclasses.replace(DetectorConfig(), emit_trees=True)275 config = DetectorConfig().with_overrides(emit_trees=True)
277 assert apply_tree_emission(None, "chroma_vegetation", config) == (276 assert apply_tree_emission(None, "chroma_vegetation", config) == (
278 "tree",277 "tree",
279 "chroma_vegetation",278 "chroma_vegetation",
280 )279 )
Importance #33: tests/test_chroma_vegetation.py @@ -290,23 +289,23 @@
290 assert config.chroma_veg_exg_iqr_min == DetectorConfig().chroma_veg_exg_iqr_min289 assert config.chroma_veg_exg_iqr_min == DetectorConfig().chroma_veg_exg_iqr_min
291290
292291
293def test_config_rejects_an_unknown_key_in_the_new_section(tmp_path) -> None:292def test_config_rejects_an_unknown_key_in_the_new_section(tmp_path) -> None:
294 """Pins the _ALLOWED_BY_SECTION wiring.293 """Pins the ChromaVegetationConfig wiring.
295294
296 Without this, deleting the "chroma_vegetation" entry from that mapping295 Without this, dropping the "chroma_vegetation" field from the model tree
297 leaves the whole suite green while silently disabling validation for the296 leaves the whole suite green while silently disabling validation for the
298 section -- a typo'd threshold would then be accepted and ignored.297 section -- a typo'd threshold would then be accepted and ignored.
299 """298 """
300 path = tmp_path / "override.json"299 path = tmp_path / "override.json"
301 path.write_text(json.dumps({"chroma_vegetation": {"exg_minimum": 0.2}}))300 path.write_text(json.dumps({"chroma_vegetation": {"exg_minimum": 0.2}}))
302 with pytest.raises(_config.ConfigError):301 with pytest.raises(_config.VerticalSignsConfigError):
303 _config.load_verticalsigns_config(path)302 _config.load_verticalsigns_config(path)
304303
305304
306def test_config_accepts_every_documented_key(tmp_path) -> None:305def test_config_accepts_every_documented_key(tmp_path, section_values) -> None:
307 """The other half: no allowlisted key is rejected."""306 """The other half: no modelled key is rejected."""
308 section = {k: 0.1 for k in _config.ALLOWED_CHROMA_VEGETATION_KEYS}307 section = section_values(_model_tree.ChromaVegetationConfig)
309 section["enabled"] = True308 section["enabled"] = True
310 path = tmp_path / "override.json"309 path = tmp_path / "override.json"
311 path.write_text(json.dumps({"chroma_vegetation": section}))310 path.write_text(json.dumps({"chroma_vegetation": section}))
312 assert _config.load_verticalsigns_config(path)["chroma_vegetation"]["enabled"]311 assert _config.load_verticalsigns_config(path)["chroma_vegetation"]["enabled"]
Importance #34: tests/test_config_split.py @@ -6,16 +6,18 @@
6things must stay true for that split to be invisible to callers:6things must stay true for that split to be invisible to callers:
77
8* every field is still reachable from the nested config document,8* every field is still reachable from the nested config document,
9* the slices partition the fields (no field lost, none declared twice),9* the slices partition the fields (no field lost, none declared twice),
10* an absent key still falls back to the dataclass default.10* an absent key still falls back to the model default.
11"""11"""
1212
13import dataclasses
14import json13import json
15import re14import re
16from pathlib import Path15from pathlib import Path
1716
17from iolabs.common import config_loader
18
19from iolabs_point_cloud_detection_verticalsigns import _config_model
18from iolabs_point_cloud_detection_verticalsigns._config import load_default_config20from iolabs_point_cloud_detection_verticalsigns._config import load_default_config
19from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig21from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig
2022
21CONFIG_PY = (23CONFIG_PY = (
Importance #35: tests/test_config_split.py @@ -45,9 +47,9 @@
45 """47 """
46 section_locals: dict[str, str] = {}48 section_locals: dict[str, str] = {}
47 document: dict[str, dict] = {}49 document: dict[str, dict] = {}
48 expected: dict[str, object] = {}50 expected: dict[str, object] = {}
49 fields = {f.name: f for f in dataclasses.fields(DetectorConfig)}51 fields = DetectorConfig.model_fields
5052
51 for path in sorted(CONFIG_PY.parent.glob("_config_*.py")):53 for path in sorted(CONFIG_PY.parent.glob("_config_*.py")):
52 text = path.read_text()54 text = path.read_text()
53 section_locals.update(55 section_locals.update(
Importance #36: tests/test_config_split.py @@ -69,9 +71,9 @@
69 wrong = {n: (getattr(built, n), v) for n, v in expected.items() if getattr(built, n) != v}71 wrong = {n: (getattr(built, n), v) for n, v in expected.items() if getattr(built, n) != v}
70 assert not wrong72 assert not wrong
7173
7274
73def test_absent_sections_fall_back_to_the_dataclass_defaults() -> None:75def test_absent_sections_fall_back_to_the_model_defaults() -> None:
74 assert DetectorConfig.from_mapping({}) == DetectorConfig()76 assert DetectorConfig.from_mapping({}) == DetectorConfig()
7577
7678
77def test_a_partial_section_only_overrides_the_keys_it_carries() -> None:79def test_a_partial_section_only_overrides_the_keys_it_carries() -> None:
Importance #37: tests/test_config_split.py @@ -80,8 +82,46 @@
80 assert built.ground_percentile == DetectorConfig().ground_percentile82 assert built.ground_percentile == DetectorConfig().ground_percentile
81 assert built.perspective_coverage_tol_m == DetectorConfig().perspective_coverage_tol_m83 assert built.perspective_coverage_tol_m == DetectorConfig().perspective_coverage_tol_m
8284
8385
86def test_every_mapped_key_exists_in_the_nested_model() -> None:
87 """A flat field wired to a section key the model does not declare is dead.
88
89 ``load_verticalsigns_config`` validates against the model, so such a key is
90 rejected for a user config and can only ever hold its flat default.
91 """
92 document, _ = _saturating_config()
93 merged = config_loader.deep_merge_dicts(load_default_config(), document)
94 assert _config_model.VerticalSignsConfig.model_validate(merged)
95
96
84def test_the_packaged_defaults_round_trip() -> None:97def test_the_packaged_defaults_round_trip() -> None:
85 packaged = load_default_config()98 packaged = load_default_config()
86 assert json.dumps(packaged) # it is a plain JSON document99 assert json.dumps(packaged) # it is a plain JSON document
87 assert DetectorConfig.from_mapping(packaged) == DetectorConfig.load()100 assert DetectorConfig.from_mapping(packaged) == DetectorConfig.load()
101
102
103def test_the_packaged_defaults_equal_the_flat_defaults() -> None:
104 """The nested model and the flat slices must not drift apart.
105
106 The nested :class:`VerticalSignsConfig` sections and the flat
107 ``DetectorConfig`` slices declare the same numbers twice, so a value
108 changed on one side only is a silent config bug: ``DetectorConfig()`` (what
109 tests and ad-hoc calls build) would disagree with ``DetectorConfig.load()``
110 (what the detector runs).
111 """
112 assert DetectorConfig.from_mapping(load_default_config()) == DetectorConfig()
113
114
115def test_with_overrides_rejects_a_misspelled_field() -> None:
116 """A typo must not become a new attribute while the threshold keeps its default.
117
118 ``model_copy(update=...)`` skips validation, so this is the only thing
119 standing between a misspelled override and a silently ignored threshold.
120 """
121 assert DetectorConfig().with_overrides(cluster_eps_m=0.9).cluster_eps_m == 0.9
122 try:
123 DetectorConfig().with_overrides(cluster_eps=0.9)
124 except ValueError as exc:
125 assert "cluster_eps" in str(exc)
126 else: # pragma: no cover - the failure the test exists to catch
127 raise AssertionError("a misspelled field name was accepted")
Importance #38: tests/test_detect.py @@ -1,6 +1,5 @@
1import csv1import csv
2import dataclasses
3import io2import io
43
5import numpy as np4import numpy as np
65
Importance #39: tests/test_detect.py @@ -14,9 +13,9 @@
14 assign_record_coverage,13 assign_record_coverage,
15)14)
16from iolabs_point_cloud_detection_verticalsigns.features import ClusterFeatures15from iolabs_point_cloud_detection_verticalsigns.features import ClusterFeatures
1716
18_CFG = dataclasses.replace(DetectorConfig(), tree_crown_h_min_m=2.5)17_CFG = DetectorConfig().with_overrides(tree_crown_h_min_m=2.5)
1918
2019
21def _feat(**overrides) -> ClusterFeatures:20def _feat(**overrides) -> ClusterFeatures:
22 base = dict(21 base = dict(
Importance #40: tests/test_edgeline.py @@ -8,10 +8,8 @@
8"""8"""
99
10from __future__ import annotations10from __future__ import annotations
1111
12import dataclasses
13
14import numpy as np12import numpy as np
1513
16from iolabs_point_cloud_detection_verticalsigns.classify import (14from iolabs_point_cloud_detection_verticalsigns.classify import (
17 apply_edge_line_gate,15 apply_edge_line_gate,
Importance #41: tests/test_edgeline.py @@ -279,12 +277,10 @@
279 axis = _axis()277 axis = _axis()
280 coverages = []278 coverages = []
281 for pct in (93.0, 95.0, 97.0):279 for pct in (93.0, 95.0, 97.0):
282 for fill in (0.3, 0.4, 0.5):280 for fill in (0.3, 0.4, 0.5):
283 config = dataclasses.replace(281 config = DetectorConfig().with_overrides(
284 DetectorConfig(),282 edgeline_paint_intensity_percentile=pct, edgeline_min_line_along_fill=fill
285 edgeline_paint_intensity_percentile=pct,
286 edgeline_min_line_along_fill=fill,
287 )283 )
288 coverages.append(build_edge_lines(xy, intensity, axis, config).coverage)284 coverages.append(build_edge_lines(xy, intensity, axis, config).coverage)
289 assert min(coverages) >= 0.6, coverages285 assert min(coverages) >= 0.6, coverages
290286
Importance #42: tests/test_tcs_ground.py @@ -5,9 +5,8 @@
5"""5"""
66
7from __future__ import annotations7from __future__ import annotations
88
9import dataclasses
10import os9import os
11from pathlib import Path10from pathlib import Path
1211
13import numpy as np12import numpy as np
Importance #43: tests/test_tcs_ground.py @@ -94,9 +93,9 @@
94 return _write_record(tmp_path / name, points)93 return _write_record(tmp_path / name, points)
9594
9695
97def _config(**overrides: object) -> DetectorConfig:96def _config(**overrides: object) -> DetectorConfig:
98 return dataclasses.replace(DetectorConfig(), **overrides)97 return DetectorConfig().with_overrides(**overrides)
9998
10099
101def _dem(files: list[Path]) -> np.ndarray:100def _dem(files: list[Path]) -> np.ndarray:
102 model = build_ground_model(101 model = build_ground_model(
Importance #44: tests/test_tree_instances.py @@ -12,15 +12,14 @@
12"""12"""
1313
14from __future__ import annotations14from __future__ import annotations
1515
16import dataclasses
17import json16import json
1817
19import numpy as np18import numpy as np
20import pytest19import pytest
2120
22from iolabs_point_cloud_detection_verticalsigns import _config21from iolabs_point_cloud_detection_verticalsigns import _config, _model_tree
23from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig22from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig
24from iolabs_point_cloud_detection_verticalsigns.tree_instances import (23from iolabs_point_cloud_detection_verticalsigns.tree_instances import (
25 ABSTAIN_ASSIGNED,24 ABSTAIN_ASSIGNED,
26 ABSTAIN_HEDGE,25 ABSTAIN_HEDGE,
Importance #45: tests/test_tree_instances.py @@ -363,9 +362,9 @@
363 _canopy_ball(rng, radius=1.5, centre_h=3.0, centre_xy=(0.0, 0.0)),362 _canopy_ball(rng, radius=1.5, centre_h=3.0, centre_xy=(0.0, 0.0)),
364 _canopy_ball(rng, radius=1.5, centre_h=3.0, centre_xy=(18.0, 0.0)),363 _canopy_ball(rng, radius=1.5, centre_h=3.0, centre_xy=(18.0, 0.0)),
365 ]364 ]
366 )365 )
367 config = dataclasses.replace(DetectorConfig(), ti_apex_fallback_enabled=False)366 config = DetectorConfig().with_overrides(ti_apex_fallback_enabled=False)
368367
369 result = split_tree_cluster(xyz, 0.0, None, config)368 result = split_tree_cluster(xyz, 0.0, None, config)
370369
371 assert result.seeds == []370 assert result.seeds == []
Importance #46: tests/test_tree_instances.py @@ -464,10 +463,10 @@
464 )463 )
465 # The apex fallback is switched off here on purpose: it would recover the464 # The apex fallback is switched off here on purpose: it would recover the
466 # far trees from their crowns (that is exactly what it is for) and so hide465 # far trees from their crowns (that is exactly what it is for) and so hide
467 # the failure this counterfactual exists to display.466 # the failure this counterfactual exists to display.
468 config = dataclasses.replace(467 config = DetectorConfig().with_overrides(
469 DetectorConfig(), ti_local_ground_footprint_m=1e6, ti_apex_fallback_enabled=False468 ti_local_ground_footprint_m=1e6, ti_apex_fallback_enabled=False
470 )469 )
471470
472 result = split_tree_cluster(xyz, 0.0, None, config)471 result = split_tree_cluster(xyz, 0.0, None, config)
473472
Importance #47: tests/test_tree_instances.py @@ -492,9 +491,9 @@
492 # Stem-only: with the apex fallback on, the fragment is a crown of its own491 # Stem-only: with the apex fallback on, the fragment is a crown of its own
493 # and legitimately becomes its own instance. What must never happen โ€” and492 # and legitimately becomes its own instance. What must never happen โ€” and
494 # is what this test pins โ€” is the fragment being ANNEXED by the tree next493 # is what this test pins โ€” is the fragment being ANNEXED by the tree next
495 # to it through the graph.494 # to it through the graph.
496 config = dataclasses.replace(DetectorConfig(), ti_apex_fallback_enabled=False)495 config = DetectorConfig().with_overrides(ti_apex_fallback_enabled=False)
497496
498 result = split_tree_cluster(xyz, 0.0, None, config)497 result = split_tree_cluster(xyz, 0.0, None, config)
499498
500 assert len(result.seeds) == 1499 assert len(result.seeds) == 1
Importance #48: tests/test_tree_instances.py @@ -510,10 +509,10 @@
510 which is right for some callers and wrong for most, so it is a switch.509 which is right for some callers and wrong for most, so it is a switch.
511 """510 """
512 rng = np.random.default_rng(47)511 rng = np.random.default_rng(47)
513 xyz = np.vstack([_cone(rng, 0.0, 0.0), _cone(rng, 3.0, 0.0)])512 xyz = np.vstack([_cone(rng, 0.0, 0.0), _cone(rng, 3.0, 0.0)])
514 strict = dataclasses.replace(513 strict = DetectorConfig().with_overrides(
515 DetectorConfig(), ti_low_evidence_abstain=True, ti_low_evidence_margin=0.05514 ti_low_evidence_abstain=True, ti_low_evidence_margin=0.05
516 )515 )
517516
518 lenient = split_tree_cluster(xyz, 0.0, None, DetectorConfig())517 lenient = split_tree_cluster(xyz, 0.0, None, DetectorConfig())
519 result = split_tree_cluster(xyz, 0.0, None, strict)518 result = split_tree_cluster(xyz, 0.0, None, strict)
Importance #49: tests/test_tree_instances.py @@ -748,9 +747,9 @@
748 [r * np.cos(ang), r * np.sin(ang), rng.uniform(0.0, 2.2, 800)]747 [r * np.cos(ang), r * np.sin(ang), rng.uniform(0.0, 2.2, 800)]
749 )748 )
750 xyz = np.vstack([band, trunk])749 xyz = np.vstack([band, trunk])
751 # Stem-only, so the one seed is unambiguous and the cap is what is measured.750 # Stem-only, so the one seed is unambiguous and the cap is what is measured.
752 config = dataclasses.replace(DetectorConfig(), ti_apex_fallback_enabled=False)751 config = DetectorConfig().with_overrides(ti_apex_fallback_enabled=False)
753752
754 result = split_tree_cluster(xyz, 0.0, None, config)753 result = split_tree_cluster(xyz, 0.0, None, config)
755 far = xyz[:, 0] > 15.0754 far = xyz[:, 0] > 15.0
756 near = xyz[:, 0] < 5.0755 near = xyz[:, 0] < 5.0
Importance #50: tests/test_tree_instances.py @@ -764,10 +763,9 @@
764 uncapped = split_tree_cluster(763 uncapped = split_tree_cluster(
765 xyz,764 xyz,
766 0.0,765 0.0,
767 None,766 None,
768 dataclasses.replace(767 config.with_overrides(
769 config,
770 ti_max_claim_radius_m=40.0,768 ti_max_claim_radius_m=40.0,
771 ti_instance_max_linearity=1.01,769 ti_instance_max_linearity=1.01,
772 ti_instance_min_thickness_share=0.0,770 ti_instance_min_thickness_share=0.0,
773 ),771 ),
Importance #51: tests/test_tree_instances.py @@ -791,9 +789,9 @@
791789
792 result = split_tree_cluster(xyz, 0.0, None, DetectorConfig())790 result = split_tree_cluster(xyz, 0.0, None, DetectorConfig())
793 # The v2 semantics, reconstructed: the same cap applied to the PATH.791 # The v2 semantics, reconstructed: the same cap applied to the PATH.
794 v2 = split_tree_cluster(792 v2 = split_tree_cluster(
795 xyz, 0.0, None, dataclasses.replace(DetectorConfig(), ti_assign_max_graph_dist_m=9.0)793 xyz, 0.0, None, DetectorConfig().with_overrides(ti_assign_max_graph_dist_m=9.0)
796 )794 )
797795
798 assert len(result.seeds) == 1796 assert len(result.seeds) == 1
799 assert float(np.mean(result.labels[canopy] >= 0)) > 0.95797 assert float(np.mean(result.labels[canopy] >= 0)) > 0.95
Importance #52: tests/test_tree_instances.py @@ -812,9 +810,9 @@
812 rng = np.random.default_rng(97)810 rng = np.random.default_rng(97)
813 big = _cone(rng, 0.0, 0.0, n_crown=3000, n_trunk=400)811 big = _cone(rng, 0.0, 0.0, n_crown=3000, n_trunk=400)
814 small = _cone(rng, 4.0, 0.0, top=4.0, crown_r=1.0, n_crown=300, n_trunk=120)812 small = _cone(rng, 4.0, 0.0, top=4.0, crown_r=1.0, n_crown=300, n_trunk=120)
815 xyz = np.vstack([big, small])813 xyz = np.vstack([big, small])
816 config = dataclasses.replace(DetectorConfig(), ti_min_instance_points=1000)814 config = DetectorConfig().with_overrides(ti_min_instance_points=1000)
817815
818 kept = split_tree_cluster(xyz, 0.0, None, DetectorConfig())816 kept = split_tree_cluster(xyz, 0.0, None, DetectorConfig())
819 result = split_tree_cluster(xyz, 0.0, None, config)817 result = split_tree_cluster(xyz, 0.0, None, config)
820818
Importance #53: tests/test_tree_instances.py @@ -873,10 +871,9 @@
873 kept = split_tree_cluster(871 kept = split_tree_cluster(
874 xyz,872 xyz,
875 0.0,873 0.0,
876 None,874 None,
877 dataclasses.replace(875 DetectorConfig().with_overrides(
878 DetectorConfig(),
879 ti_instance_max_linearity=1.01,876 ti_instance_max_linearity=1.01,
880 ti_instance_min_minor_m=0.0,877 ti_instance_min_minor_m=0.0,
881 ti_instance_min_vertical_m=0.0,878 ti_instance_min_vertical_m=0.0,
882 ti_instance_min_thickness_share=0.0,879 ti_instance_min_thickness_share=0.0,
Importance #54: tests/test_tree_instances.py @@ -910,9 +907,9 @@
910 )907 )
911908
912 result = split_tree_cluster(xyz, 0.0, None, DetectorConfig())909 result = split_tree_cluster(xyz, 0.0, None, DetectorConfig())
913 undamped = split_tree_cluster(910 undamped = split_tree_cluster(
914 xyz, 0.0, None, dataclasses.replace(DetectorConfig(), ti_min_points_per_instance=1)911 xyz, 0.0, None, DetectorConfig().with_overrides(ti_min_points_per_instance=1)
915 )912 )
916913
917 assert len(undamped.seeds) >= 3914 assert len(undamped.seeds) >= 3
918 assert len(result.seeds) <= 1915 assert len(result.seeds) <= 1
Importance #55: tests/test_tree_instances.py @@ -931,16 +928,16 @@
931 [rng.uniform(0.0, 7.0, n), rng.uniform(-0.3, 0.3, n), rng.uniform(0.0, 3.0, n)]928 [rng.uniform(0.0, 7.0, n), rng.uniform(-0.3, 0.3, n), rng.uniform(0.0, 3.0, n)]
932 )929 )
933 # Stem-only: the seedless verdict is what is being measured, and an apex930 # Stem-only: the seedless verdict is what is being measured, and an apex
934 # seed would answer the question before the gate is reached.931 # seed would answer the question before the gate is reached.
935 config = dataclasses.replace(DetectorConfig(), ti_apex_fallback_enabled=False)932 config = DetectorConfig().with_overrides(ti_apex_fallback_enabled=False)
936933
937 result = split_tree_cluster(xyz, 0.0, None, config)934 result = split_tree_cluster(xyz, 0.0, None, config)
938 lenient = split_tree_cluster(935 lenient = split_tree_cluster(
939 xyz,936 xyz,
940 0.0,937 0.0,
941 None,938 None,
942 dataclasses.replace(config, ti_seedless_max_aspect=100.0, ti_seedless_min_points=1),939 config.with_overrides(ti_seedless_max_aspect=100.0, ti_seedless_min_points=1),
943 )940 )
944941
945 assert result.seeds == []942 assert result.seeds == []
946 assert result.split_quality == "uncertain"943 assert result.split_quality == "uncertain"
Importance #56: tests/test_tree_instances.py @@ -974,9 +971,9 @@
974 xyz = _foliage_band(rng)971 xyz = _foliage_band(rng)
975972
976 result = split_tree_cluster(xyz, 0.0, None, DetectorConfig())973 result = split_tree_cluster(xyz, 0.0, None, DetectorConfig())
977 blind = split_tree_cluster(974 blind = split_tree_cluster(
978 xyz, 0.0, None, dataclasses.replace(DetectorConfig(), ti_hedge_max_height_m=4.5)975 xyz, 0.0, None, DetectorConfig().with_overrides(ti_hedge_max_height_m=4.5)
979 )976 )
980977
981 assert result.is_hedge and result.split_quality == "hedge"978 assert result.is_hedge and result.split_quality == "hedge"
982 assert result.seeds == []979 assert result.seeds == []
Importance #57: tests/test_tree_instances.py @@ -1051,9 +1048,9 @@
10511048
1052# --- config section --------------------------------------------------------1049# --- config section --------------------------------------------------------
10531050
10541051
1055def test_overrides_reach_the_dataclass() -> None:1052def test_overrides_reach_the_model() -> None:
1056 config = DetectorConfig.from_mapping(1053 config = DetectorConfig.from_mapping(
1057 {"tree_instance": {"enabled": True, "stem_eps_m": 0.5}}1054 {"tree_instance": {"enabled": True, "stem_eps_m": 0.5}}
1058 )1055 )
1059 assert config.tree_instance_enabled is True1056 assert config.tree_instance_enabled is True
Importance #58: tests/test_tree_instances.py @@ -1061,21 +1058,21 @@
1061 assert config.ti_stem_min_samples == DetectorConfig().ti_stem_min_samples1058 assert config.ti_stem_min_samples == DetectorConfig().ti_stem_min_samples
10621059
10631060
1064def test_config_rejects_an_unknown_key_in_the_new_section(tmp_path) -> None:1061def test_config_rejects_an_unknown_key_in_the_new_section(tmp_path) -> None:
1065 """Pins the _ALLOWED_BY_SECTION wiring.1062 """Pins the TreeInstanceConfig wiring.
10661063
1067 Without it the section validates against nothing, and a typo'd threshold1064 Without it the section validates against nothing, and a typo'd threshold
1068 is accepted and then silently ignored.1065 is accepted and then silently ignored.
1069 """1066 """
1070 path = tmp_path / "override.json"1067 path = tmp_path / "override.json"
1071 path.write_text(json.dumps({"tree_instance": {"stem_eps": 0.5}}))1068 path.write_text(json.dumps({"tree_instance": {"stem_eps": 0.5}}))
1072 with pytest.raises(_config.ConfigError):1069 with pytest.raises(_config.VerticalSignsConfigError):
1073 _config.load_verticalsigns_config(path)1070 _config.load_verticalsigns_config(path)
10741071
10751072
1076def test_config_accepts_every_documented_key(tmp_path) -> None:1073def test_config_accepts_every_documented_key(tmp_path, section_values) -> None:
1077 section = {k: 0.5 for k in _config.ALLOWED_TREE_INSTANCE_KEYS}1074 section = section_values(_model_tree.TreeInstanceConfig)
1078 section["enabled"] = True1075 section["enabled"] = True
1079 path = tmp_path / "override.json"1076 path = tmp_path / "override.json"
1080 path.write_text(json.dumps({"tree_instance": section}))1077 path.write_text(json.dumps({"tree_instance": section}))
1081 assert _config.load_verticalsigns_config(path)["tree_instance"]["enabled"]1078 assert _config.load_verticalsigns_config(path)["tree_instance"]["enabled"]
Importance #59: pyproject.toml @@ -1,7 +1,7 @@
1[project]1[project]
2name = "iolabs-point-cloud-detection-verticalsigns"2name = "iolabs-point-cloud-detection-verticalsigns"
3version = "0.2.0"3version = "0.2.1"
4description = "Classical geometric vertical sign and gate detection in MLS LiDAR point clouds"4description = "Classical geometric vertical sign and gate detection in MLS LiDAR point clouds"
5readme = "README.md"5readme = "README.md"
6requires-python = ">=3.11,<3.13"6requires-python = ">=3.11,<3.13"
7dependencies = [7dependencies = [
Importance #60: pyproject.toml @@ -9,10 +9,11 @@
9 "pillow>=10.0",9 "pillow>=10.0",
10 "scikit-learn>=1.5",10 "scikit-learn>=1.5",
11 "scipy>=1.13",11 "scipy>=1.13",
12 "joblib>=1.3",12 "joblib>=1.3",
13 "pydantic>=2.7",
13 "iolabs-logstash>=0.4.0",14 "iolabs-logstash>=0.4.0",
14 "iolabs-common>=0.7.0",15 "iolabs-common>=0.9.0",
15 "iolabs-geometry-geometry>=0.11.0",16 "iolabs-geometry-geometry>=0.11.0",
16 "iolabs-geometry-raster>=0.2.0",17 "iolabs-geometry-raster>=0.2.0",
17 "iolabs-point-cloud-tablecloth>=0.2.0",18 "iolabs-point-cloud-tablecloth>=0.2.0",
18]19]
Importance #61: BRIEF.md @@ -94,10 +94,12 @@
94 plus the packaged `verticalsigns.default.json`, and `tests/` with fast94 plus the packaged `verticalsigns.default.json`, and `tests/` with fast
95 synthetic unit tests (a fake pole, a fake wall, a fake tree โ†’ correct95 synthetic unit tests (a fake pole, a fake wall, a fake tree โ†’ correct
96 classification; world_to_pixel round-trip). Config thresholds live in96 classification; world_to_pixel round-trip). Config thresholds live in
97 `verticalsigns.default.json`; `_config.load_verticalsigns_config` deep-merges97 `verticalsigns.default.json`; `_config.load_verticalsigns_config` deep-merges
98 a user `--config` JSON over the defaults (ALLOWED_* frozensets reject unknown98 a user `--config` JSON over the defaults and validates the result against the
99 keys). Logging uses `iolabs.logstash.get_props_logger(__name__, LOG_PROPS)`.99 `VerticalSignsConfig` pydantic model tree (`_config_model.py` +
100 `_model_<slice>.py`), which rejects unknown keys and bad values. Logging
101 uses `iolabs.logstash.get_props_logger(__name__, LOG_PROPS)`.
100- CLI: `uv run verticalsigns-detect --data-dir ... --segments 000,012 --out out/ [--config overrides.json]`102- CLI: `uv run verticalsigns-detect --data-dir ... --segments 000,012 --out out/ [--config overrides.json]`
101 (segment IDs zero-padded to 3); `python -m103 (segment IDs zero-padded to 3); `python -m
102 iolabs_point_cloud_detection_verticalsigns.detect ...` is equivalent.104 iolabs_point_cloud_detection_verticalsigns.detect ...` is equivalent.
103- Performance: a segment has ~2โ€“8 M points across its run3 files; use105- Performance: a segment has ~2โ€“8 M points across its run3 files; use
Importance #62: README.md @@ -23,13 +23,23 @@
23`ground`, `occupancy`, `candidates`, `clustering`, `classification`, `corridor`,23`ground`, `occupancy`, `candidates`, `clustering`, `classification`, `corridor`,
24`context`, `delineator`, `sign_post`, `panel`, `gantry`, `repetitive_row`,24`context`, `delineator`, `sign_post`, `panel`, `gantry`, `repetitive_row`,
25`marker_extract`, `rail_halfpost`, `reject_rescue`, `tree`, `tree_detection`,25`marker_extract`, `rail_halfpost`, `reject_rescue`, `tree`, `tree_detection`,
26`chroma_vegetation`, `vehicle`, `views`, `perspective`). Pass `--config`26`chroma_vegetation`, `vehicle`, `views`, `perspective`). Pass `--config`
27to deep-merge a partial JSON over those defaults; unknown keys are rejected.27to deep-merge a partial JSON over those defaults; unknown keys and bad values
2828are rejected.
29The 378-field `DetectorConfig` is declared across the `_config_<section>`29
30modules and recombined in `config.py`, which re-exports every name โ€” import30The schema of that JSON is the `VerticalSignsConfig` pydantic model tree
31from `...verticalsigns.config` exactly as before.31(`_config_model.py` plus the `_model_<slice>.py` sections, built on
32`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 model
34and the default to `verticalsigns.default.json`, nothing else.**
35
36The 379-field `DetectorConfig` is the FLAT view the detector modules read
37(`config.ground_cell_m`): it is declared across the `_config_<section>` modules
38and recombined in `config.py`, which re-exports every name โ€” import from
39`...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 key
41onto it.
3242
33## QC rendering is an optional extra43## QC rendering is an optional extra
3444
35`verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need45`verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need
Importance #63: README.md @@ -23,13 +23,23 @@
23`ground`, `occupancy`, `candidates`, `clustering`, `classification`, `corridor`,23`ground`, `occupancy`, `candidates`, `clustering`, `classification`, `corridor`,
24`context`, `delineator`, `sign_post`, `panel`, `gantry`, `repetitive_row`,24`context`, `delineator`, `sign_post`, `panel`, `gantry`, `repetitive_row`,
25`marker_extract`, `rail_halfpost`, `reject_rescue`, `tree`, `tree_detection`,25`marker_extract`, `rail_halfpost`, `reject_rescue`, `tree`, `tree_detection`,
26`chroma_vegetation`, `vehicle`, `views`, `perspective`). Pass `--config`26`chroma_vegetation`, `vehicle`, `views`, `perspective`). Pass `--config`
27to deep-merge a partial JSON over those defaults; unknown keys are rejected.27to deep-merge a partial JSON over those defaults; unknown keys and bad values
2828are rejected.
29The 378-field `DetectorConfig` is declared across the `_config_<section>`29
30modules and recombined in `config.py`, which re-exports every name โ€” import30The schema of that JSON is the `VerticalSignsConfig` pydantic model tree
31from `...verticalsigns.config` exactly as before.31(`_config_model.py` plus the `_model_<slice>.py` sections, built on
32`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 model
34and the default to `verticalsigns.default.json`, nothing else.**
35
36The 379-field `DetectorConfig` is the FLAT view the detector modules read
37(`config.ground_cell_m`): it is declared across the `_config_<section>` modules
38and recombined in `config.py`, which re-exports every name โ€” import from
39`...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 key
41onto it.
3242
33## QC rendering is an optional extra43## QC rendering is an optional extra
3444
35`verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need45`verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need
Importance #64: dev/out_eval/pass8/p8_edgeline_diag.py @@ -11,9 +11,8 @@
1111
12from __future__ import annotations12from __future__ import annotations
1313
14import argparse14import argparse
15import dataclasses
16from pathlib import Path15from pathlib import Path
1716
18import numpy as np17import numpy as np
1918
Importance #65: dev/out_eval/pass8/p8_edgeline_diag.py @@ -217,10 +216,10 @@
217 header = "variant".ljust(20) + "".join(s.rjust(9) for s in segments)216 header = "variant".ljust(20) + "".join(s.rjust(9) for s in segments)
218 print(header)217 print(header)
219 for name, overrides in variants.items():218 for name, overrides in variants.items():
220 try:219 try:
221 cfg = dataclasses.replace(config, **overrides)220 cfg = config.with_overrides(**overrides)
222 except TypeError as exc:221 except ValueError as exc:
223 print(f"{name.ljust(20)} SKIP ({exc})")222 print(f"{name.ljust(20)} SKIP ({exc})")
224 continue223 continue
225 cells = []224 cells = []
226 for segment in segments:225 for segment in segments:
Importance #66: dev/out_eval/pass9/p10/p10_yield.py @@ -80,40 +80,36 @@
8080
8181
82def fix_delineator_h_ceiling(h):82def fix_delineator_h_ceiling(h):
83 def _f(f, cfg):83 def _f(f, cfg):
84 return f, dataclasses.replace(cfg, delineator_h_max_m=h)84 return f, cfg.with_overrides(delineator_h_max_m=h)
8585
86 return _f86 return _f
8787
8888
89def fix_min_points(n):89def fix_min_points(n):
90 def _f(f, cfg):90 def _f(f, cfg):
91 return f, dataclasses.replace(cfg, delineator_min_points=n)91 return f, cfg.with_overrides(delineator_min_points=n)
9292
93 return _f93 return _f
9494
9595
96def fix_relaxed_footprint(m):96def fix_relaxed_footprint(m):
97 def _f(f, cfg):97 def _f(f, cfg):
98 return f, dataclasses.replace(cfg, delineator_relaxed_footprint_m=m)98 return f, cfg.with_overrides(delineator_relaxed_footprint_m=m)
9999
100 return _f100 return _f
101101
102102
103def fix_combo_sentinel_hceiling(f, cfg):103def fix_combo_sentinel_hceiling(f, cfg):
104 cfg2 = dataclasses.replace(cfg, delineator_h_max_m=1.8)104 cfg2 = cfg.with_overrides(delineator_h_max_m=1.8)
105 if f.len_minor > SENTINEL and f.verticality == 0.0:105 if f.len_minor > SENTINEL and f.verticality == 0.0:
106 return dataclasses.replace(f, verticality=1.0), cfg2106 return dataclasses.replace(f, verticality=1.0), cfg2
107 return f, cfg2107 return f, cfg2
108108
109109
110def fix_all(f, cfg):110def fix_all(f, cfg):
111 cfg2 = dataclasses.replace(111 cfg2 = cfg.with_overrides(delineator_h_max_m=1.8, delineator_min_points=150)
112 cfg,
113 delineator_h_max_m=1.8,
114 delineator_min_points=150,
115 )
116 if f.len_minor > SENTINEL and f.verticality == 0.0:112 if f.len_minor > SENTINEL and f.verticality == 0.0:
117 return dataclasses.replace(f, verticality=1.0), cfg2113 return dataclasses.replace(f, verticality=1.0), cfg2
118 return f, cfg2114 return f, cfg2
119115
Importance #67: pyproject.toml @@ -1,7 +1,7 @@
1[project]1[project]
2name = "iolabs-point-cloud-detection-verticalsigns"2name = "iolabs-point-cloud-detection-verticalsigns"
3version = "0.2.0"3version = "0.2.1"
4description = "Classical geometric vertical sign and gate detection in MLS LiDAR point clouds"4description = "Classical geometric vertical sign and gate detection in MLS LiDAR point clouds"
5readme = "README.md"5readme = "README.md"
6requires-python = ">=3.11,<3.13"6requires-python = ">=3.11,<3.13"
7dependencies = [7dependencies = [
Importance #68: pyproject.toml @@ -9,10 +9,11 @@
9 "pillow>=10.0",9 "pillow>=10.0",
10 "scikit-learn>=1.5",10 "scikit-learn>=1.5",
11 "scipy>=1.13",11 "scipy>=1.13",
12 "joblib>=1.3",12 "joblib>=1.3",
13 "pydantic>=2.7",
13 "iolabs-logstash>=0.4.0",14 "iolabs-logstash>=0.4.0",
14 "iolabs-common>=0.7.0",15 "iolabs-common>=0.9.0",
15 "iolabs-geometry-geometry>=0.11.0",16 "iolabs-geometry-geometry>=0.11.0",
16 "iolabs-geometry-raster>=0.2.0",17 "iolabs-geometry-raster>=0.2.0",
17 "iolabs-point-cloud-tablecloth>=0.2.0",18 "iolabs-point-cloud-tablecloth>=0.2.0",
18]19]
Importance #69: src/iolabs_point_cloud_detection_verticalsigns/_config.py @@ -1,630 +1,81 @@
1"""Packaged-default configuration loading and validation for the detector.1"""Packaged-default configuration loading and validation for the detector.
22
3The canonical configuration lives in ``verticalsigns.default.json`` packaged3The canonical configuration lives in ``verticalsigns.default.json`` packaged
4next to this module. ``load_verticalsigns_config`` returns a validated dict that4next to this module, and its schema is the :class:`VerticalSignsConfig` pydantic
5deep-merges an optional user JSON over those defaults, rejecting unknown keys5model tree in ``_config_model``. ``load_verticalsigns_config`` returns a
6(per section) with a clear error. The internal :class:`DetectorConfig` dataclass6validated plain dict that deep-merges an optional user JSON over those defaults,
7is constructed from that dict via ``DetectorConfig.from_mapping``.7rejecting unknown keys (per section) and bad values with a clear error. The
88internal :class:`DetectorConfig` model is built from that dict via
9Deep-merge, default-path resolution, and allowed-key validation are provided by9``DetectorConfig.from_mapping``.
10``iolabs.common.config_loader``; section allowlists and entrypoints stay here.10
11Loading, deep-merge and validation are provided by
12``iolabs.common.config_loader``; the schema and entrypoints stay here.
11"""13"""
1214
13from __future__ import annotations15from __future__ import annotations
1416
15import json17import json
18import logging
16from pathlib import Path19from pathlib import Path
17from typing import Any20from typing import Any
1821
19from iolabs.common.config_loader import (22from iolabs.common import config_loader
20 ConfigError,
21 deep_merge_dicts,
22 load_packaged_json,
23 validate_allowed_keys,
24)
25
26_PACKAGE_NAME = "iolabs_point_cloud_detection_verticalsigns"
27_DEFAULT_RESOURCE = "verticalsigns.default.json"
28
29ALLOWED_TOP_LEVEL_KEYS = frozenset(
30 {
31 "ground",
32 "occupancy",
33 "candidates",
34 "clustering",
35 "classification",
36 "radius",
37 "corridor",
38 "context",
39 "delineator",
40 "sign_post",
41 "panel",
42 "gantry",
43 "tree",
44 "tree_detection",
45 "tree_instance",
46 "chroma_vegetation",
47 "tcs_ground",
48 "conic_gate",
49 "conifer_rule",
50 "vehicle",
51 "repetitive_row",
52 "road_context",
53 "edge_line",
54 "field_stake",
55 "marker_extract",
56 "rail_halfpost",
57 "reject_rescue",
58 "views",
59 "perspective",
60 }
61)
6223
63ALLOWED_GROUND_KEYS = frozenset({"cell_m", "percentile"})24from ._config_model import VerticalSignsConfig
64ALLOWED_OCCUPANCY_KEYS = frozenset({"cell_m"})
65ALLOWED_CANDIDATES_KEYS = frozenset(
66 {
67 "min_height_m",
68 "max_height_m",
69 "seed_min_vertical_span_m",
70 "seed_min_h_max_m",
71 "seed_bright_min_vertical_span_m",
72 "seed_bright_min_h_max_m",
73 "seed_bright_min_points",
74 }
75)
76ALLOWED_CLUSTERING_KEYS = frozenset({"eps_m", "min_samples", "hull_margin_m"})
77ALLOWED_CLASSIFICATION_KEYS = frozenset(
78 {
79 "continuity_bin_m",
80 "reject_len_major_m",
81 "reject_h_max_with_large_footprint_m",
82 "min_continuity",
83 "min_accept_h_max_m",
84 "core_rms_bin_m",
85 "core_rms_h_min_m",
86 "core_rms_h_cap_m",
87 "hi_intensity_all_points_percentile",
88 "seed_bright_percentile",
89 "verticality_sentinel_fix",
90 "robust_extent_stats",
91 "robust_h_max_percentile",
92 "robust_extent_lo_percentile",
93 "robust_extent_hi_percentile",
94 "single_record_transient_veto",
95 "transient_max_verticality",
96 "transient_min_len_major_m",
97 "transient_max_h_max_m",
98 "veg_texture_veto",
99 "veg_texture_min_plate_thickness_m",
100 "veg_texture_min_hi_seed_fraction",
101 "lattice_admission",
102 "lattice_min_anchors",
103 "lattice_snap_m",
104 "lattice_max_skip",
105 "lattice_min_seed_spacing_m",
106 "lattice_max_seed_spacing_m",
107 "lattice_max_spacing_resid",
108 "lattice_pool_h_max_min_m",
109 "lattice_pool_h_max_max_m",
110 "lattice_pool_max_len_major_m",
111 "lattice_pool_min_verticality",
112 "lattice_pool_min_points",
113 "lattice_pool_max_plate_thickness_m",
114 "lattice_pool_min_hi_seed_fraction",
115 "min_volumetric_density",
116 "pole_floating_min_h_min_m",
117 "pole_isolated_radius_m",
118 "dedup_radius_m",
119 "emit_trees",
120 "ml_verifier_enabled",
121 "ml_veto_threshold",
122 "ml_model_path",
123 "ml_veto_requires_corridor",
124 }
125)
126ALLOWED_RADIUS_KEYS = frozenset(
127 {
128 "fit_bin_m",
129 "fit_min_bin_points",
130 "fit_min_arc_deg",
131 "fit_residual_frac",
132 "fit_residual_abs_m",
133 "fit_divergence_factor",
134 "pole_radius_max_m",
135 "trunk_radius_max_m",
136 "crown_radius_percentile",
137 "crown_lobe_gap_m",
138 "crown_lobe_min_samples",
139 "crown_lobe_min_points",
140 "crown_lobe_coverage_target",
141 "crown_lobe_max_count",
142 "debug_cluster_points",
143 }
144)
145ALLOWED_CORRIDOR_KEYS = frozenset(
146 {
147 "max_dist_to_road_m",
148 "on_carriageway_dist_m",
149 "on_carriageway_exempt_h_max_m",
150 "density_min_points",
151 "density_frac_p95",
152 "density_max_points",
153 "component_min_area_frac",
154 "component_min_area_cells",
155 "on_carriageway_road_fraction",
156 "on_carriageway_bright_frac",
157 "on_carriageway_delineator_max_len_major_m",
158 "on_carriageway_delineator_min_verticality",
159 }
160)
161ALLOWED_CONTEXT_KEYS = frozenset(
162 {
163 "ring_r_inner_m",
164 "ring_r_outer_m",
165 "ring_h_min_m",
166 "ring_h_max_m",
167 "ring_max_fill_ratio",
168 "ring_min_points",
169 "forest_min_neighbors",
170 "forest_radius_m",
171 "forest_neighbor_min_h_max_m",
172 }
173)
174ALLOWED_DELINEATOR_KEYS = frozenset(
175 {
176 "h_min_m",
177 "h_max_m",
178 "max_footprint_m",
179 "relaxed_footprint_m",
180 "relaxed_min_verticality",
181 "relaxed_max_ring_fill_ratio",
182 "relaxed_min_hi_intensity_fraction",
183 "min_hi_intensity_fraction",
184 "min_points",
185 }
186)
187ALLOWED_SIGN_POST_KEYS = frozenset(
188 {
189 "max_len_minor_m",
190 "h_min_m",
191 "h_max_m",
192 "min_continuity",
193 "plate_hi_intensity_fraction",
194 "plate_hi_intensity_fraction_weak",
195 "plate_upper_surplus_ratio",
196 "min_upper_half_surplus",
197 "plate_min_core_rms_m",
198 "max_plate_thickness_m",
199 "bare_post_min_h_max_m",
200 "bare_post_max_core_rms_m",
201 "bare_post_min_verticality",
202 "bare_post_min_points",
203 }
204)
205ALLOWED_PANEL_KEYS = frozenset(
206 {"min_hi", "max_thickness_m", "h_min_m", "len_major_min_m", "len_major_max_m"}
207)
208ALLOWED_GANTRY_KEYS = frozenset(
209 {
210 "h_min_m",
211 "len_major_m",
212 "max_len_minor_m",
213 "pair_station_tolerance_m",
214 "pair_min_separation_m",
215 "overhead_h_min_m",
216 "pair_isolation_radius_m",
217 }
218)
219ALLOWED_REPETITIVE_ROW_KEYS = frozenset(
220 {
221 "min_members",
222 "max_spacing_m",
223 "max_perp_spread_m",
224 "max_h_max_range_m",
225 "member_max_len_major_m",
226 "member_max_len_minor_m",
227 }
228)
229ALLOWED_ROAD_CONTEXT_KEYS = frozenset(
230 {
231 "gate_enabled",
232 "xml_enabled",
233 "xml_min_agreement",
234 "xml_vote_slack_m",
235 "xml_max_distance_m",
236 "xml_station_tolerance_m",
237 "xml_station_step_m",
238 "min_carriageway_width_m",
239 "max_carriageway_width_m",
240 "paint_fallback_enabled",
241 "saturation_intensity",
242 "radius_m",
243 "neighbour_span",
244 "cache_dir",
245 "min_neighbourhood_saturated",
246 }
247)
248ALLOWED_EDGE_LINE_KEYS = frozenset(
249 {
250 "gate_enabled",
251 "paint_max_height_m",
252 "paint_min_height_m",
253 "paint_intensity_percentile",
254 "paint_subsample",
255 "station_len_m",
256 "min_window_returns",
257 "lateral_bin_m",
258 "min_line_points",
259 "max_line_width_m",
260 "min_line_along_fill",
261 "drive_line_bin_m",
262 "min_band_width_m",
263 "max_band_width_m",
264 "inward_margin_m",
265 "min_coverage_frac",
266 "min_axis_contrast",
267 "axis_search_radius_m",
268 "axis_max_angle_cos",
269 "axis_max_distance_m",
270 "exempt_h_max_m",
271 "reject_requires_transient",
272 "transient_max_records",
273 "far_filter_enabled",
274 "far_max_distance_m",
275 "far_include_lane_lines",
276 "far_tier2_enabled",
277 "far_tier2_distance_m",
278 "far_tier2_max_saturation",
279 # Read from the edge_line section by DetectorConfig.from_mapping but
280 # historically listed only under road_context, which the loader never
281 # consults for them โ€” they were unsettable until added here.
282 "xml_enabled",
283 "xml_min_agreement",
284 "xml_vote_slack_m",
285 "xml_max_distance_m",
286 "xml_station_tolerance_m",
287 "xml_station_step_m",
288 "min_carriageway_width_m",
289 "max_carriageway_width_m",
290 "paint_fallback_enabled",
291 }
292)
293ALLOWED_FIELD_STAKE_KEYS = frozenset(
294 {
295 "row_emit",
296 "min_members",
297 "min_spacing_m",
298 "max_spacing_m",
299 "max_spacing_cv",
300 }
301)
302ALLOWED_MARKER_EXTRACT_KEYS = frozenset(
303 {
304 "min_len_major_m",
305 "bright_h_min_m",
306 "min_bright_points",
307 "window_m",
308 "min_bright_fraction",
309 "min_h_max_m",
310 "min_vertical_span_m",
311 }
312)
313# Own sections rather than more classification keys: both are STAGES with their
314# own frozen constant sets and their own provenance (see railpost.py /
315# rescue.py), and folding a dozen probe constants into `classification` would
316# make it impossible to see at a glance which knobs belong to which stage.
317ALLOWED_RAIL_HALFPOST_KEYS = frozenset(
318 {
319 "enabled",
320 "models_dir",
321 "band_lat_m",
322 "band_z_lo_m",
323 "band_z_hi_m",
324 "sample_step_m",
325 "cluster_cell_m",
326 "min_emit_points",
327 "ground_cell_m",
328 "ground_percentile",
329 "saturation_intensity",
330 "h_min_m",
331 "h_max_m",
332 "max_lateral_m",
333 "max_width_m",
334 "min_points",
335 "min_z_extent_m",
336 "dedupe_m",
337 "prime_min_sat",
338 "prime_min_records",
339 }
340)
341ALLOWED_REJECT_RESCUE_KEYS = frozenset(
342 {
343 "enabled",
344 "h_min_m",
345 "h_max_m",
346 "min_verticality",
347 "max_core_rms_m",
348 "min_h_over_width",
349 "min_records",
350 "min_roadctx_sat",
351 "min_continuity",
352 "min_decile_fill",
353 "min_points",
354 "merge_radius_m",
355 "accepted_exclusion_m",
356 "per_segment_cap",
357 }
358)
359ALLOWED_TREE_KEYS = frozenset(
360 {"crown_h_min_m", "crown_max_area_m2", "isotropy_ratio", "greenness_hint"}
361)
362ALLOWED_TREE_DETECTION_KEYS = frozenset(
363 {
364 "enabled",
365 "max_dist_to_road_m",
366 "seed_min_vertical_span_m",
367 "seed_points_above_m",
368 "eps_m",
369 "min_samples",
370 "hull_margin_m",
371 "min_points",
372 "bridge_max_on_road_fraction",
373 "dedup_radius_m",
374 "min_confidence",
375 "model_path",
376 "hedge_split_enabled",
377 }
378)
379# Post-detection instance splitting, a separate section from "tree_detection":
380# those keys decide WHICH blobs are emitted as trees, these decide how ONE
381# emitted blob is cut into per-point instances. Both carry an "enabled" and
382# several metre thresholds with similar names, so merging them would make a
383# hand-written override ambiguous about which stage it is tuning.
384ALLOWED_TREE_INSTANCE_KEYS = frozenset(
385 {
386 "enabled",
387 "local_ground_footprint_m",
388 "local_ground_cell_m",
389 "local_ground_percentile",
390 "local_ground_window_m",
391 "crown_base_bin_m",
392 "crown_base_density_frac",
393 "crown_base_run_bins",
394 "crown_base_min_m",
395 "stem_band_low_m",
396 "stem_band_cap_m",
397 "stem_band_min_thickness_m",
398 "stem_eps_m",
399 "stem_min_samples",
400 "stem_max_diameter_m",
401 "stem_min_vertical_reach",
402 "stem_min_verticality",
403 "stem_min_score",
404 "stem_exg_bonus",
405 "stem_merge_dist_m",
406 "stem_uncertain_dist_m",
407 "hedge_max_ground_gap_m",
408 "hedge_max_height_m",
409 "hedge_min_length_m",
410 "hedge_min_area_m2",
411 "hedge_max_top_relief_m",
412 "hedge_max_seed_per_10m",
413 "hedge_stem_score_min",
414 "assign_voxel_m",
415 "assign_max_gap_m",
416 "assign_max_graph_dist_m",
417 "low_evidence_margin",
418 "low_evidence_abstain",
419 "min_cluster_points",
420 "single_tree_footprint_m",
421 "partial_abstain_fraction",
422 "confidence_seed_weight",
423 "apex_fallback_enabled",
424 "apex_cell_m",
425 "apex_smooth_sigma_m",
426 "apex_min_separation_m",
427 "apex_min_prominence_m",
428 "apex_min_height_m",
429 "apex_trigger_span_m",
430 "apex_seed_radius_m",
431 "apex_confidence_scale",
432 "seedless_single_max_footprint_m",
433 "seedless_single_min_height_m",
434 "seedless_single_max_height_m",
435 "seedless_single_confidence",
436 "seedless_min_p95_h_m",
437 "seedless_max_aspect",
438 "seedless_min_points",
439 "min_points_per_instance",
440 "float_fragment_min_h_m",
441 "float_fragment_p25_h_m",
442 "min_tree_footprint_m",
443 "max_tree_footprint_m",
444 "megacluster_points",
445 "planar_min_footprint_m",
446 "planar_cell_m",
447 "planar_max_spread_m",
448 "planar_fraction_min",
449 "hedge_min_continuity",
450 "hedge_continuity_bin_m",
451 "max_claim_radius_m",
452 "min_instance_points",
453 "min_instance_fraction",
454 "instance_max_linearity",
455 "instance_min_minor_m",
456 "instance_min_thickness_share",
457 "instance_min_vertical_m",
458 "confidence_size_ref_points",
459 "confidence_max",
460 "confidence_fallback_max",
461 }
462)
463# Deliberately its OWN section rather than more keys under "tree": the tree
464# section's greenness_hint (0.45) is calibrated against the legacy segment-max
465# normalized greenness, while these thresholds are ExG chromaticity in a
466# different range entirely. Separate sections make the two impossible to
467# confuse in a hand-written config override.
468ALLOWED_CHROMA_VEGETATION_KEYS = frozenset(
469 {
470 "enabled",
471 "exg_min",
472 "exg_iqr_min",
473 "max_hi_intensity_fraction",
474 "min_change_of_curvature",
475 "min_plate_thickness_m",
476 }
477)
478ALLOWED_VEHICLE_KEYS = frozenset(
479 {"h_min_m", "h_max_m", "len_major_m", "len_minor_m", "max_hi_intensity_fraction"}
480)
481ALLOWED_VIEWS_KEYS = frozenset(
482 {"near_radius_m", "fov_deg", "splat", "image_width", "image_height", "view_names"}
483)
484ALLOWED_PERSPECTIVE_KEYS = frozenset(
485 {
486 "depth_tol_m",
487 "line_samples",
488 "occluded_alpha",
489 "solid_width_px",
490 "halo_width_px",
491 "base_marker_radius_px",
492 "back_distance_m",
493 "back_height_m",
494 "context_distance_m",
495 "context_height_m",
496 "share_radius_m",
497 "coverage_tol_m",
498 }
499)
50025
501ALLOWED_TCS_GROUND_KEYS = frozenset(26logger = logging.getLogger(__name__)
502 {
503 "enabled",
504 "mechanism",
505 "cell_m",
506 "slope_threshold",
507 "max_elev_diff_m",
508 "smrf_max_window_m",
509 "elev_scalar",
510 "pit_fill_enabled",
511 "cache_dir",
512 }
513)
514ALLOWED_CONIC_GATE_KEYS = frozenset(
515 {
516 "enabled",
517 "taper_slope_max",
518 "taper_slope_robust_max",
519 "apex_deg_min",
520 "apex_deg_max",
521 "h_over_width_min",
522 "h_over_width_max",
523 "texture_cue_enabled",
524 "change_of_curvature_min",
525 "omnivariance_min",
526 "max_hi_intensity_fraction",
527 "h_max_min_m",
528 "max_on_road_fraction",
529 "min_decile_fill_fraction",
530 "min_crown_area_m2",
531 }
532)
533ALLOWED_CONIFER_RULE_KEYS = frozenset(
534 {
535 "enabled",
536 "max_stem_ratio",
537 "min_volumetric_density",
538 "max_volumetric_density",
539 "max_apex_ratio",
540 "max_crown_taper",
541 "max_crown_base_frac",
542 "h_over_width_min",
543 "h_over_width_max",
544 "h_max_min_m",
545 "min_change_of_curvature",
546 "max_hi_intensity_fraction",
547 "max_on_road_fraction",
548 "min_decile_fill_fraction",
549 "min_crown_area_m2",
550 }
551)
55227
553_ALLOWED_BY_SECTION: dict[str, frozenset[str]] = {28_PACKAGE_NAME = "iolabs_point_cloud_detection_verticalsigns"
554 "ground": ALLOWED_GROUND_KEYS,29_DEFAULT_RESOURCE = "verticalsigns.default.json"
555 "occupancy": ALLOWED_OCCUPANCY_KEYS,30_CONTEXT = "verticalsigns config"
556 "candidates": ALLOWED_CANDIDATES_KEYS,
557 "clustering": ALLOWED_CLUSTERING_KEYS,
558 "classification": ALLOWED_CLASSIFICATION_KEYS,
559 "radius": ALLOWED_RADIUS_KEYS,
560 "corridor": ALLOWED_CORRIDOR_KEYS,
561 "context": ALLOWED_CONTEXT_KEYS,
562 "delineator": ALLOWED_DELINEATOR_KEYS,
563 "sign_post": ALLOWED_SIGN_POST_KEYS,
564 "panel": ALLOWED_PANEL_KEYS,
565 "gantry": ALLOWED_GANTRY_KEYS,
566 "tree": ALLOWED_TREE_KEYS,
567 "tree_detection": ALLOWED_TREE_DETECTION_KEYS,
568 "tree_instance": ALLOWED_TREE_INSTANCE_KEYS,
569 "chroma_vegetation": ALLOWED_CHROMA_VEGETATION_KEYS,
570 "tcs_ground": ALLOWED_TCS_GROUND_KEYS,
571 "conic_gate": ALLOWED_CONIC_GATE_KEYS,
572 "conifer_rule": ALLOWED_CONIFER_RULE_KEYS,
573 "vehicle": ALLOWED_VEHICLE_KEYS,
574 "repetitive_row": ALLOWED_REPETITIVE_ROW_KEYS,
575 "road_context": ALLOWED_ROAD_CONTEXT_KEYS,
576 "edge_line": ALLOWED_EDGE_LINE_KEYS,
577 "field_stake": ALLOWED_FIELD_STAKE_KEYS,
578 "marker_extract": ALLOWED_MARKER_EXTRACT_KEYS,
579 "rail_halfpost": ALLOWED_RAIL_HALFPOST_KEYS,
580 "reject_rescue": ALLOWED_REJECT_RESCUE_KEYS,
581 "views": ALLOWED_VIEWS_KEYS,
582 "perspective": ALLOWED_PERSPECTIVE_KEYS,
583}
58431
58532
586class VerticalSignsConfigError(ConfigError):33class VerticalSignsConfigError(config_loader.ConfigError):
587 """Raised when the vertical sign detector config contains unsupported keys."""34 """Raised when the vertical sign detector config contains unsupported keys."""
58835
58936
590def _validate_config(config: dict[str, Any]) -> None:
591 validate_allowed_keys(
592 config,
593 ALLOWED_TOP_LEVEL_KEYS,
594 context="verticalsigns config",
595 error_cls=VerticalSignsConfigError,
596 )
597 for section, allowed_keys in _ALLOWED_BY_SECTION.items():
598 raw_section = config.get(section)
599 if raw_section is None:
600 continue
601 if not isinstance(raw_section, dict):
602 raise VerticalSignsConfigError(
603 f"verticalsigns config section '{section}' must be a mapping"
604 )
605 validate_allowed_keys(
606 raw_section,
607 allowed_keys,
608 context=f"verticalsigns {section}",
609 error_cls=VerticalSignsConfigError,
610 )
611
612
613def load_default_config() -> dict[str, Any]:37def load_default_config() -> dict[str, Any]:
614 """Return a fresh copy of the packaged default configuration."""38 """Return a fresh copy of the packaged default configuration."""
615 return load_packaged_json(_PACKAGE_NAME, _DEFAULT_RESOURCE)39 return load_verticalsigns_config()
61640
61741
618def load_verticalsigns_config(config_path: str | Path | None = None) -> dict[str, Any]:42def load_verticalsigns_config(config_path: str | Path | None = None) -> dict[str, Any]:
619 """Load the detector config, deep-merging an optional user JSON over the defaults.43 """Load the detector config, deep-merging an optional user JSON over the defaults.
62044
621 Unknown top-level sections or per-section keys raise :class:`VerticalSignsConfigError`.45 Args:
46 config_path: Optional user JSON merged over the packaged defaults. Only
47 the keys it carries are overridden.
48
49 Returns:
50 The validated config document, every section present.
51
52 Raises:
53 VerticalSignsConfigError: The user JSON is malformed, or the merged
54 config holds an unknown section/key or an invalid value.
622 """55 """
623 config = load_default_config()56 overrides = _read_user_config(config_path) if config_path is not None else None
624 if config_path is not None:57 config = config_loader.load_config(
625 with Path(config_path).open("r", encoding="utf-8") as handle:58 VerticalSignsConfig,
626 user_config: dict[str, Any] = json.load(handle)59 package=_PACKAGE_NAME,
627 _validate_config(user_config)60 filename=_DEFAULT_RESOURCE,
628 config = deep_merge_dicts(config, user_config)61 overrides=overrides,
629 _validate_config(config)62 context=_CONTEXT,
630 return config63 error_cls=VerticalSignsConfigError,
64 )
65 return config.model_dump()
66
67
68def _read_user_config(config_path: str | Path) -> dict[str, Any]:
69 """Read a user config JSON, wrapping decode errors in the package error."""
70 path = Path(config_path)
71 try:
72 with path.open("r", encoding="utf-8") as handle:
73 user_config: Any = json.load(handle)
74 except json.JSONDecodeError as exc:
75 raise VerticalSignsConfigError(f"Invalid JSON in {path}: {exc}") from exc
76 if not isinstance(user_config, dict):
77 raise VerticalSignsConfigError(
78 f"{path} must hold a JSON object, not a {type(user_config).__name__}"
79 )
80 logger.debug("Loaded %s overrides from %s", _CONTEXT, path)
81 return user_config
Importance #70: src/iolabs_point_cloud_detection_verticalsigns/_config_conic.py @@ -1,17 +1,17 @@
1"""The colour-free conic gate and the conifer rule that rides on it.1"""The colour-free conic gate and the conifer rule that rides on it.
22
3One slice of the flat 372-field ``DetectorConfig``, moved out of3One slice of the flat ``DetectorConfig``, moved out of
4``config.py`` verbatim. ``config.py`` recombines the slices and4``config.py`` verbatim. ``config.py`` recombines the slices and
5re-exports both names defined here.5re-exports both names defined here.
6"""6"""
77
8from dataclasses import dataclass
9from typing import Any8from typing import Any
109
10from iolabs.common import config_loader
1111
12@dataclass(frozen=True)12
13class ConicFields:13class ConicFields(config_loader.ConfigModel):
14 """The colour-free conic gate and the conifer rule that rides on it.14 """The colour-free conic gate and the conifer rule that rides on it.
1515
16 Metres unless stated otherwise.16 Metres unless stated otherwise.
17 """17 """
Importance #71: src/iolabs_point_cloud_detection_verticalsigns/_config_corridor.py @@ -1,19 +1,19 @@
1"""Road corridor rasterization and on-carriageway rejection.1"""Road corridor rasterization and on-carriageway rejection.
22
3Also plate planarity, the bright-panel class and the free-space ring.3Also plate planarity, the bright-panel class and the free-space ring.
44
5One slice of the flat 372-field ``DetectorConfig``, moved out of5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.7re-exports both names defined here.
8"""8"""
99
10from dataclasses import dataclass
11from typing import Any10from typing import Any
1211
12from iolabs.common import config_loader
1313
14@dataclass(frozen=True)14
15class CorridorFields:15class CorridorFields(config_loader.ConfigModel):
16 """Road corridor rasterization and on-carriageway rejection.16 """Road corridor rasterization and on-carriageway rejection.
1717
18 Also plate planarity, the bright-panel class and the free-space ring.18 Also plate planarity, the bright-panel class and the free-space ring.
1919
Importance #72: src/iolabs_point_cloud_detection_verticalsigns/_config_devices.py @@ -1,19 +1,19 @@
1"""Per-device thresholds for delineators, sign posts and gantries.1"""Per-device thresholds for delineators, sign posts and gantries.
22
3Also isolated-floating-pole rejection and duplicate suppression.3Also isolated-floating-pole rejection and duplicate suppression.
44
5One slice of the flat 372-field ``DetectorConfig``, moved out of5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.7re-exports both names defined here.
8"""8"""
99
10from dataclasses import dataclass
11from typing import Any10from typing import Any
1211
12from iolabs.common import config_loader
1313
14@dataclass(frozen=True)14
15class DeviceFields:15class DeviceFields(config_loader.ConfigModel):
16 """Per-device thresholds for delineators, sign posts and gantries.16 """Per-device thresholds for delineators, sign posts and gantries.
1717
18 Also isolated-floating-pole rejection and duplicate suppression.18 Also isolated-floating-pole rejection and duplicate suppression.
1919
Importance #73: src/iolabs_point_cloud_detection_verticalsigns/_config_evidence.py @@ -3,19 +3,19 @@
3Covers the verticality sentinel, tier-2 robust extent statistics,3Covers the verticality sentinel, tier-2 robust extent statistics,
4retroreflectivity references, the single-record transient and4retroreflectivity references, the single-record transient and
5vegetation-texture vetoes, the delineator lattice and tree emission.5vegetation-texture vetoes, the delineator lattice and tree emission.
66
7One slice of the flat 372-field ``DetectorConfig``, moved out of7One slice of the flat ``DetectorConfig``, moved out of
8``config.py`` verbatim. ``config.py`` recombines the slices and8``config.py`` verbatim. ``config.py`` recombines the slices and
9re-exports both names defined here.9re-exports both names defined here.
10"""10"""
1111
12from dataclasses import dataclass
13from typing import Any12from typing import Any
1413
14from iolabs.common import config_loader
1515
16@dataclass(frozen=True)16
17class EvidenceFields:17class EvidenceFields(config_loader.ConfigModel):
18 """Evidence-level thresholds: sentinels, vetoes and reference percentiles.18 """Evidence-level thresholds: sentinels, vetoes and reference percentiles.
1919
20 Covers the verticality sentinel, tier-2 robust extent statistics,20 Covers the verticality sentinel, tier-2 robust extent statistics,
21 retroreflectivity references, the single-record transient and21 retroreflectivity references, the single-record transient and
Importance #74: src/iolabs_point_cloud_detection_verticalsigns/_config_grid.py @@ -1,19 +1,19 @@
1"""Ground, occupancy grid, candidate band and clustering thresholds.1"""Ground, occupancy grid, candidate band and clustering thresholds.
22
3Also the first classification gates and vehicle rejection.3Also the first classification gates and vehicle rejection.
44
5One slice of the flat 372-field ``DetectorConfig``, moved out of5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.7re-exports both names defined here.
8"""8"""
99
10from dataclasses import dataclass
11from typing import Any10from typing import Any
1211
12from iolabs.common import config_loader
1313
14@dataclass(frozen=True)14
15class GridFields:15class GridFields(config_loader.ConfigModel):
16 """Ground, occupancy grid, candidate band and clustering thresholds.16 """Ground, occupancy grid, candidate band and clustering thresholds.
1717
18 Also the first classification gates and vehicle rejection.18 Also the first classification gates and vehicle rejection.
1919
Importance #75: src/iolabs_point_cloud_detection_verticalsigns/_config_model.py @@ -0,0 +1,45 @@
1"""The nested pydantic config model for the vertical-sign detector.
2
3``VerticalSignsConfig`` mirrors ``verticalsigns.default.json`` section for
4section 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 to
6the matching section model and a default to the packaged JSON.
7"""
8
9from iolabs.common import config_loader
10
11from . import _model_devices, _model_grid, _model_road, _model_tree
12
13
14class VerticalSignsConfig(config_loader.ConfigModel):
15 """Every configuration section of the vertical-sign detector."""
16
17 ground: _model_grid.GroundConfig = _model_grid.GroundConfig()
18 occupancy: _model_grid.OccupancyConfig = _model_grid.OccupancyConfig()
19 candidates: _model_grid.CandidatesConfig = _model_grid.CandidatesConfig()
20 clustering: _model_grid.ClusteringConfig = _model_grid.ClusteringConfig()
21 classification: _model_grid.ClassificationConfig = _model_grid.ClassificationConfig()
22 corridor: _model_grid.CorridorConfig = _model_grid.CorridorConfig()
23 context: _model_grid.ContextConfig = _model_grid.ContextConfig()
24 delineator: _model_devices.DelineatorConfig = _model_devices.DelineatorConfig()
25 sign_post: _model_devices.SignPostConfig = _model_devices.SignPostConfig()
26 panel: _model_devices.PanelConfig = _model_devices.PanelConfig()
27 gantry: _model_devices.GantryConfig = _model_devices.GantryConfig()
28 repetitive_row: _model_devices.RepetitiveRowConfig = _model_devices.RepetitiveRowConfig()
29 road_context: _model_road.RoadContextConfig = _model_road.RoadContextConfig()
30 edge_line: _model_road.EdgeLineConfig = _model_road.EdgeLineConfig()
31 field_stake: _model_devices.FieldStakeConfig = _model_devices.FieldStakeConfig()
32 marker_extract: _model_devices.MarkerExtractConfig = _model_devices.MarkerExtractConfig()
33 tree: _model_tree.TreeConfig = _model_tree.TreeConfig()
34 tree_detection: _model_tree.TreeDetectionConfig = _model_tree.TreeDetectionConfig()
35 chroma_vegetation: _model_tree.ChromaVegetationConfig = _model_tree.ChromaVegetationConfig()
36 vehicle: _model_grid.VehicleConfig = _model_grid.VehicleConfig()
37 views: _model_road.ViewsConfig = _model_road.ViewsConfig()
38 perspective: _model_road.PerspectiveConfig = _model_road.PerspectiveConfig()
39 tree_instance: _model_tree.TreeInstanceConfig = _model_tree.TreeInstanceConfig()
40 conic_gate: _model_tree.ConicGateConfig = _model_tree.ConicGateConfig()
41 conifer_rule: _model_tree.ConiferRuleConfig = _model_tree.ConiferRuleConfig()
42 radius: _model_grid.RadiusConfig = _model_grid.RadiusConfig()
43 rail_halfpost: _model_devices.RailHalfpostConfig = _model_devices.RailHalfpostConfig()
44 reject_rescue: _model_devices.RejectRescueConfig = _model_devices.RejectRescueConfig()
45 tcs_ground: _model_tree.TcsGroundConfig = _model_tree.TcsGroundConfig()
0
Importance #76: src/iolabs_point_cloud_detection_verticalsigns/_config_perspective.py @@ -1,17 +1,17 @@
1"""Perspective-projection QC overlay cameras and coverage tolerances.1"""Perspective-projection QC overlay cameras and coverage tolerances.
22
3One slice of the flat 372-field ``DetectorConfig``, moved out of3One slice of the flat ``DetectorConfig``, moved out of
4``config.py`` verbatim. ``config.py`` recombines the slices and4``config.py`` verbatim. ``config.py`` recombines the slices and
5re-exports both names defined here.5re-exports both names defined here.
6"""6"""
77
8from dataclasses import dataclass
9from typing import Any8from typing import Any
109
10from iolabs.common import config_loader
1111
12@dataclass(frozen=True)12
13class PerspectiveFields:13class PerspectiveFields(config_loader.ConfigModel):
14 """Perspective-projection QC overlay cameras and coverage tolerances.14 """Perspective-projection QC overlay cameras and coverage tolerances.
1515
16 Metres unless stated otherwise.16 Metres unless stated otherwise.
17 """17 """
Importance #77: src/iolabs_point_cloud_detection_verticalsigns/_config_roadcontext.py @@ -1,19 +1,19 @@
1"""Road-context gate, driven-lane band and repetitive-row rejection.1"""Road-context gate, driven-lane band and repetitive-row rejection.
22
3Also field-stake rows and embedded-marker extraction.3Also field-stake rows and embedded-marker extraction.
44
5One slice of the flat 378-field ``DetectorConfig``, moved out of5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.7re-exports both names defined here.
8"""8"""
99
10from dataclasses import dataclass
11from typing import Any10from typing import Any
1211
12from iolabs.common import config_loader
1313
14@dataclass(frozen=True)14
15class RoadContextFields:15class RoadContextFields(config_loader.ConfigModel):
16 """Road-context gate, driven-lane band and repetitive-row rejection.16 """Road-context gate, driven-lane band and repetitive-row rejection.
1717
18 Also field-stake rows and embedded-marker extraction.18 Also field-stake rows and embedded-marker extraction.
1919
Importance #78: src/iolabs_point_cloud_detection_verticalsigns/_config_stages.py @@ -2,19 +2,19 @@
22
3The rail-relative half-post pass, the reject-rescue second look and3The rail-relative half-post pass, the reject-rescue second look and
4the ML verifier.4the ML verifier.
55
6One slice of the flat 372-field ``DetectorConfig``, moved out of6One slice of the flat ``DetectorConfig``, moved out of
7``config.py`` verbatim. ``config.py`` recombines the slices and7``config.py`` verbatim. ``config.py`` recombines the slices and
8re-exports both names defined here.8re-exports both names defined here.
9"""9"""
1010
11from dataclasses import dataclass
12from typing import Any11from typing import Any
1312
13from iolabs.common import config_loader
1414
15@dataclass(frozen=True)15
16class StageFields:16class StageFields(config_loader.ConfigModel):
17 """Opt-in post-classification stages.17 """Opt-in post-classification stages.
1818
19 The rail-relative half-post pass, the reject-rescue second look and19 The rail-relative half-post pass, the reject-rescue second look and
20 the ML verifier.20 the ML verifier.
Importance #79: src/iolabs_point_cloud_detection_verticalsigns/_config_treedetect.py @@ -1,17 +1,17 @@
1"""Experimental tree detection and TCS ground filtering of the DEM input.1"""Experimental tree detection and TCS ground filtering of the DEM input.
22
3One slice of the flat 372-field ``DetectorConfig``, moved out of3One slice of the flat ``DetectorConfig``, moved out of
4``config.py`` verbatim. ``config.py`` recombines the slices and4``config.py`` verbatim. ``config.py`` recombines the slices and
5re-exports both names defined here.5re-exports both names defined here.
6"""6"""
77
8from dataclasses import dataclass
9from typing import Any8from typing import Any
109
10from iolabs.common import config_loader
1111
12@dataclass(frozen=True)12
13class TreeDetectionFields:13class TreeDetectionFields(config_loader.ConfigModel):
14 """Experimental tree detection and TCS ground filtering of the DEM input.14 """Experimental tree detection and TCS ground filtering of the DEM input.
1515
16 Metres unless stated otherwise.16 Metres unless stated otherwise.
17 """17 """
Importance #80: src/iolabs_point_cloud_detection_verticalsigns/_config_treeinstance.py @@ -1,16 +1,16 @@
1"""Per-point tree instance splitting of merged canopy blobs.1"""Per-point tree instance splitting of merged canopy blobs.
22
3One slice of the flat 372-field ``DetectorConfig``. ``config.py``3One slice of the flat ``DetectorConfig``. ``config.py``
4recombines the slices and re-exports both names defined here.4recombines the slices and re-exports both names defined here.
5"""5"""
66
7from dataclasses import dataclass
8from typing import Any7from typing import Any
98
9from iolabs.common import config_loader
1010
11@dataclass(frozen=True)11
12class TreeInstanceFields:12class TreeInstanceFields(config_loader.ConfigModel):
13 """Stem-seeded instance splitting of a single ``type: "tree"`` detection.13 """Stem-seeded instance splitting of a single ``type: "tree"`` detection.
1414
15 Metres unless stated otherwise.15 Metres unless stated otherwise.
16 """16 """
Importance #81: src/iolabs_point_cloud_detection_verticalsigns/_config_vegetation.py @@ -1,19 +1,19 @@
1"""Tree rejection, chromaticity vegetation reject and radius fitting.1"""Tree rejection, chromaticity vegetation reject and radius fitting.
22
3Also core compactness and the crown-circle overlay knobs.3Also core compactness and the crown-circle overlay knobs.
44
5One slice of the flat 372-field ``DetectorConfig``, moved out of5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.7re-exports both names defined here.
8"""8"""
99
10from dataclasses import dataclass
11from typing import Any10from typing import Any
1211
12from iolabs.common import config_loader
1313
14@dataclass(frozen=True)14
15class VegetationFields:15class VegetationFields(config_loader.ConfigModel):
16 """Tree rejection, chromaticity vegetation reject and radius fitting.16 """Tree rejection, chromaticity vegetation reject and radius fitting.
1717
18 Also core compactness and the crown-circle overlay knobs.18 Also core compactness and the crown-circle overlay knobs.
1919
Importance #82: src/iolabs_point_cloud_detection_verticalsigns/_model_devices.py @@ -0,0 +1,140 @@
1"""Per-device acceptance gates and the two probe stages.
2
3One slice of the nested :class:`VerticalSignsConfig` model tree; the sections
4mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines
5the slices.
6"""
7
8from iolabs.common import config_loader
9
10
11class DelineatorConfig(config_loader.ConfigModel):
12 """Delineator (Leitpfosten) acceptance gates."""
13
14 h_min_m: float = 0.7
15 h_max_m: float = 1.5
16 max_footprint_m: float = 0.45
17 relaxed_footprint_m: float = 0.85
18 relaxed_min_verticality: float = 0.85
19 relaxed_max_ring_fill_ratio: float = 1.0
20 relaxed_min_hi_intensity_fraction: float = 0.15
21 min_hi_intensity_fraction: float = 0.08
22 min_points: int = 300
23
24
25class SignPostConfig(config_loader.ConfigModel):
26 """Sign-post and plate acceptance gates."""
27
28 max_len_minor_m: float = 0.8
29 h_min_m: float = 1.5
30 h_max_m: float = 6.0
31 min_continuity: float = 0.6
32 plate_hi_intensity_fraction: float = 0.4
33 plate_hi_intensity_fraction_weak: float = 0.3
34 plate_upper_surplus_ratio: float = 2.0
35 min_upper_half_surplus: float = 0.3
36 plate_min_core_rms_m: float = 0.1
37 max_plate_thickness_m: float = 0.15
38 bare_post_min_h_max_m: float = 4.5
39 bare_post_max_core_rms_m: float = 0.065
40 bare_post_min_verticality: float = 0.9
41 bare_post_min_points: int = 450
42
43
44class PanelConfig(config_loader.ConfigModel):
45 """Large panel acceptance gates."""
46
47 min_hi: float = 0.4
48 max_thickness_m: float = 0.2
49 h_min_m: float = 0.9
50 len_major_min_m: float = 1.5
51 len_major_max_m: float = 5.0
52
53
54class GantryConfig(config_loader.ConfigModel):
55 """Gantry leg and pairing gates."""
56
57 h_min_m: float = 4.5
58 len_major_m: float = 8.0
59 max_len_minor_m: float = 6.0
60 pair_station_tolerance_m: float = 5.0
61 pair_min_separation_m: float = 3.0
62 overhead_h_min_m: float = 4.5
63 pair_isolation_radius_m: float = 8.0
64
65
66class RepetitiveRowConfig(config_loader.ConfigModel):
67 """Repetitive-row (guardrail post series) grouping."""
68
69 min_members: int = 4
70 max_spacing_m: float = 5.0
71 max_perp_spread_m: float = 1.5
72 max_h_max_range_m: float = 0.7
73 member_max_len_major_m: float = 2.0
74 member_max_len_minor_m: float = 0.8
75
76
77class FieldStakeConfig(config_loader.ConfigModel):
78 """Field-stake row emission gates."""
79
80 row_emit: bool = True
81 min_members: int = 4
82 min_spacing_m: float = 2.0
83 max_spacing_m: float = 10.0
84 max_spacing_cv: float = 0.35
85
86
87class MarkerExtractConfig(config_loader.ConfigModel):
88 """Bright marker extraction from rejected clusters."""
89
90 min_len_major_m: float = 6.0
91 bright_h_min_m: float = 1.5
92 min_bright_points: int = 400
93 window_m: float = 2.5
94 min_bright_fraction: float = 0.45
95 min_h_max_m: float = 1.6
96 min_vertical_span_m: float = 0.5
97
98
99class RailHalfpostConfig(config_loader.ConfigModel):
100 """Guardrail half-post probe stage."""
101
102 band_lat_m: float = 0.8
103 band_z_hi_m: float = 1.5
104 band_z_lo_m: float = 0.15
105 cluster_cell_m: float = 0.15
106 dedupe_m: float = 1.5
107 enabled: bool = False
108 ground_cell_m: float = 2.0
109 ground_percentile: float = 10.0
110 h_max_m: float = 0.8
111 h_min_m: float = 0.2
112 max_lateral_m: float = 0.5
113 max_width_m: float = 0.2
114 min_emit_points: int = 8
115 min_points: int = 15
116 min_z_extent_m: float = 0.1
117 models_dir: str = ""
118 prime_min_records: int = 2
119 prime_min_sat: int = 1
120 sample_step_m: float = 0.1
121 saturation_intensity: float = 55000.0
122
123
124class RejectRescueConfig(config_loader.ConfigModel):
125 """Reject-rescue stage gates."""
126
127 accepted_exclusion_m: float = 2.0
128 enabled: bool = False
129 h_max_m: float = 1.6
130 h_min_m: float = 0.85
131 max_core_rms_m: float = 0.2
132 merge_radius_m: float = 1.0
133 min_continuity: float = 0.8
134 min_decile_fill: float = 0.6
135 min_h_over_width: float = 1.4
136 min_points: int = 30
137 min_records: int = 2
138 min_roadctx_sat: int = 17
139 min_verticality: float = 0.9
140 per_segment_cap: int = 0
0
Importance #83: src/iolabs_point_cloud_detection_verticalsigns/_model_grid.py @@ -0,0 +1,152 @@
1"""Grid, candidate, classification, radius and corridor config sections.
2
3One slice of the nested :class:`VerticalSignsConfig` model tree; the sections
4mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines
5the slices.
6"""
7
8from iolabs.common import config_loader
9
10
11class GroundConfig(config_loader.ConfigModel):
12 """Ground-model raster cell size and percentile."""
13
14 cell_m: float = 0.75
15 percentile: float = 8.0
16
17
18class OccupancyConfig(config_loader.ConfigModel):
19 """Occupancy grid used to find candidate cells."""
20
21 cell_m: float = 0.15
22
23
24class CandidatesConfig(config_loader.ConfigModel):
25 """Height band and seed-cell gates for candidate points."""
26
27 min_height_m: float = 0.3
28 max_height_m: float = 10.0
29 seed_min_vertical_span_m: float = 0.8
30 seed_min_h_max_m: float = 0.9
31 seed_bright_min_vertical_span_m: float = 0.45
32 seed_bright_min_h_max_m: float = 0.6
33 seed_bright_min_points: int = 3
34
35
36class ClusteringConfig(config_loader.ConfigModel):
37 """DBSCAN clustering of seed-cell centres."""
38
39 eps_m: float = 0.45
40 min_samples: int = 1
41 hull_margin_m: float = 0.2
42
43
44class ClassificationConfig(config_loader.ConfigModel):
45 """Cluster-level accept/reject gates and ML verifier wiring."""
46
47 continuity_bin_m: float = 0.25
48 reject_len_major_m: float = 6.0
49 reject_h_max_with_large_footprint_m: float = 4.5
50 min_continuity: float = 0.5
51 min_accept_h_max_m: float = 0.9
52 core_rms_bin_m: float = 0.25
53 core_rms_h_min_m: float = 0.3
54 core_rms_h_cap_m: float = 3.0
55 hi_intensity_all_points_percentile: float = 98.0
56 min_volumetric_density: float = 8000.0
57 pole_floating_min_h_min_m: float = 3.5
58 pole_isolated_radius_m: float = 8.0
59 dedup_radius_m: float = 0.8
60 emit_trees: bool = False
61 ml_verifier_enabled: bool = True
62 ml_veto_threshold: float = -1.0
63 ml_model_path: str = ""
64 lattice_admission: bool = True
65 lattice_max_seed_spacing_m: float = 60.0
66 lattice_max_skip: int = 6
67 lattice_max_spacing_resid: float = 0.15
68 lattice_min_anchors: int = 4
69 lattice_min_seed_spacing_m: float = 15.0
70 lattice_pool_h_max_max_m: float = 1.4
71 lattice_pool_h_max_min_m: float = 0.8
72 lattice_pool_max_len_major_m: float = 1.2
73 lattice_pool_max_plate_thickness_m: float = 0.05
74 lattice_pool_min_hi_seed_fraction: float = 0.15
75 lattice_pool_min_points: int = 20
76 lattice_pool_min_verticality: float = 0.85
77 lattice_snap_m: float = 3.0
78 ml_veto_requires_corridor: bool = True
79 robust_extent_hi_percentile: float = 99.0
80 robust_extent_lo_percentile: float = 1.0
81 robust_extent_stats: bool = True
82 robust_h_max_percentile: float = 98.0
83 seed_bright_percentile: float | None = 95.0
84 single_record_transient_veto: bool = True
85 transient_max_h_max_m: float = 2.5
86 transient_max_verticality: float = 0.3
87 transient_min_len_major_m: float = 2.0
88 veg_texture_min_hi_seed_fraction: float = 0.668
89 veg_texture_min_plate_thickness_m: float = 0.05
90 veg_texture_veto: bool = True
91 verticality_sentinel_fix: bool = True
92
93
94class RadiusConfig(config_loader.ConfigModel):
95 """Cylinder-radius fitting and crown-lobe estimation."""
96
97 crown_lobe_coverage_target: float = 0.95
98 crown_lobe_gap_m: float = 0.5
99 crown_lobe_max_count: int = 8
100 crown_lobe_min_points: int = 30
101 crown_lobe_min_samples: int = 10
102 crown_radius_percentile: float = 95.0
103 debug_cluster_points: bool = False
104 fit_bin_m: float = 0.25
105 fit_divergence_factor: float = 4.0
106 fit_min_arc_deg: float = 60.0
107 fit_min_bin_points: int = 8
108 fit_residual_abs_m: float = 0.03
109 fit_residual_frac: float = 0.35
110 pole_radius_max_m: float = 0.5
111 trunk_radius_max_m: float = 0.8
112
113
114class CorridorConfig(config_loader.ConfigModel):
115 """Road-corridor raster and on-carriageway gates."""
116
117 max_dist_to_road_m: float = 10.0
118 on_carriageway_dist_m: float = 0.25
119 on_carriageway_exempt_h_max_m: float = 4.5
120 density_min_points: float = 8.0
121 density_frac_p95: float = 0.06
122 density_max_points: float = 150.0
123 component_min_area_frac: float = 0.15
124 component_min_area_cells: int = 40
125 on_carriageway_road_fraction: float = 0.7
126 on_carriageway_bright_frac: float = 0.5
127 on_carriageway_delineator_max_len_major_m: float = 0.65
128 on_carriageway_delineator_min_verticality: float = 0.95
129
130
131class ContextConfig(config_loader.ConfigModel):
132 """Ring and forest neighbourhood context features."""
133
134 ring_r_inner_m: float = 0.5
135 ring_r_outer_m: float = 1.5
136 ring_h_min_m: float = 0.5
137 ring_h_max_m: float = 2.5
138 ring_max_fill_ratio: float = 2.0
139 ring_min_points: int = 40
140 forest_min_neighbors: int = 3
141 forest_radius_m: float = 8.0
142 forest_neighbor_min_h_max_m: float = 2.0
143
144
145class VehicleConfig(config_loader.ConfigModel):
146 """Vehicle-rejection envelope."""
147
148 h_min_m: float = 1.5
149 h_max_m: float = 4.5
150 len_major_m: float = 2.5
151 len_minor_m: float = 1.5
152 max_hi_intensity_fraction: float = 0.1
0
Importance #84: src/iolabs_point_cloud_detection_verticalsigns/_model_road.py @@ -0,0 +1,99 @@
1"""Road-context, edge-line and QC rendering config sections.
2
3One slice of the nested :class:`VerticalSignsConfig` model tree; the sections
4mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines
5the slices.
6"""
7
8from iolabs.common import config_loader
9
10
11class RoadContextConfig(config_loader.ConfigModel):
12 """Road-context saturation raster and XML carriageway votes."""
13
14 gate_enabled: bool = True
15 xml_enabled: bool = True
16 xml_min_agreement: float = 0.6
17 xml_vote_slack_m: float = 3.0
18 xml_max_distance_m: float = 60.0
19 xml_station_tolerance_m: float = 2.0
20 xml_station_step_m: float = 10.0
21 min_carriageway_width_m: float = 3.0
22 max_carriageway_width_m: float = 20.0
23 paint_fallback_enabled: bool = False
24 saturation_intensity: float = 55000.0
25 radius_m: float = 15.0
26 neighbour_span: int = 1
27 cache_dir: str = ""
28 min_neighbourhood_saturated: int = 1000
29
30
31class EdgeLineConfig(config_loader.ConfigModel):
32 """Edge-line paint detection and far-distance filtering."""
33
34 gate_enabled: bool = True
35 paint_max_height_m: float = 0.35
36 paint_min_height_m: float = -0.25
37 paint_intensity_percentile: float = 95.0
38 paint_subsample: int = 20
39 station_len_m: float = 10.0
40 min_window_returns: int = 2000
41 lateral_bin_m: float = 0.1
42 min_line_points: int = 40
43 max_line_width_m: float = 1.5
44 min_line_along_fill: float = 0.4
45 drive_line_bin_m: float = 0.5
46 min_band_width_m: float = 2.0
47 max_band_width_m: float = 9.0
48 inward_margin_m: float = 0.3
49 min_coverage_frac: float = 0.6
50 min_axis_contrast: float = 3.0
51 axis_search_radius_m: float = 40.0
52 axis_max_angle_cos: float = 0.8
53 axis_max_distance_m: float = 150.0
54 exempt_h_max_m: float = 4.5
55 reject_requires_transient: bool = True
56 transient_max_records: int = 1
57 far_filter_enabled: bool = True
58 far_max_distance_m: float = 30.0
59 far_include_lane_lines: bool = True
60 far_tier2_enabled: bool = True
61 far_tier2_distance_m: float = 15.0
62 far_tier2_max_saturation: int = 150
63 max_carriageway_width_m: float = 20.0
64 min_carriageway_width_m: float = 3.0
65 paint_fallback_enabled: bool = False
66 xml_enabled: bool = True
67 xml_max_distance_m: float = 60.0
68 xml_min_agreement: float = 0.6
69 xml_station_step_m: float = 10.0
70 xml_station_tolerance_m: float = 2.0
71 xml_vote_slack_m: float = 3.0
72
73
74class ViewsConfig(config_loader.ConfigModel):
75 """Rendered QC view cameras and image size."""
76
77 near_radius_m: float = 45.0
78 fov_deg: float = 55.0
79 splat: int = 2
80 image_width: int = 1100
81 image_height: int = 750
82 view_names: tuple[str, ...] = ("back", "side")
83
84
85class PerspectiveConfig(config_loader.ConfigModel):
86 """Perspective-projection QC overlay cameras and tolerances."""
87
88 depth_tol_m: float = 0.5
89 line_samples: int = 20
90 occluded_alpha: int = 90
91 solid_width_px: int = 3
92 halo_width_px: int = 6
93 base_marker_radius_px: int = 6
94 back_distance_m: float = 22.0
95 back_height_m: float = 4.0
96 context_distance_m: float = 40.0
97 context_height_m: float = 6.0
98 share_radius_m: float = 15.0
99 coverage_tol_m: float = 0.5
0
Importance #85: src/iolabs_point_cloud_detection_verticalsigns/_model_tree.py @@ -0,0 +1,180 @@
1"""Tree, vegetation and ground-filter config sections.
2
3One slice of the nested :class:`VerticalSignsConfig` model tree; the sections
4mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines
5the slices.
6"""
7
8from iolabs.common import config_loader
9
10
11class TreeConfig(config_loader.ConfigModel):
12 """Legacy tree crown hints."""
13
14 crown_h_min_m: float = 2.0
15 crown_max_area_m2: float = 4.0
16 isotropy_ratio: float = 0.75
17 greenness_hint: float = 0.45
18
19
20class TreeDetectionConfig(config_loader.ConfigModel):
21 """Tree detection stage: which blobs are emitted as trees."""
22
23 enabled: bool = False
24 max_dist_to_road_m: float = 20.0
25 seed_min_vertical_span_m: float = 1.5
26 seed_points_above_m: float = 2.0
27 eps_m: float = 1.5
28 min_samples: int = 3
29 hull_margin_m: float = 0.5
30 min_points: int = 60
31 bridge_max_on_road_fraction: float = 0.6
32 dedup_radius_m: float = 2.0
33 min_confidence: float = -1.0
34 model_path: str = ""
35 hedge_split_enabled: bool = False
36
37
38class TreeInstanceConfig(config_loader.ConfigModel):
39 """Tree instance splitting: how one blob is cut into instances."""
40
41 enabled: bool = False
42 local_ground_footprint_m: float = 15.0
43 local_ground_cell_m: float = 2.0
44 local_ground_percentile: float = 5.0
45 local_ground_window_m: float = 6.0
46 crown_base_bin_m: float = 0.25
47 crown_base_density_frac: float = 0.35
48 crown_base_run_bins: int = 3
49 crown_base_min_m: float = 1.2
50 stem_band_low_m: float = 0.5
51 stem_band_cap_m: float = 4.0
52 stem_band_min_thickness_m: float = 0.7
53 stem_eps_m: float = 0.35
54 stem_min_samples: int = 20
55 stem_max_diameter_m: float = 1.2
56 stem_min_vertical_reach: float = 0.5
57 stem_min_verticality: float = 0.6
58 stem_min_score: float = 0.45
59 stem_exg_bonus: float = 0.1
60 stem_merge_dist_m: float = 1.2
61 stem_uncertain_dist_m: float = 2.0
62 apex_fallback_enabled: bool = True
63 apex_cell_m: float = 0.5
64 apex_smooth_sigma_m: float = 0.7
65 apex_min_separation_m: float = 2.5
66 apex_min_prominence_m: float = 0.8
67 apex_min_height_m: float = 2.0
68 apex_trigger_span_m: float = 8.0
69 apex_seed_radius_m: float = 0.6
70 apex_confidence_scale: float = 0.6
71 min_points_per_instance: int = 1200
72 seedless_single_max_footprint_m: float = 10.0
73 seedless_single_min_height_m: float = 1.5
74 seedless_single_max_height_m: float = 25.0
75 seedless_single_confidence: float = 0.35
76 seedless_min_p95_h_m: float = 2.0
77 seedless_max_aspect: float = 2.5
78 seedless_min_points: int = 800
79 float_fragment_min_h_m: float = 3.0
80 float_fragment_p25_h_m: float = 4.0
81 min_tree_footprint_m: float = 1.5
82 max_tree_footprint_m: float = 60.0
83 megacluster_points: int = 1000000
84 planar_min_footprint_m: float = 12.0
85 planar_cell_m: float = 1.0
86 planar_max_spread_m: float = 0.3
87 planar_fraction_min: float = 0.55
88 hedge_max_ground_gap_m: float = 2.0
89 hedge_max_height_m: float = 7.5
90 hedge_min_length_m: float = 8.0
91 hedge_min_area_m2: float = 20.0
92 hedge_min_continuity: float = 0.75
93 hedge_continuity_bin_m: float = 1.0
94 hedge_max_top_relief_m: float = 1.5
95 hedge_max_seed_per_10m: float = 1.0
96 hedge_stem_score_min: float = 0.6
97 assign_voxel_m: float = 0.3
98 assign_max_gap_m: float = 1.25
99 assign_max_graph_dist_m: float = 30.0
100 max_claim_radius_m: float = 9.0
101 low_evidence_margin: float = 0.05
102 low_evidence_abstain: bool = False
103 min_cluster_points: int = 150
104 single_tree_footprint_m: float = 8.0
105 partial_abstain_fraction: float = 0.2
106 min_instance_points: int = 120
107 min_instance_fraction: float = 0.01
108 instance_max_linearity: float = 0.92
109 instance_min_minor_m: float = 1.0
110 instance_min_vertical_m: float = 1.5
111 instance_min_thickness_share: float = 0.02
112 confidence_seed_weight: float = 0.6
113 confidence_size_ref_points: float = 2000.0
114 confidence_max: float = 0.95
115 confidence_fallback_max: float = 0.9
116
117
118class ChromaVegetationConfig(config_loader.ConfigModel):
119 """ExG chromaticity vegetation veto."""
120
121 enabled: bool = False
122 exg_min: float = 0.155
123 exg_iqr_min: float = 0.21
124 max_hi_intensity_fraction: float = 0.08
125 min_change_of_curvature: float = 0.2
126 min_plate_thickness_m: float = 0.175
127
128
129class TcsGroundConfig(config_loader.ConfigModel):
130 """Tablecloth (TCS) ground pre-filter."""
131
132 cache_dir: str = ""
133 cell_m: float = 0.2
134 elev_scalar: float = 0.0
135 enabled: bool = False
136 max_elev_diff_m: float = 0.15
137 mechanism: str = "smrf_numpy"
138 pit_fill_enabled: bool = True
139 slope_threshold: float = 0.3
140 smrf_max_window_m: float = 6.0
141
142
143class ConicGateConfig(config_loader.ConfigModel):
144 """Conic-shape gate for cone/tree separation."""
145
146 apex_deg_max: float = 35.0
147 apex_deg_min: float = 5.0
148 change_of_curvature_min: float = 0.06
149 enabled: bool = False
150 h_max_min_m: float = 2.5
151 h_over_width_max: float = 12.0
152 h_over_width_min: float = 1.5
153 max_hi_intensity_fraction: float = 0.2
154 max_on_road_fraction: float = 0.6
155 min_crown_area_m2: float = 0.3
156 min_decile_fill_fraction: float = 0.8
157 omnivariance_min: float = 0.1
158 taper_slope_max: float = -0.4
159 taper_slope_robust_max: float = -0.3
160 texture_cue_enabled: bool = True
161
162
163class ConiferRuleConfig(config_loader.ConfigModel):
164 """Conifer acceptance rule."""
165
166 enabled: bool = False
167 h_max_min_m: float = 2.0
168 h_over_width_max: float = 15.0
169 h_over_width_min: float = 2.0
170 max_apex_ratio: float = 0.75
171 max_crown_base_frac: float = 0.55
172 max_crown_taper: float = -0.1
173 max_hi_intensity_fraction: float = 0.2
174 max_on_road_fraction: float = 0.6
175 max_stem_ratio: float = 2.2
176 max_volumetric_density: float = 380.0
177 min_change_of_curvature: float = 0.04
178 min_crown_area_m2: float = 0.2
179 min_decile_fill_fraction: float = 0.8
180 min_volumetric_density: float = 140.0
0
Importance #86: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -1,13 +1,16 @@
1"""Detector configuration.1"""Detector configuration.
22
3The 378-field :class:`DetectorConfig` and its ``from_mapping`` flattener are3The 379-field :class:`DetectorConfig` and its ``from_mapping`` flattener are
4split by section across the ``_config_<section>`` modules; this module4split by section across the ``_config_<section>`` modules; this module
5recombines them and re-exports every piece, so ``from .config import X``5recombines them and re-exports every piece, so ``from .config import X``
6keeps working for every name that used to live here.6keeps working for every name that used to live here.
7
8``DetectorConfig`` is the FLAT view the detector modules read
9(``config.ground_cell_m``); the NESTED document it is built from is validated
10by the :class:`VerticalSignsConfig` model tree in ``_config_model``.
7"""11"""
812
9from dataclasses import dataclass
10from pathlib import Path13from pathlib import Path
11from typing import Any14from typing import Any
1215
13from ._config import load_verticalsigns_config16from ._config import load_verticalsigns_config
Importance #87: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -49,16 +52,15 @@
49 "perspective_kwargs",52 "perspective_kwargs",
50]53]
5154
5255
53@dataclass(frozen=True)
54class DetectorConfig( # noqa: D101 - docstring below, after the base list56class DetectorConfig( # noqa: D101 - docstring below, after the base list
55 # The bases are listed in REVERSE section order ON PURPOSE: dataclasses57 # The bases are listed in REVERSE section order ON PURPOSE: both
56 # collects fields by walking the MRO backwards, so this ordering58 # dataclasses and pydantic collect fields by walking the MRO backwards, so
57 # reproduces the original single-class field order exactly (ground first,59 # this ordering reproduces the original single-class field order exactly
58 # then perspective, then the slices added since). Reordering these lines60 # (ground first, then perspective, then the slices added since).
59 # reorders the fields, so a NEW slice goes at the TOP of this list to have61 # Reordering these lines reorders the fields, so a NEW slice goes at the
60 # its fields appended at the end.62 # TOP of this list to have its fields appended at the end.
61 TreeInstanceFields,63 TreeInstanceFields,
62 PerspectiveFields,64 PerspectiveFields,
63 ConicFields,65 ConicFields,
64 TreeDetectionFields,66 TreeDetectionFields,
Importance #88: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -75,9 +77,9 @@
75 @classmethod77 @classmethod
76 def from_mapping(cls, config: dict[str, Any]) -> "DetectorConfig":78 def from_mapping(cls, config: dict[str, Any]) -> "DetectorConfig":
77 """Builds a DetectorConfig by flattening the nested config sections.79 """Builds a DetectorConfig by flattening the nested config sections.
7880
79 Only keys present in a section override the corresponding dataclass81 Only keys present in a section override the corresponding model
80 default, so a partial (or default) config reproduces the built-in82 default, so a partial (or default) config reproduces the built-in
81 thresholds exactly.83 thresholds exactly.
8284
83 Args:85 Args:
Importance #89: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -101,8 +103,30 @@
101 **perspective_kwargs(config, defaults),103 **perspective_kwargs(config, defaults),
102 **tree_instance_kwargs(config, defaults),104 **tree_instance_kwargs(config, defaults),
103 )105 )
104106
107 def with_overrides(self, **overrides: Any) -> "DetectorConfig":
108 """Return a copy of this config with *overrides* applied.
109
110 ``model_copy(update=...)`` skips validation, so a misspelled name would
111 be attached as a new attribute and the intended threshold would keep
112 its default. The names are therefore checked here, reproducing the
113 ``TypeError`` that ``dataclasses.replace`` used to raise.
114
115 Args:
116 overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``.
117
118 Returns:
119 A new frozen config carrying *overrides*.
120
121 Raises:
122 ValueError: An override names a field this config does not declare.
123 """
124 unknown = sorted(set(overrides) - set(type(self).model_fields))
125 if unknown:
126 raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}")
127 return self.model_copy(update=overrides)
128
105 @classmethod129 @classmethod
106 def load(cls, config_path: str | Path | None = None) -> "DetectorConfig":130 def load(cls, config_path: str | Path | None = None) -> "DetectorConfig":
107 """Load config from the packaged defaults merged with an optional user JSON."""131 """Load config from the packaged defaults merged with an optional user JSON."""
108 return cls.from_mapping(load_verticalsigns_config(config_path))132 return cls.from_mapping(load_verticalsigns_config(config_path))
Importance #90: src/iolabs_point_cloud_detection_verticalsigns/ml.py @@ -680,10 +680,8 @@
680 forest stand merges into one large low-curvature canopy blob that the680 forest stand merges into one large low-curvature canopy blob that the
681 fine-DBSCAN sign-path trees never produce, so the classifier must see it.681 fine-DBSCAN sign-path trees never produce, so the classifier must see it.
682 Lazy imports avoid a heavy import chain at module load.682 Lazy imports avoid a heavy import chain at module load.
683 """683 """
684 from dataclasses import replace
685
686 import numpy as np684 import numpy as np
687685
688 from . import detect as _detect686 from . import detect as _detect
689 from .corridor import build_road_corridor687 from .corridor import build_road_corridor
Importance #91: src/iolabs_point_cloud_detection_verticalsigns/ml.py @@ -715,10 +713,10 @@
715 )713 )
716 mask = select_tree_seed_cells(counts, vspan, hmax, config)714 mask = select_tree_seed_cells(counts, vspan, hmax, config)
717 if not np.any(mask):715 if not np.any(mask):
718 return []716 return []
719 tcfg = replace(717 tcfg = config.with_overrides(
720 config, cluster_eps_m=config.tree_eps_m,718 cluster_eps_m=config.tree_eps_m,
721 cluster_min_samples=config.tree_min_samples,719 cluster_min_samples=config.tree_min_samples,
722 cluster_hull_margin_m=config.tree_hull_margin_m,720 cluster_hull_margin_m=config.tree_hull_margin_m,
723 )721 )
724 clusters = cluster_candidates(722 clusters = cluster_candidates(
Importance #92: src/iolabs_point_cloud_detection_verticalsigns/trees.py @@ -19,10 +19,8 @@
19"""19"""
2020
21from __future__ import annotations21from __future__ import annotations
2222
23from dataclasses import replace
24
25import numpy as np23import numpy as np
26from iolabs.logstash import get_props_logger24from iolabs.logstash import get_props_logger
2725
28from ._log_props import LOG_PROPS26from ._log_props import LOG_PROPS
Importance #93: src/iolabs_point_cloud_detection_verticalsigns/trees.py @@ -117,10 +115,9 @@
117 if not np.any(seed_mask):115 if not np.any(seed_mask):
118 return []116 return []
119117
120 # Coarser DBSCAN via a shallow config clone (own eps / min_samples / margin).118 # Coarser DBSCAN via a shallow config clone (own eps / min_samples / margin).
121 tree_cfg = replace(119 tree_cfg = config.with_overrides(
122 config,
123 cluster_eps_m=config.tree_eps_m,120 cluster_eps_m=config.tree_eps_m,
124 cluster_min_samples=config.tree_min_samples,121 cluster_min_samples=config.tree_min_samples,
125 cluster_hull_margin_m=config.tree_hull_margin_m,122 cluster_hull_margin_m=config.tree_hull_margin_m,
126 )123 )
Importance #94: tests/conftest.py @@ -0,0 +1,29 @@
1"""Shared fixtures for the vertical-sign detector tests."""
2
3from collections.abc import Callable
4from typing import Any
5
6import pytest
7from iolabs.common import config_loader
8
9
10def _section_values(model: type[config_loader.ConfigModel]) -> dict[str, Any]:
11 """Return one valid non-default value per field of *model*."""
12 values: dict[str, Any] = {}
13 for name, field in model.model_fields.items():
14 annotation = field.annotation
15 if annotation is bool:
16 values[name] = not field.default
17 elif annotation is int:
18 values[name] = int(field.default) + 1
19 elif annotation is str:
20 values[name] = f"{field.default}_x"
21 else:
22 values[name] = 0.5
23 return values
24
25
26@pytest.fixture
27def section_values() -> Callable[[type[config_loader.ConfigModel]], dict[str, Any]]:
28 """Return a builder for a full override of one config section."""
29 return _section_values
0
Importance #95: tests/test_chroma_vegetation.py @@ -8,15 +8,14 @@
8"""8"""
99
10from __future__ import annotations10from __future__ import annotations
1111
12import dataclasses
13import json12import json
1413
15import numpy as np14import numpy as np
16import pytest15import pytest
1716
18from iolabs_point_cloud_detection_verticalsigns import _config17from iolabs_point_cloud_detection_verticalsigns import _config, _model_tree
19from iolabs_point_cloud_detection_verticalsigns.classify import (18from iolabs_point_cloud_detection_verticalsigns.classify import (
20 CHROMA_VETOABLE_TYPES,19 CHROMA_VETOABLE_TYPES,
21 apply_tree_emission,20 apply_tree_emission,
22 classify_cluster,21 classify_cluster,
Importance #96: tests/test_chroma_vegetation.py @@ -77,9 +76,9 @@
77 return ClusterFeatures(**base)76 return ClusterFeatures(**base)
7877
7978
80def _enabled(**overrides) -> DetectorConfig:79def _enabled(**overrides) -> DetectorConfig:
81 return dataclasses.replace(DetectorConfig(), chroma_veg_enabled=True, **overrides)80 return DetectorConfig().with_overrides(chroma_veg_enabled=True, **overrides)
8281
8382
84# --- the rule does its job -------------------------------------------------83# --- the rule does its job -------------------------------------------------
8584
Importance #97: tests/test_chroma_vegetation.py @@ -272,9 +271,9 @@
272# --- wiring ----------------------------------------------------------------271# --- wiring ----------------------------------------------------------------
273272
274273
275def test_emit_trees_promotes_the_reason() -> None:274def test_emit_trees_promotes_the_reason() -> None:
276 config = dataclasses.replace(DetectorConfig(), emit_trees=True)275 config = DetectorConfig().with_overrides(emit_trees=True)
277 assert apply_tree_emission(None, "chroma_vegetation", config) == (276 assert apply_tree_emission(None, "chroma_vegetation", config) == (
278 "tree",277 "tree",
279 "chroma_vegetation",278 "chroma_vegetation",
280 )279 )
Importance #98: tests/test_chroma_vegetation.py @@ -290,23 +289,23 @@
290 assert config.chroma_veg_exg_iqr_min == DetectorConfig().chroma_veg_exg_iqr_min289 assert config.chroma_veg_exg_iqr_min == DetectorConfig().chroma_veg_exg_iqr_min
291290
292291
293def test_config_rejects_an_unknown_key_in_the_new_section(tmp_path) -> None:292def test_config_rejects_an_unknown_key_in_the_new_section(tmp_path) -> None:
294 """Pins the _ALLOWED_BY_SECTION wiring.293 """Pins the ChromaVegetationConfig wiring.
295294
296 Without this, deleting the "chroma_vegetation" entry from that mapping295 Without this, dropping the "chroma_vegetation" field from the model tree
297 leaves the whole suite green while silently disabling validation for the296 leaves the whole suite green while silently disabling validation for the
298 section -- a typo'd threshold would then be accepted and ignored.297 section -- a typo'd threshold would then be accepted and ignored.
299 """298 """
300 path = tmp_path / "override.json"299 path = tmp_path / "override.json"
301 path.write_text(json.dumps({"chroma_vegetation": {"exg_minimum": 0.2}}))300 path.write_text(json.dumps({"chroma_vegetation": {"exg_minimum": 0.2}}))
302 with pytest.raises(_config.ConfigError):301 with pytest.raises(_config.VerticalSignsConfigError):
303 _config.load_verticalsigns_config(path)302 _config.load_verticalsigns_config(path)
304303
305304
306def test_config_accepts_every_documented_key(tmp_path) -> None:305def test_config_accepts_every_documented_key(tmp_path, section_values) -> None:
307 """The other half: no allowlisted key is rejected."""306 """The other half: no modelled key is rejected."""
308 section = {k: 0.1 for k in _config.ALLOWED_CHROMA_VEGETATION_KEYS}307 section = section_values(_model_tree.ChromaVegetationConfig)
309 section["enabled"] = True308 section["enabled"] = True
310 path = tmp_path / "override.json"309 path = tmp_path / "override.json"
311 path.write_text(json.dumps({"chroma_vegetation": section}))310 path.write_text(json.dumps({"chroma_vegetation": section}))
312 assert _config.load_verticalsigns_config(path)["chroma_vegetation"]["enabled"]311 assert _config.load_verticalsigns_config(path)["chroma_vegetation"]["enabled"]
Importance #99: tests/test_config_split.py @@ -6,16 +6,18 @@
6things must stay true for that split to be invisible to callers:6things must stay true for that split to be invisible to callers:
77
8* every field is still reachable from the nested config document,8* every field is still reachable from the nested config document,
9* the slices partition the fields (no field lost, none declared twice),9* the slices partition the fields (no field lost, none declared twice),
10* an absent key still falls back to the dataclass default.10* an absent key still falls back to the model default.
11"""11"""
1212
13import dataclasses
14import json13import json
15import re14import re
16from pathlib import Path15from pathlib import Path
1716
17from iolabs.common import config_loader
18
19from iolabs_point_cloud_detection_verticalsigns import _config_model
18from iolabs_point_cloud_detection_verticalsigns._config import load_default_config20from iolabs_point_cloud_detection_verticalsigns._config import load_default_config
19from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig21from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig
2022
21CONFIG_PY = (23CONFIG_PY = (
Importance #100: tests/test_config_split.py @@ -45,9 +47,9 @@
45 """47 """
46 section_locals: dict[str, str] = {}48 section_locals: dict[str, str] = {}
47 document: dict[str, dict] = {}49 document: dict[str, dict] = {}
48 expected: dict[str, object] = {}50 expected: dict[str, object] = {}
49 fields = {f.name: f for f in dataclasses.fields(DetectorConfig)}51 fields = DetectorConfig.model_fields
5052
51 for path in sorted(CONFIG_PY.parent.glob("_config_*.py")):53 for path in sorted(CONFIG_PY.parent.glob("_config_*.py")):
52 text = path.read_text()54 text = path.read_text()
53 section_locals.update(55 section_locals.update(
Importance #101: tests/test_config_split.py @@ -69,9 +71,9 @@
69 wrong = {n: (getattr(built, n), v) for n, v in expected.items() if getattr(built, n) != v}71 wrong = {n: (getattr(built, n), v) for n, v in expected.items() if getattr(built, n) != v}
70 assert not wrong72 assert not wrong
7173
7274
73def test_absent_sections_fall_back_to_the_dataclass_defaults() -> None:75def test_absent_sections_fall_back_to_the_model_defaults() -> None:
74 assert DetectorConfig.from_mapping({}) == DetectorConfig()76 assert DetectorConfig.from_mapping({}) == DetectorConfig()
7577
7678
77def test_a_partial_section_only_overrides_the_keys_it_carries() -> None:79def test_a_partial_section_only_overrides_the_keys_it_carries() -> None:
Importance #102: tests/test_config_split.py @@ -80,8 +82,46 @@
80 assert built.ground_percentile == DetectorConfig().ground_percentile82 assert built.ground_percentile == DetectorConfig().ground_percentile
81 assert built.perspective_coverage_tol_m == DetectorConfig().perspective_coverage_tol_m83 assert built.perspective_coverage_tol_m == DetectorConfig().perspective_coverage_tol_m
8284
8385
86def test_every_mapped_key_exists_in_the_nested_model() -> None:
87 """A flat field wired to a section key the model does not declare is dead.
88
89 ``load_verticalsigns_config`` validates against the model, so such a key is
90 rejected for a user config and can only ever hold its flat default.
91 """
92 document, _ = _saturating_config()
93 merged = config_loader.deep_merge_dicts(load_default_config(), document)
94 assert _config_model.VerticalSignsConfig.model_validate(merged)
95
96
84def test_the_packaged_defaults_round_trip() -> None:97def test_the_packaged_defaults_round_trip() -> None:
85 packaged = load_default_config()98 packaged = load_default_config()
86 assert json.dumps(packaged) # it is a plain JSON document99 assert json.dumps(packaged) # it is a plain JSON document
87 assert DetectorConfig.from_mapping(packaged) == DetectorConfig.load()100 assert DetectorConfig.from_mapping(packaged) == DetectorConfig.load()
101
102
103def test_the_packaged_defaults_equal_the_flat_defaults() -> None:
104 """The nested model and the flat slices must not drift apart.
105
106 The nested :class:`VerticalSignsConfig` sections and the flat
107 ``DetectorConfig`` slices declare the same numbers twice, so a value
108 changed on one side only is a silent config bug: ``DetectorConfig()`` (what
109 tests and ad-hoc calls build) would disagree with ``DetectorConfig.load()``
110 (what the detector runs).
111 """
112 assert DetectorConfig.from_mapping(load_default_config()) == DetectorConfig()
113
114
115def test_with_overrides_rejects_a_misspelled_field() -> None:
116 """A typo must not become a new attribute while the threshold keeps its default.
117
118 ``model_copy(update=...)`` skips validation, so this is the only thing
119 standing between a misspelled override and a silently ignored threshold.
120 """
121 assert DetectorConfig().with_overrides(cluster_eps_m=0.9).cluster_eps_m == 0.9
122 try:
123 DetectorConfig().with_overrides(cluster_eps=0.9)
124 except ValueError as exc:
125 assert "cluster_eps" in str(exc)
126 else: # pragma: no cover - the failure the test exists to catch
127 raise AssertionError("a misspelled field name was accepted")
Importance #103: tests/test_detect.py @@ -1,6 +1,5 @@
1import csv1import csv
2import dataclasses
3import io2import io
43
5import numpy as np4import numpy as np
65
Importance #104: tests/test_detect.py @@ -14,9 +13,9 @@
14 assign_record_coverage,13 assign_record_coverage,
15)14)
16from iolabs_point_cloud_detection_verticalsigns.features import ClusterFeatures15from iolabs_point_cloud_detection_verticalsigns.features import ClusterFeatures
1716
18_CFG = dataclasses.replace(DetectorConfig(), tree_crown_h_min_m=2.5)17_CFG = DetectorConfig().with_overrides(tree_crown_h_min_m=2.5)
1918
2019
21def _feat(**overrides) -> ClusterFeatures:20def _feat(**overrides) -> ClusterFeatures:
22 base = dict(21 base = dict(
Importance #105: tests/test_edgeline.py @@ -8,10 +8,8 @@
8"""8"""
99
10from __future__ import annotations10from __future__ import annotations
1111
12import dataclasses
13
14import numpy as np12import numpy as np
1513
16from iolabs_point_cloud_detection_verticalsigns.classify import (14from iolabs_point_cloud_detection_verticalsigns.classify import (
17 apply_edge_line_gate,15 apply_edge_line_gate,
Importance #106: tests/test_edgeline.py @@ -279,12 +277,10 @@
279 axis = _axis()277 axis = _axis()
280 coverages = []278 coverages = []
281 for pct in (93.0, 95.0, 97.0):279 for pct in (93.0, 95.0, 97.0):
282 for fill in (0.3, 0.4, 0.5):280 for fill in (0.3, 0.4, 0.5):
283 config = dataclasses.replace(281 config = DetectorConfig().with_overrides(
284 DetectorConfig(),282 edgeline_paint_intensity_percentile=pct, edgeline_min_line_along_fill=fill
285 edgeline_paint_intensity_percentile=pct,
286 edgeline_min_line_along_fill=fill,
287 )283 )
288 coverages.append(build_edge_lines(xy, intensity, axis, config).coverage)284 coverages.append(build_edge_lines(xy, intensity, axis, config).coverage)
289 assert min(coverages) >= 0.6, coverages285 assert min(coverages) >= 0.6, coverages
290286
Importance #107: tests/test_tcs_ground.py @@ -5,9 +5,8 @@
5"""5"""
66
7from __future__ import annotations7from __future__ import annotations
88
9import dataclasses
10import os9import os
11from pathlib import Path10from pathlib import Path
1211
13import numpy as np12import numpy as np
Importance #108: tests/test_tcs_ground.py @@ -94,9 +93,9 @@
94 return _write_record(tmp_path / name, points)93 return _write_record(tmp_path / name, points)
9594
9695
97def _config(**overrides: object) -> DetectorConfig:96def _config(**overrides: object) -> DetectorConfig:
98 return dataclasses.replace(DetectorConfig(), **overrides)97 return DetectorConfig().with_overrides(**overrides)
9998
10099
101def _dem(files: list[Path]) -> np.ndarray:100def _dem(files: list[Path]) -> np.ndarray:
102 model = build_ground_model(101 model = build_ground_model(
Importance #109: tests/test_tree_instances.py @@ -12,15 +12,14 @@
12"""12"""
1313
14from __future__ import annotations14from __future__ import annotations
1515
16import dataclasses
17import json16import json
1817
19import numpy as np18import numpy as np
20import pytest19import pytest
2120
22from iolabs_point_cloud_detection_verticalsigns import _config21from iolabs_point_cloud_detection_verticalsigns import _config, _model_tree
23from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig22from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig
24from iolabs_point_cloud_detection_verticalsigns.tree_instances import (23from iolabs_point_cloud_detection_verticalsigns.tree_instances import (
25 ABSTAIN_ASSIGNED,24 ABSTAIN_ASSIGNED,
26 ABSTAIN_HEDGE,25 ABSTAIN_HEDGE,
Importance #110: tests/test_tree_instances.py @@ -363,9 +362,9 @@
363 _canopy_ball(rng, radius=1.5, centre_h=3.0, centre_xy=(0.0, 0.0)),362 _canopy_ball(rng, radius=1.5, centre_h=3.0, centre_xy=(0.0, 0.0)),
364 _canopy_ball(rng, radius=1.5, centre_h=3.0, centre_xy=(18.0, 0.0)),363 _canopy_ball(rng, radius=1.5, centre_h=3.0, centre_xy=(18.0, 0.0)),
365 ]364 ]
366 )365 )
367 config = dataclasses.replace(DetectorConfig(), ti_apex_fallback_enabled=False)366 config = DetectorConfig().with_overrides(ti_apex_fallback_enabled=False)
368367
369 result = split_tree_cluster(xyz, 0.0, None, config)368 result = split_tree_cluster(xyz, 0.0, None, config)
370369
371 assert result.seeds == []370 assert result.seeds == []
Importance #111: tests/test_tree_instances.py @@ -464,10 +463,10 @@
464 )463 )
465 # The apex fallback is switched off here on purpose: it would recover the464 # The apex fallback is switched off here on purpose: it would recover the
466 # far trees from their crowns (that is exactly what it is for) and so hide465 # far trees from their crowns (that is exactly what it is for) and so hide
467 # the failure this counterfactual exists to display.466 # the failure this counterfactual exists to display.
468 config = dataclasses.replace(467 config = DetectorConfig().with_overrides(
469 DetectorConfig(), ti_local_ground_footprint_m=1e6, ti_apex_fallback_enabled=False468 ti_local_ground_footprint_m=1e6, ti_apex_fallback_enabled=False
470 )469 )
471470
472 result = split_tree_cluster(xyz, 0.0, None, config)471 result = split_tree_cluster(xyz, 0.0, None, config)
473472
Importance #112: tests/test_tree_instances.py @@ -492,9 +491,9 @@
492 # Stem-only: with the apex fallback on, the fragment is a crown of its own491 # Stem-only: with the apex fallback on, the fragment is a crown of its own
493 # and legitimately becomes its own instance. What must never happen โ€” and492 # and legitimately becomes its own instance. What must never happen โ€” and
494 # is what this test pins โ€” is the fragment being ANNEXED by the tree next493 # is what this test pins โ€” is the fragment being ANNEXED by the tree next
495 # to it through the graph.494 # to it through the graph.
496 config = dataclasses.replace(DetectorConfig(), ti_apex_fallback_enabled=False)495 config = DetectorConfig().with_overrides(ti_apex_fallback_enabled=False)
497496
498 result = split_tree_cluster(xyz, 0.0, None, config)497 result = split_tree_cluster(xyz, 0.0, None, config)
499498
500 assert len(result.seeds) == 1499 assert len(result.seeds) == 1
Importance #113: tests/test_tree_instances.py @@ -510,10 +509,10 @@
510 which is right for some callers and wrong for most, so it is a switch.509 which is right for some callers and wrong for most, so it is a switch.
511 """510 """
512 rng = np.random.default_rng(47)511 rng = np.random.default_rng(47)
513 xyz = np.vstack([_cone(rng, 0.0, 0.0), _cone(rng, 3.0, 0.0)])512 xyz = np.vstack([_cone(rng, 0.0, 0.0), _cone(rng, 3.0, 0.0)])
514 strict = dataclasses.replace(513 strict = DetectorConfig().with_overrides(
515 DetectorConfig(), ti_low_evidence_abstain=True, ti_low_evidence_margin=0.05514 ti_low_evidence_abstain=True, ti_low_evidence_margin=0.05
516 )515 )
517516
518 lenient = split_tree_cluster(xyz, 0.0, None, DetectorConfig())517 lenient = split_tree_cluster(xyz, 0.0, None, DetectorConfig())
519 result = split_tree_cluster(xyz, 0.0, None, strict)518 result = split_tree_cluster(xyz, 0.0, None, strict)
Importance #114: tests/test_tree_instances.py @@ -748,9 +747,9 @@
748 [r * np.cos(ang), r * np.sin(ang), rng.uniform(0.0, 2.2, 800)]747 [r * np.cos(ang), r * np.sin(ang), rng.uniform(0.0, 2.2, 800)]
749 )748 )
750 xyz = np.vstack([band, trunk])749 xyz = np.vstack([band, trunk])
751 # Stem-only, so the one seed is unambiguous and the cap is what is measured.750 # Stem-only, so the one seed is unambiguous and the cap is what is measured.
752 config = dataclasses.replace(DetectorConfig(), ti_apex_fallback_enabled=False)751 config = DetectorConfig().with_overrides(ti_apex_fallback_enabled=False)
753752
754 result = split_tree_cluster(xyz, 0.0, None, config)753 result = split_tree_cluster(xyz, 0.0, None, config)
755 far = xyz[:, 0] > 15.0754 far = xyz[:, 0] > 15.0
756 near = xyz[:, 0] < 5.0755 near = xyz[:, 0] < 5.0
Importance #115: tests/test_tree_instances.py @@ -764,10 +763,9 @@
764 uncapped = split_tree_cluster(763 uncapped = split_tree_cluster(
765 xyz,764 xyz,
766 0.0,765 0.0,
767 None,766 None,
768 dataclasses.replace(767 config.with_overrides(
769 config,
770 ti_max_claim_radius_m=40.0,768 ti_max_claim_radius_m=40.0,
771 ti_instance_max_linearity=1.01,769 ti_instance_max_linearity=1.01,
772 ti_instance_min_thickness_share=0.0,770 ti_instance_min_thickness_share=0.0,
773 ),771 ),
Importance #116: tests/test_tree_instances.py @@ -791,9 +789,9 @@
791789
792 result = split_tree_cluster(xyz, 0.0, None, DetectorConfig())790 result = split_tree_cluster(xyz, 0.0, None, DetectorConfig())
793 # The v2 semantics, reconstructed: the same cap applied to the PATH.791 # The v2 semantics, reconstructed: the same cap applied to the PATH.
794 v2 = split_tree_cluster(792 v2 = split_tree_cluster(
795 xyz, 0.0, None, dataclasses.replace(DetectorConfig(), ti_assign_max_graph_dist_m=9.0)793 xyz, 0.0, None, DetectorConfig().with_overrides(ti_assign_max_graph_dist_m=9.0)
796 )794 )
797795
798 assert len(result.seeds) == 1796 assert len(result.seeds) == 1
799 assert float(np.mean(result.labels[canopy] >= 0)) > 0.95797 assert float(np.mean(result.labels[canopy] >= 0)) > 0.95
Importance #117: tests/test_tree_instances.py @@ -812,9 +810,9 @@
812 rng = np.random.default_rng(97)810 rng = np.random.default_rng(97)
813 big = _cone(rng, 0.0, 0.0, n_crown=3000, n_trunk=400)811 big = _cone(rng, 0.0, 0.0, n_crown=3000, n_trunk=400)
814 small = _cone(rng, 4.0, 0.0, top=4.0, crown_r=1.0, n_crown=300, n_trunk=120)812 small = _cone(rng, 4.0, 0.0, top=4.0, crown_r=1.0, n_crown=300, n_trunk=120)
815 xyz = np.vstack([big, small])813 xyz = np.vstack([big, small])
816 config = dataclasses.replace(DetectorConfig(), ti_min_instance_points=1000)814 config = DetectorConfig().with_overrides(ti_min_instance_points=1000)
817815
818 kept = split_tree_cluster(xyz, 0.0, None, DetectorConfig())816 kept = split_tree_cluster(xyz, 0.0, None, DetectorConfig())
819 result = split_tree_cluster(xyz, 0.0, None, config)817 result = split_tree_cluster(xyz, 0.0, None, config)
820818
Importance #118: tests/test_tree_instances.py @@ -873,10 +871,9 @@
873 kept = split_tree_cluster(871 kept = split_tree_cluster(
874 xyz,872 xyz,
875 0.0,873 0.0,
876 None,874 None,
877 dataclasses.replace(875 DetectorConfig().with_overrides(
878 DetectorConfig(),
879 ti_instance_max_linearity=1.01,876 ti_instance_max_linearity=1.01,
880 ti_instance_min_minor_m=0.0,877 ti_instance_min_minor_m=0.0,
881 ti_instance_min_vertical_m=0.0,878 ti_instance_min_vertical_m=0.0,
882 ti_instance_min_thickness_share=0.0,879 ti_instance_min_thickness_share=0.0,
Importance #119: tests/test_tree_instances.py @@ -910,9 +907,9 @@
910 )907 )
911908
912 result = split_tree_cluster(xyz, 0.0, None, DetectorConfig())909 result = split_tree_cluster(xyz, 0.0, None, DetectorConfig())
913 undamped = split_tree_cluster(910 undamped = split_tree_cluster(
914 xyz, 0.0, None, dataclasses.replace(DetectorConfig(), ti_min_points_per_instance=1)911 xyz, 0.0, None, DetectorConfig().with_overrides(ti_min_points_per_instance=1)
915 )912 )
916913
917 assert len(undamped.seeds) >= 3914 assert len(undamped.seeds) >= 3
918 assert len(result.seeds) <= 1915 assert len(result.seeds) <= 1
Importance #120: tests/test_tree_instances.py @@ -931,16 +928,16 @@
931 [rng.uniform(0.0, 7.0, n), rng.uniform(-0.3, 0.3, n), rng.uniform(0.0, 3.0, n)]928 [rng.uniform(0.0, 7.0, n), rng.uniform(-0.3, 0.3, n), rng.uniform(0.0, 3.0, n)]
932 )929 )
933 # Stem-only: the seedless verdict is what is being measured, and an apex930 # Stem-only: the seedless verdict is what is being measured, and an apex
934 # seed would answer the question before the gate is reached.931 # seed would answer the question before the gate is reached.
935 config = dataclasses.replace(DetectorConfig(), ti_apex_fallback_enabled=False)932 config = DetectorConfig().with_overrides(ti_apex_fallback_enabled=False)
936933
937 result = split_tree_cluster(xyz, 0.0, None, config)934 result = split_tree_cluster(xyz, 0.0, None, config)
938 lenient = split_tree_cluster(935 lenient = split_tree_cluster(
939 xyz,936 xyz,
940 0.0,937 0.0,
941 None,938 None,
942 dataclasses.replace(config, ti_seedless_max_aspect=100.0, ti_seedless_min_points=1),939 config.with_overrides(ti_seedless_max_aspect=100.0, ti_seedless_min_points=1),
943 )940 )
944941
945 assert result.seeds == []942 assert result.seeds == []
946 assert result.split_quality == "uncertain"943 assert result.split_quality == "uncertain"
Importance #121: tests/test_tree_instances.py @@ -974,9 +971,9 @@
974 xyz = _foliage_band(rng)971 xyz = _foliage_band(rng)
975972
976 result = split_tree_cluster(xyz, 0.0, None, DetectorConfig())973 result = split_tree_cluster(xyz, 0.0, None, DetectorConfig())
977 blind = split_tree_cluster(974 blind = split_tree_cluster(
978 xyz, 0.0, None, dataclasses.replace(DetectorConfig(), ti_hedge_max_height_m=4.5)975 xyz, 0.0, None, DetectorConfig().with_overrides(ti_hedge_max_height_m=4.5)
979 )976 )
980977
981 assert result.is_hedge and result.split_quality == "hedge"978 assert result.is_hedge and result.split_quality == "hedge"
982 assert result.seeds == []979 assert result.seeds == []
Importance #122: tests/test_tree_instances.py @@ -1051,9 +1048,9 @@
10511048
1052# --- config section --------------------------------------------------------1049# --- config section --------------------------------------------------------
10531050
10541051
1055def test_overrides_reach_the_dataclass() -> None:1052def test_overrides_reach_the_model() -> None:
1056 config = DetectorConfig.from_mapping(1053 config = DetectorConfig.from_mapping(
1057 {"tree_instance": {"enabled": True, "stem_eps_m": 0.5}}1054 {"tree_instance": {"enabled": True, "stem_eps_m": 0.5}}
1058 )1055 )
1059 assert config.tree_instance_enabled is True1056 assert config.tree_instance_enabled is True
Importance #123: tests/test_tree_instances.py @@ -1061,21 +1058,21 @@
1061 assert config.ti_stem_min_samples == DetectorConfig().ti_stem_min_samples1058 assert config.ti_stem_min_samples == DetectorConfig().ti_stem_min_samples
10621059
10631060
1064def test_config_rejects_an_unknown_key_in_the_new_section(tmp_path) -> None:1061def test_config_rejects_an_unknown_key_in_the_new_section(tmp_path) -> None:
1065 """Pins the _ALLOWED_BY_SECTION wiring.1062 """Pins the TreeInstanceConfig wiring.
10661063
1067 Without it the section validates against nothing, and a typo'd threshold1064 Without it the section validates against nothing, and a typo'd threshold
1068 is accepted and then silently ignored.1065 is accepted and then silently ignored.
1069 """1066 """
1070 path = tmp_path / "override.json"1067 path = tmp_path / "override.json"
1071 path.write_text(json.dumps({"tree_instance": {"stem_eps": 0.5}}))1068 path.write_text(json.dumps({"tree_instance": {"stem_eps": 0.5}}))
1072 with pytest.raises(_config.ConfigError):1069 with pytest.raises(_config.VerticalSignsConfigError):
1073 _config.load_verticalsigns_config(path)1070 _config.load_verticalsigns_config(path)
10741071
10751072
1076def test_config_accepts_every_documented_key(tmp_path) -> None:1073def test_config_accepts_every_documented_key(tmp_path, section_values) -> None:
1077 section = {k: 0.5 for k in _config.ALLOWED_TREE_INSTANCE_KEYS}1074 section = section_values(_model_tree.TreeInstanceConfig)
1078 section["enabled"] = True1075 section["enabled"] = True
1079 path = tmp_path / "override.json"1076 path = tmp_path / "override.json"
1080 path.write_text(json.dumps({"tree_instance": section}))1077 path.write_text(json.dumps({"tree_instance": section}))
1081 assert _config.load_verticalsigns_config(path)["tree_instance"]["enabled"]1078 assert _config.load_verticalsigns_config(path)["tree_instance"]["enabled"]