Back to report index

verticalsigns fdf0c72: AI3D-379 Align config module with fleet pattern

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

Commit #71 ยท 71 snippets

 BRIEF.md                                           |   4 +-
 README.md                                          |  49 +-
 .../__init__.py                                    |   6 +-
 .../_config.py                                     | 345 +++++++++++--
 .../_config_conic.py                               | 210 --------
 .../_config_corridor.py                            | 200 --------
 .../_config_devices.py                             | 248 ---------
 .../_config_evidence.py                            | 259 ----------
 .../_config_grid.py                                | 131 -----
 .../_config_model.py                               |  48 --
 .../_config_perspective.py                         |  96 ----
 .../_config_roadcontext.py                         | 374 --------------
 .../_config_stages.py                              | 241 ---------
 .../_config_treedetect.py                          | 146 ------
 .../_config_treeinstance.py                        | 560 ---------------------
 .../_config_vegetation.py                          | 222 --------
 .../_model_base.py                                 |  67 +++
 .../_model_conic.py                                | 122 +++++
 .../_model_corridor.py                             | 121 +++++
 .../_model_devices.py                              | 288 ++++++-----
 .../_model_evidence.py                             | 186 +++++++
 .../_model_grid.py                                 | 219 +++-----
 .../_model_perspective.py                          |  59 +++
 .../_model_road.py                                 | 284 +++++++----
 .../_model_stages.py                               | 130 +++++
 .../_model_tree.py                                 | 180 -------
 .../_model_treedetect.py                           |  87 ++++
 .../_model_treeinstance.py                         | 345 +++++++++++++
 .../_model_vegetation.py                           | 150 ++++++
 .../config.py                                      | 147 +-----
 tests/conftest.py                                  |  28 +-
 tests/test_chroma_vegetation.py                    |   4 +-
 tests/test_config.py                               | 195 +++++++
 tests/test_config_split.py                         | 143 ------
 tests/test_tree_instances.py                       |   4 +-
 35 files changed, 2260 insertions(+), 3638 deletions(-)
Importance #1: src/iolabs_point_cloud_detection_verticalsigns/_config.py @@ -53,30 +324,14 @@
53 Raises:324 Raises:
54 VerticalSignsConfigError: The user JSON is malformed, or the merged325 VerticalSignsConfigError: The user JSON is malformed, or the merged
55 config holds an unknown section/key or an invalid value.326 config holds an unknown section/key or an invalid value.
56 """327 """
57 overrides = _read_user_config(config_path) if config_path is not None else None328 return build_verticalsigns_config(config_path=config_path)
58 config = config_loader.load_config(329
59 VerticalSignsConfig,330
60 package=_PACKAGE_NAME,331def load_default_config() -> dict[str, Any]:
61 filename=_DEFAULT_RESOURCE,332 """Return a fresh copy of the packaged default configuration.
62 overrides=overrides,333
63 context=_CONTEXT,334 Returns:
64 error_cls=VerticalSignsConfigError,335 The packaged defaults as plain JSON types.
65 )336 """
66 return config.model_dump(mode="json")337 return build_verticalsigns_config()
67
68
69def _read_user_config(config_path: str | Path) -> dict[str, Any]:
70 """Read a user config JSON, wrapping decode errors in the package error."""
71 path = Path(config_path)
72 try:
73 with path.open("r", encoding="utf-8") as handle:
74 user_config: Any = json.load(handle)
75 except json.JSONDecodeError as exc:
76 raise VerticalSignsConfigError(f"Invalid JSON in {path}: {exc}") from exc
77 if not isinstance(user_config, dict):
78 raise VerticalSignsConfigError(
79 f"{path} must hold a JSON object, not a {type(user_config).__name__}"
80 )
81 logger.debug("Loaded %s overrides from %s", _CONTEXT, path)
82 return user_config
Importance #2: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -1,135 +1,26 @@
1"""Detector configuration.1"""Public import path for the detector configuration.
22
3The 379-field :class:`DetectorConfig` and its ``from_mapping`` flattener are3The schema, the loading entry points and the error class live in `_config`;
4split by section across the ``_config_<section>`` modules; this module4this module re-exports them so the documented ``from
5recombines them and re-exports every piece, so ``from .config import X``5iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig`` keeps
6keeps working for every name that used to live here.6working. The field declarations themselves are split across the
77``_model_<topic>`` slices.
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``.
11"""8"""
129
13from pathlib import Path10from ._config import (
14from typing import Any11 DetectorConfig,
1512 VerticalSignsConfigError,
16from ._config import load_verticalsigns_config13 build_verticalsigns_config,
17from ._config_conic import ConicFields, conic_kwargs14 load_default_config,
18from ._config_corridor import CorridorFields, corridor_kwargs15 load_verticalsigns_config,
19from ._config_devices import DeviceFields, device_kwargs16 normalize_verticalsigns_config,
20from ._config_evidence import EvidenceFields, evidence_kwargs17)
21from ._config_grid import GridFields, grid_kwargs
22from ._config_perspective import PerspectiveFields, perspective_kwargs
23from ._config_roadcontext import RoadContextFields, road_context_kwargs
24from ._config_stages import StageFields, stage_kwargs
25from ._config_treedetect import TreeDetectionFields, tree_detection_kwargs
26from ._config_treeinstance import TreeInstanceFields, tree_instance_kwargs
27from ._config_vegetation import VegetationFields, vegetation_kwargs
2818
29__all__ = [19__all__ = [
30 "DetectorConfig",20 "DetectorConfig",
31 "GridFields",21 "VerticalSignsConfigError",
32 "DeviceFields",22 "build_verticalsigns_config",
33 "VegetationFields",23 "load_default_config",
34 "RoadContextFields",24 "load_verticalsigns_config",
35 "CorridorFields",25 "normalize_verticalsigns_config",
36 "EvidenceFields",
37 "StageFields",
38 "TreeDetectionFields",
39 "TreeInstanceFields",
40 "ConicFields",
41 "PerspectiveFields",
42 "grid_kwargs",
43 "device_kwargs",
44 "vegetation_kwargs",
45 "road_context_kwargs",
46 "corridor_kwargs",
47 "evidence_kwargs",
48 "stage_kwargs",
49 "tree_detection_kwargs",
50 "tree_instance_kwargs",
51 "conic_kwargs",
52 "perspective_kwargs",
53]26]
54
55
56class DetectorConfig( # noqa: D101 - docstring below, after the base list
57 # The bases are listed in REVERSE section order ON PURPOSE: both
58 # dataclasses and pydantic collect fields by walking the MRO backwards, so
59 # this ordering reproduces the original single-class field order exactly
60 # (ground first, then perspective, then the slices added since).
61 # Reordering these lines reorders the fields, so a NEW slice goes at the
62 # TOP of this list to have its fields appended at the end.
63 TreeInstanceFields,
64 PerspectiveFields,
65 ConicFields,
66 TreeDetectionFields,
67 StageFields,
68 EvidenceFields,
69 CorridorFields,
70 RoadContextFields,
71 VegetationFields,
72 DeviceFields,
73 GridFields,
74):
75 """Spatial and geometric thresholds, in metres unless stated otherwise."""
76
77 @classmethod
78 def from_mapping(cls, config: dict[str, Any]) -> "DetectorConfig":
79 """Builds a DetectorConfig by flattening the nested config sections.
80
81 Only keys present in a section override the corresponding model
82 default, so a partial (or default) config reproduces the built-in
83 thresholds exactly.
84
85 Args:
86 config: The nested config document (packaged defaults merged with
87 an optional user JSON).
88
89 Returns:
90 The flattened configuration.
91 """
92 defaults = cls()
93 return cls(
94 **grid_kwargs(config, defaults),
95 **device_kwargs(config, defaults),
96 **vegetation_kwargs(config, defaults),
97 **road_context_kwargs(config, defaults),
98 **corridor_kwargs(config, defaults),
99 **evidence_kwargs(config, defaults),
100 **stage_kwargs(config, defaults),
101 **tree_detection_kwargs(config, defaults),
102 **conic_kwargs(config, defaults),
103 **perspective_kwargs(config, defaults),
104 **tree_instance_kwargs(config, defaults),
105 )
106
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 a wrongly typed value would be
112 stored uncoerced. The names are checked here and the values are run
113 through the model, so this validates where ``dataclasses.replace``
114 merely type-checked the call.
115
116 Args:
117 overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``.
118
119 Returns:
120 A new frozen config carrying *overrides*.
121
122 Raises:
123 ValueError: An override names a field this config does not declare,
124 or carries a value the field rejects (a
125 ``pydantic.ValidationError``, itself a ``ValueError``).
126 """
127 unknown = sorted(set(overrides) - set(type(self).model_fields))
128 if unknown:
129 raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}")
130 return type(self).model_validate({**self.model_dump(), **overrides})
131
132 @classmethod
133 def load(cls, config_path: str | Path | None = None) -> "DetectorConfig":
134 """Load config from the packaged defaults merged with an optional user JSON."""
135 return cls.from_mapping(load_verticalsigns_config(config_path))
Importance #3: src/iolabs_point_cloud_detection_verticalsigns/_config_conic.py @@ -1,210 +0,0 @@
1"""The colour-free conic gate and the conifer rule that rides on it.
2
3One slice of the flat ``DetectorConfig``, moved out of
4``config.py`` verbatim. ``config.py`` recombines the slices and
5re-exports both names defined here.
6"""
7
8from typing import Any
9
10from iolabs.common import config_loader
11
12
13class ConicFields(config_loader.ConfigModel):
14 """The colour-free conic gate and the conifer rule that rides on it.
15
16 Metres unless stated otherwise.
17 """
18
19 # Colour-free conic gate (AI3D-339): an OR-bypass around the vegetation RF
20 # for conifers. The RF cannot pass them (its positives contained none, and
21 # crown_isotropy is information-free for cone-vs-pole), so a rule is the
22 # only path that surfaces them. TWO-CUE by design -- shape AND surface
23 # texture -- because a single cue family cannot separate foliage from a
24 # mast. SHIPS OFF; thresholds below are unvalidated seeds pending the
25 # real-distribution dump, and emissions are tagged reason="conic_rule".
26 conic_gate_enabled: bool = False
27 conic_taper_slope_max: float = -0.4
28 # The taper must survive dropping any single decile. Measured on real
29 # A4_5 data, every cluster that faked a cone had its whole slope carried
30 # by one decile -- a ground skirt at the base or one twig at the top.
31 conic_taper_slope_robust_max: float = -0.3
32 conic_apex_deg_min: float = 5.0
33 conic_apex_deg_max: float = 35.0
34 conic_h_over_width_min: float = 1.5
35 conic_h_over_width_max: float = 12.0
36 # Texture conjunct: foliage is scattering-rough, a pole/mast is smooth.
37 # Reads the EXISTING eigenfeature fields. Disable to A/B the shape cue
38 # alone during diagnostics; it is on whenever the gate itself is on.
39 conic_texture_cue_enabled: bool = True
40 conic_change_of_curvature_min: float = 0.06
41 conic_omnivariance_min: float = 0.10
42 conic_max_hi_intensity_fraction: float = 0.2
43 conic_h_max_min_m: float = 2.5
44 conic_max_on_road_fraction: float = 0.6
45 # Abstention guard -- an occlusion-starved radius profile must not be
46 # allowed to fake a conifer's taper.
47 conic_min_decile_fill_fraction: float = 0.8
48 # Minimum crown footprint. A taper says how the radius CHANGES with height
49 # but says nothing about absolute size, so a 0.34 x 0.18 m post 3 m tall
50 # satisfies every shape test while being far too thin to be a crown.
51 # Calibrated on the 143-segment A4_5 sweep: the three thinnest conic
52 # emissions (0.061 / 0.177 / 0.256 m2) were independently judged posts or
53 # bare stems in visual review, while 47 of the 51 clusters the trained
54 # vegetation RF accepted sit above 0.5 m2.
55 conic_min_crown_area_m2: float = 0.3
56
57 # --- conifer rule (AI3D-339) -------------------------------------------
58 # A SECOND, independent bypass. The conic rule above selects for foliage
59 # reaching the ground -- shrub mounds, hedge banks -- because it fits the
60 # taper over the whole cluster. A conifer carrying its crown above a bare
61 # trunk has the opposite profile and is structurally rejected there. This
62 # rule reads the crown-relative fields instead, so it can accept one.
63 #
64 # These thresholds are MORPHOLOGICAL PRIORS, not fitted values: the corpus
65 # contains a single visually-confirmed clean conifer, which is far too few
66 # to calibrate against without overfitting. They are deliberately loose,
67 # to be narrowed once emissions have been reviewed.
68 conifer_rule_enabled: bool = False
69 # THE DISCRIMINATOR, and it is not a shape term. Thirteen candidates were
70 # rendered as 360-degree orbits and labelled by three independent blind
71 # judges; no shape feature separated the five confirmed conifers from the
72 # six confirmed non-conifers (stem_ratio: conifers 0.46-2.08, others
73 # 0.96-1.64 -- fully overlapping). Every judge instead gave the same
74 # reason, "densely filled" versus "see-through twiggy", and a density
75 # BAND separates the labelled set perfectly:
76 #
77 # conifers 154 191 208 278 332
78 # leaf-off 98 116 130 (bare April twigs return little)
79 # hedge/thicket 679 745 853 (a solid mass, not a tree)
80 #
81 # Physically: a conifer is dense foliage on an OPEN branching tree, so it
82 # sits between bare deciduous and a solid hedge. Unlike the shape terms
83 # these bounds ARE fitted -- to 11 labels, which is few -- so they are set
84 # at the midpoints of the observed gaps to maximise margin, and both
85 # contested candidates fall outside the band.
86 conifer_min_volumetric_density: float = 140.0
87 conifer_max_volumetric_density: float = 380.0
88 # Shape sanity only; NOT the discriminator (see above). Kept loose enough
89 # to admit every confirmed conifer, including merged pairs whose base is
90 # widened by the neighbour they were clustered with.
91 conifer_max_stem_ratio: float = 2.2
92 # A point at the top rather than a flat or broadening crown.
93 conifer_max_apex_ratio: float = 0.75
94 # The crown limb must actually taper.
95 conifer_max_crown_taper: float = -0.10
96 # The crown must sit low enough to be a cone, not a mushroom.
97 conifer_max_crown_base_frac: float = 0.55
98 # Slenderness of the whole object: a spire, not a bush and not a mast.
99 conifer_h_over_width_min: float = 2.0
100 conifer_h_over_width_max: float = 15.0
101 conifer_h_max_min_m: float = 2.0
102 # Foliage is scattering-rough; a pole or a fence face is smooth.
103 conifer_min_change_of_curvature: float = 0.04
104 # Not retroreflective, not over the carriageway, not starved of deciles.
105 conifer_max_hi_intensity_fraction: float = 0.2
106 conifer_max_on_road_fraction: float = 0.6
107 conifer_min_decile_fill_fraction: float = 0.8
108 conifer_min_crown_area_m2: float = 0.2
109
110
111def conic_kwargs(config: dict[str, Any], defaults: ConicFields) -> dict[str, Any]:
112 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
113
114 Sections read: ``conic_gate``, ``conifer_rule``.
115
116 Args:
117 config: The nested config document, not a single section.
118 defaults: Instance supplying the fallback for every absent key.
119
120 Returns:
121 The ``ConicFields`` keyword arguments, defaults filled in.
122 """
123 conic_gate = config.get("conic_gate", {})
124 conifer = config.get("conifer_rule", {})
125 return {
126 "conic_gate_enabled": conic_gate.get("enabled", defaults.conic_gate_enabled),
127 "conic_taper_slope_max": conic_gate.get(
128 "taper_slope_max", defaults.conic_taper_slope_max
129 ),
130 "conic_taper_slope_robust_max": conic_gate.get(
131 "taper_slope_robust_max", defaults.conic_taper_slope_robust_max
132 ),
133 "conic_apex_deg_min": conic_gate.get(
134 "apex_deg_min", defaults.conic_apex_deg_min
135 ),
136 "conic_apex_deg_max": conic_gate.get(
137 "apex_deg_max", defaults.conic_apex_deg_max
138 ),
139 "conic_h_over_width_min": conic_gate.get(
140 "h_over_width_min", defaults.conic_h_over_width_min
141 ),
142 "conic_h_over_width_max": conic_gate.get(
143 "h_over_width_max", defaults.conic_h_over_width_max
144 ),
145 "conic_texture_cue_enabled": conic_gate.get(
146 "texture_cue_enabled", defaults.conic_texture_cue_enabled
147 ),
148 "conic_change_of_curvature_min": conic_gate.get(
149 "change_of_curvature_min", defaults.conic_change_of_curvature_min
150 ),
151 "conic_omnivariance_min": conic_gate.get(
152 "omnivariance_min", defaults.conic_omnivariance_min
153 ),
154 "conic_max_hi_intensity_fraction": conic_gate.get(
155 "max_hi_intensity_fraction", defaults.conic_max_hi_intensity_fraction
156 ),
157 "conic_h_max_min_m": conic_gate.get(
158 "h_max_min_m", defaults.conic_h_max_min_m
159 ),
160 "conic_max_on_road_fraction": conic_gate.get(
161 "max_on_road_fraction", defaults.conic_max_on_road_fraction
162 ),
163 "conic_min_decile_fill_fraction": conic_gate.get(
164 "min_decile_fill_fraction", defaults.conic_min_decile_fill_fraction
165 ),
166 "conic_min_crown_area_m2": conic_gate.get(
167 "min_crown_area_m2", defaults.conic_min_crown_area_m2
168 ),
169 "conifer_rule_enabled": conifer.get("enabled", defaults.conifer_rule_enabled),
170 "conifer_max_stem_ratio": conifer.get(
171 "max_stem_ratio", defaults.conifer_max_stem_ratio
172 ),
173 "conifer_min_volumetric_density": conifer.get(
174 "min_volumetric_density", defaults.conifer_min_volumetric_density
175 ),
176 "conifer_max_volumetric_density": conifer.get(
177 "max_volumetric_density", defaults.conifer_max_volumetric_density
178 ),
179 "conifer_max_apex_ratio": conifer.get(
180 "max_apex_ratio", defaults.conifer_max_apex_ratio
181 ),
182 "conifer_max_crown_taper": conifer.get(
183 "max_crown_taper", defaults.conifer_max_crown_taper
184 ),
185 "conifer_max_crown_base_frac": conifer.get(
186 "max_crown_base_frac", defaults.conifer_max_crown_base_frac
187 ),
188 "conifer_h_over_width_min": conifer.get(
189 "h_over_width_min", defaults.conifer_h_over_width_min
190 ),
191 "conifer_h_over_width_max": conifer.get(
192 "h_over_width_max", defaults.conifer_h_over_width_max
193 ),
194 "conifer_h_max_min_m": conifer.get("h_max_min_m", defaults.conifer_h_max_min_m),
195 "conifer_min_change_of_curvature": conifer.get(
196 "min_change_of_curvature", defaults.conifer_min_change_of_curvature
197 ),
198 "conifer_max_hi_intensity_fraction": conifer.get(
199 "max_hi_intensity_fraction", defaults.conifer_max_hi_intensity_fraction
200 ),
201 "conifer_max_on_road_fraction": conifer.get(
202 "max_on_road_fraction", defaults.conifer_max_on_road_fraction
203 ),
204 "conifer_min_decile_fill_fraction": conifer.get(
205 "min_decile_fill_fraction", defaults.conifer_min_decile_fill_fraction
206 ),
207 "conifer_min_crown_area_m2": conifer.get(
208 "min_crown_area_m2", defaults.conifer_min_crown_area_m2
209 ),
210 }
0
Importance #4: src/iolabs_point_cloud_detection_verticalsigns/_config_corridor.py @@ -1,200 +0,0 @@
1"""Road corridor rasterization and on-carriageway rejection.
2
3Also plate planarity, the bright-panel class and the free-space ring.
4
5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.
8"""
9
10from typing import Any
11
12from iolabs.common import config_loader
13
14
15class CorridorFields(config_loader.ConfigModel):
16 """Road corridor rasterization and on-carriageway rejection.
17
18 Also plate planarity, the bright-panel class and the free-space ring.
19
20 Metres unless stated otherwise.
21 """
22
23 # Road corridor (rasterized on the ground-grid geometry).
24 max_dist_to_road_m: float = 10.0
25 on_carriageway_dist_m: float = 0.25
26 on_carriageway_exempt_h_max_m: float = 4.5
27 # Carriageway isolation: run4 over-extends the fitted road plane onto verge /
28 # field-track areas with a sparse point density (segment 000). Keep only
29 # cells whose run4 count clears a segment-adaptive density floor
30 # (max of an absolute floor and a fraction of the p95 cell count), then keep
31 # the connected component(s) covering the main carriageway.
32 corridor_density_min_points: float = 8.0
33 corridor_density_frac_p95: float = 0.06
34 # Cap on the p95-scaled density floor. On heavily-overscanned segments the
35 # main carriageway core is sampled by many overlapping run4 passes, so its
36 # p95 cell count balloons (segment 134: p95~8100 โ†’ floor 487) and the floor
37 # over-drops legitimately-paved but less-densely-scanned branch roads / gore
38 # aprons / ramps (134's apron cells hold ~170-210 returns). The cap keeps the
39 # floor at a road-vs-extrapolation boundary (~150) regardless of how dense the
40 # core is. It only lowers the floor where density_frac_p95*p95 exceeds it, so
41 # genuinely sparse segments (000's vineyard field track, floor 152, field
42 # cells <150) are unchanged and their extrapolated planes stay dropped.
43 corridor_density_max_points: float = 150.0
44 corridor_component_min_area_frac: float = 0.15
45 # A dense run4 component is kept when it is either a decent fraction of the
46 # largest (component_min_area_frac) OR clears an absolute cell-area floor. A
47 # branch road / apron forms its own component disconnected from the main
48 # carriageway across the curb gap; on a long junction tile it is far smaller
49 # than the through-road, so the fractional test alone drops it. run4 holds
50 # road-surface points only, so a dense component of this size is road.
51 corridor_component_min_area_cells: int = 40
52 # On-carriageway rejection: a cluster whose footprint sits (almost) entirely
53 # over genuine road cells is a vehicle / on-road object, rejected for every
54 # class except tall gantry legs (h_max >= on_carriageway_exempt_h_max_m).
55 # Edge delineators keep a mixed footprint and stay below this fraction.
56 on_carriageway_road_fraction: float = 0.7
57 # An on-carriageway cluster is only kept if it is a genuine marker: either
58 # volumetrically dense (a static post/plate packs points) or brightly
59 # retroreflective (a wide guide panel overhanging the edge, segment 006).
60 # A dull, sparse blob on the carriageway is a vehicle / debris smear.
61 min_volumetric_density: float = 8000.0
62 on_carriageway_bright_frac: float = 0.5
63 # Delineator-shape exemption from on-carriageway rejection. The corridor
64 # density cap can extend the kept road mask onto paved shoulders / medians,
65 # so genuine edge delineators end up sitting (almost) entirely over road
66 # cells and get swept up by the on-carriageway rejection (segments 076, 123).
67 # A moving-vehicle smear is never a sub-delineator-height, sub-0.65 m,
68 # near-perfectly-vertical retroreflective column, so a cluster matching that
69 # delineator signature is exempt and allowed to reach the delineator gates.
70 # The len_major cap (0.65 m) sits below the 114/130 vehicle-smear footprints
71 # (1.25 x 0.66 / 1.28 x 0.77), so those FPs stay rejected.
72 on_carriageway_delineator_max_len_major_m: float = 0.65
73 on_carriageway_delineator_min_verticality: float = 0.95
74
75 # Plate planarity: a real sign plate is a thin slab, so the smallest 3D
76 # covariance eigenvalue of its upper-half points (plate_thickness_m) is small.
77 # Vegetation clumps are volumetric and thick. Gate the sign class on it.
78 sign_max_plate_thickness_m: float = 0.15
79
80 # Bright panel (segment 114): a real chevron/warning panel (Richtungstafel)
81 # can sit below the sign_post_h_min_m post-height floor (a low roadside
82 # panel, not a tall post-mounted plate). It is still a thin, bright, planar
83 # slab of plausible plate width, so gate it on brightness, thinness, height,
84 # width and vertical continuity directly rather than routing it through the
85 # post logic.
86 panel_min_hi: float = 0.40
87 panel_max_thickness_m: float = 0.20
88 panel_h_min_m: float = 0.9
89 # A genuine chevron panel is a WIDE board (segment 114's reads 2.95 m).
90 # The 1.5 m floor keeps narrow bright low posts/plates (segment 134's
91 # 1.25 m roadside marker) out of the panel class.
92 panel_len_major_min_m: float = 1.5
93 panel_len_major_max_m: float = 5.0
94
95 # Free-space ring: real plate-less posts (sign_post/pole_other/delineator)
96 # stand clear, so a cylindrical ring around the cluster axis holds few
97 # non-cluster candidate points. Bush interiors, saplings and forest trunks
98 # sit inside filled rings. Also reject a plate-less candidate embedded in a
99 # forest context (several tall neighbouring clusters nearby).
100 ring_r_inner_m: float = 0.5
101 ring_r_outer_m: float = 1.5
102 ring_h_min_m: float = 0.5
103 ring_h_max_m: float = 2.5
104 # Ring fill measured as the ratio of non-cluster ring points to the cluster's
105 # own point count; a sapling/trunk embedded in foliage has a ring several
106 # times denser than itself, a real clear-standing post has a near-empty ring.
107 ring_max_fill_ratio: float = 2.0
108 ring_min_points: int = 40
109 forest_min_neighbors: int = 3
110 forest_radius_m: float = 8.0
111 forest_neighbor_min_h_max_m: float = 2.0
112
113
114def corridor_kwargs(config: dict[str, Any], defaults: CorridorFields) -> dict[str, Any]:
115 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
116
117 Sections read: ``classification``, ``corridor``, ``context``, ``sign_post``, ``panel``.
118
119 Args:
120 config: The nested config document, not a single section.
121 defaults: Instance supplying the fallback for every absent key.
122
123 Returns:
124 The ``CorridorFields`` keyword arguments, defaults filled in.
125 """
126 classification = config.get("classification", {})
127 corridor = config.get("corridor", {})
128 context = config.get("context", {})
129 sign_post = config.get("sign_post", {})
130 panel = config.get("panel", {})
131 return {
132 "max_dist_to_road_m": corridor.get("max_dist_to_road_m", defaults.max_dist_to_road_m),
133 "on_carriageway_dist_m": corridor.get(
134 "on_carriageway_dist_m", defaults.on_carriageway_dist_m
135 ),
136 "on_carriageway_exempt_h_max_m": corridor.get(
137 "on_carriageway_exempt_h_max_m", defaults.on_carriageway_exempt_h_max_m
138 ),
139 "corridor_density_min_points": corridor.get(
140 "density_min_points", defaults.corridor_density_min_points
141 ),
142 "corridor_density_frac_p95": corridor.get(
143 "density_frac_p95", defaults.corridor_density_frac_p95
144 ),
145 "corridor_density_max_points": corridor.get(
146 "density_max_points", defaults.corridor_density_max_points
147 ),
148 "corridor_component_min_area_frac": corridor.get(
149 "component_min_area_frac", defaults.corridor_component_min_area_frac
150 ),
151 "corridor_component_min_area_cells": corridor.get(
152 "component_min_area_cells", defaults.corridor_component_min_area_cells
153 ),
154 "on_carriageway_road_fraction": corridor.get(
155 "on_carriageway_road_fraction", defaults.on_carriageway_road_fraction
156 ),
157 "on_carriageway_bright_frac": corridor.get(
158 "on_carriageway_bright_frac", defaults.on_carriageway_bright_frac
159 ),
160 "on_carriageway_delineator_max_len_major_m": corridor.get(
161 "on_carriageway_delineator_max_len_major_m",
162 defaults.on_carriageway_delineator_max_len_major_m,
163 ),
164 "on_carriageway_delineator_min_verticality": corridor.get(
165 "on_carriageway_delineator_min_verticality",
166 defaults.on_carriageway_delineator_min_verticality,
167 ),
168 "min_volumetric_density": classification.get(
169 "min_volumetric_density", defaults.min_volumetric_density
170 ),
171 "sign_max_plate_thickness_m": sign_post.get(
172 "max_plate_thickness_m", defaults.sign_max_plate_thickness_m
173 ),
174 "panel_min_hi": panel.get("min_hi", defaults.panel_min_hi),
175 "panel_max_thickness_m": panel.get(
176 "max_thickness_m", defaults.panel_max_thickness_m
177 ),
178 "panel_h_min_m": panel.get("h_min_m", defaults.panel_h_min_m),
179 "panel_len_major_min_m": panel.get(
180 "len_major_min_m", defaults.panel_len_major_min_m
181 ),
182 "panel_len_major_max_m": panel.get(
183 "len_major_max_m", defaults.panel_len_major_max_m
184 ),
185 "ring_r_inner_m": context.get("ring_r_inner_m", defaults.ring_r_inner_m),
186 "ring_r_outer_m": context.get("ring_r_outer_m", defaults.ring_r_outer_m),
187 "ring_h_min_m": context.get("ring_h_min_m", defaults.ring_h_min_m),
188 "ring_h_max_m": context.get("ring_h_max_m", defaults.ring_h_max_m),
189 "ring_max_fill_ratio": context.get(
190 "ring_max_fill_ratio", defaults.ring_max_fill_ratio
191 ),
192 "ring_min_points": context.get("ring_min_points", defaults.ring_min_points),
193 "forest_min_neighbors": context.get(
194 "forest_min_neighbors", defaults.forest_min_neighbors
195 ),
196 "forest_radius_m": context.get("forest_radius_m", defaults.forest_radius_m),
197 "forest_neighbor_min_h_max_m": context.get(
198 "forest_neighbor_min_h_max_m", defaults.forest_neighbor_min_h_max_m
199 ),
200 }
0
Importance #5: src/iolabs_point_cloud_detection_verticalsigns/_config_devices.py @@ -1,248 +0,0 @@
1"""Per-device thresholds for delineators, sign posts and gantries.
2
3Also isolated-floating-pole rejection and duplicate suppression.
4
5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.
8"""
9
10from typing import Any
11
12from iolabs.common import config_loader
13
14
15class DeviceFields(config_loader.ConfigModel):
16 """Per-device thresholds for delineators, sign posts and gantries.
17
18 Also isolated-floating-pole rejection and duplicate suppression.
19
20 Metres unless stated otherwise.
21 """
22
23 # Delineator (Leitpfosten). The height ceiling (1.5 m) and footprint cap
24 # (0.45 m) admit taller guide posts and the mild along-track smear that gore
25 # posts pick up in MLS (segment 131's junction posts read 0.42 m major,
26 # h 1.2-1.5); real Leitpfosten cores stay ~0.12 m so the cap change does not
27 # widen the class into vehicles/vegetation.
28 delineator_h_min_m: float = 0.7
29 delineator_h_max_m: float = 1.5
30 delineator_max_footprint_m: float = 0.45
31 # Relaxed footprint band for a delineator whose along-track MLS smear at a
32 # junction/gore pushes its major extent past the tight 0.45 m cap (segment
33 # 134's splitter-island posts read 0.47-0.63 m major). Only admitted when the
34 # cluster is strongly vertical (a genuine post), so a flat bright road-marking
35 # fragment (verticality ~0.1) can never sneak in through the wider cap. Purely
36 # additive: clusters at or under delineator_max_footprint_m keep the original
37 # (verticality-free) path, so no existing detection is affected.
38 # 0.65 -> 0.85 (AI3D-339 pass 3): Abschnitt-1 Leitpfosten merge with verge
39 # grass into 0.67-0.83 m clusters that keep verticality ~0.99; the 0.65 cap
40 # was the single failing conjunct for 8 adversarially judged-real posts.
41 # At 0.85: A4_5 +3 judged-real delineators / 0 lost; A1 +~18 judged-real vs
42 # +5 judged-veg. Real (0.66-0.83) and FP (0.68-0.85) footprints fully
43 # overlap, so no tighter cap separates them โ€” the veg leak is a texture
44 # problem (multi-radius plate regularity, task #14), not a threshold one.
45 delineator_relaxed_footprint_m: float = 0.85
46 delineator_relaxed_min_verticality: float = 0.85
47 # The wider relaxed band admits more smear, so it is guarded harder than the
48 # compact path: the post must stand clear (a near-empty free-space ring, so a
49 # bright speck embedded in roadside vegetation โ€” segment 084 โ€” is rejected)
50 # and be clearly retroreflective (a higher brightness floor than the compact
51 # 0.08, so a modest-brightness on-carriageway edge feature โ€” segment 096 โ€” is
52 # rejected). Genuine gore/island posts pass both (ring ~0, hi 0.28-0.66).
53 delineator_relaxed_max_ring_fill_ratio: float = 1.0
54 delineator_relaxed_min_hi_intensity_fraction: float = 0.15
55 delineator_min_hi_intensity_fraction: float = 0.08
56 # Real Leitpfosten return a few hundred points; sub-~300 bright specks are
57 # reflective vegetation/debris (segment 048 FP had ~100; segment 084's bright
58 # speck embedded in verge scrub, newly reachable once the corridor keeps
59 # branch roads, had 239). Every genuine delineator across the dataset returns
60 # >=371, so the 300 floor drops those specks with margin to spare.
61 delineator_min_points: int = 300
62
63 # Sign post / plate
64 sign_post_max_len_minor_m: float = 0.8
65 sign_post_h_min_m: float = 1.5
66 sign_post_h_max_m: float = 6.0
67 sign_post_min_continuity: float = 0.60
68 # Plate evidence needs strong retroreflectivity: verified real sign plates
69 # (segments 006/030/132/134) return an upper-half high-intensity fraction of
70 # 0.44-0.94, while every dull false-positive "sign" (vegetation mounds,
71 # crash-cushion / truck-rear slabs, forest trunks, vegetation bands) sits at
72 # <=0.35. The gate is set at 0.40 so plate evidence requires a genuine bright
73 # panel; the weak path allows a moderately-bright, upper-piled plate.
74 plate_hi_intensity_fraction: float = 0.40
75 plate_hi_intensity_fraction_weak: float = 0.30
76 # Upper-half point pile-up ratio required as weak-plate evidence and as
77 # plate *shape*. Raised to 2.0 so a mere ~1.7 surplus (roadside bush crowns,
78 # segment 048 FPs) no longer counts as a plate; real plates pile far more
79 # returns up high (good signs sit at 2.8-4.6, or carry a broad bright core).
80 sign_plate_upper_surplus_ratio: float = 2.0
81 # A genuine plate sits high on its post, so the upper half must hold at least
82 # as many returns as ~1/3 of the lower half. Low-lying bright blobs at the
83 # foot of a vehicle/truck (segment 106 FPs at ~0.09) are not plates.
84 sign_min_upper_half_surplus: float = 0.30
85 # A real sign PLATE spreads returns laterally (broad core) or piles them in
86 # the upper half; brightness alone on a tight thin core is a reflective
87 # post/speck, not a plate โ€” route it to the (stricter) bare-post path.
88 plate_min_core_rms_m: float = 0.10
89
90 # Bare posts (no plate evidence) must be tall, tight, vertical, and
91 # well-sampled. 0.065 m tightness rejects tall roadside vegetation (whose
92 # per-bin core reaches ~0.17 m); real marker posts sit near ~0.04 m. The
93 # point-count floor rejects small bright reflective specks (~<450 returns).
94 # Plate-less posts below gantry-leg height are indistinguishable from tree
95 # guards / fence posts by LiDAR geometry alone (confirmed FP in seg 132).
96 bare_post_min_h_max_m: float = 4.5
97 bare_post_max_core_rms_m: float = 0.065
98 bare_post_min_verticality: float = 0.90
99 bare_post_min_points: int = 450
100
101 # Isolated floating-pole rejection (far-range boundary ghost, defect class 1a).
102 # A "floating" pole_other whose base sits well off the ground (h_min high โ€” no
103 # ground-connected shaft, just an upper vertical smear) is a range-smear
104 # artifact at the far edge of dense coverage (segments 005, 015: a lone
105 # ~10 m column floating over the carriageway vanishing point) UNLESS it is one
106 # of several such columns clustered together (a genuine gantry-leg / mast group
107 # โ€” segments 046, 066, 025). Verified across the full sweep: the only isolated
108 # floating poles (no floating-pole neighbour within pole_isolated_radius_m) are
109 # exactly the 005/015 ghosts; every real gantry-leg pole has >=1 neighbour.
110 pole_floating_min_h_min_m: float = 3.5
111 pole_isolated_radius_m: float = 8.0
112
113 # Post-classification duplicate suppression (defect class 4). Two detections
114 # within dedup_radius_m XY of each other describe the same physical marker
115 # (e.g. a striped gore post firing both a delineator and a sign); keep the
116 # higher-priority type (sign > delineator > sign_post > pole_other >
117 # gantry_or_gate), breaking ties by point count, and drop the other.
118 dedup_radius_m: float = 0.8
119
120 # Gantry / gate
121 gantry_h_min_m: float = 4.5
122 gantry_len_major_m: float = 8.0
123 # A road-spanning overhead beam is thin; a tilted reflective truck-trailer
124 # slab (segment 106) is broad (len_minor ~9.8 m). Cap the single-cluster
125 # overhead_span footprint minor extent (real gantry cluster ~4.75 m).
126 gantry_max_len_minor_m: float = 6.0
127 gantry_pair_station_tolerance_m: float = 5.0
128 # Narrow overhead gates (segment 066: two ~10 m retroreflective legs ~3.7 m
129 # apart straddling a ramp) must still pair, so the minimum lateral
130 # separation is 3.0 m; the overhead-return test guards against false pairs.
131 gantry_pair_min_separation_m: float = 3.0
132 gantry_overhead_h_min_m: float = 4.5
133 # A synthesized gantry from a pair of tall posts is only trustworthy when the
134 # pair is ISOLATED โ€” no third tall post nearby. Two ~10 m legs straddling a
135 # ramp with nothing between them is a real gate (segment 066); three-or-more
136 # tall columns clustered at one station are a post row / mast group whose
137 # pairwise "span" crosses empty air (segments 046, 025 โ€” the QC ghosts). If a
138 # third tall post lies within this radius of the pair midpoint, the pairing is
139 # rejected. (The overhead middle-of-span test cannot separate these โ€” verified
140 # from points: 066's real gate also has an empty mid-span, so post COUNT, not
141 # overhead support, is the discriminator.)
142 gantry_pair_isolation_radius_m: float = 8.0
143
144
145def device_kwargs(config: dict[str, Any], defaults: DeviceFields) -> dict[str, Any]:
146 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
147
148 Sections read: ``classification``, ``delineator``, ``sign_post``, ``gantry``.
149
150 Args:
151 config: The nested config document, not a single section.
152 defaults: Instance supplying the fallback for every absent key.
153
154 Returns:
155 The ``DeviceFields`` keyword arguments, defaults filled in.
156 """
157 classification = config.get("classification", {})
158 delineator = config.get("delineator", {})
159 sign_post = config.get("sign_post", {})
160 gantry = config.get("gantry", {})
161 return {
162 "pole_floating_min_h_min_m": classification.get(
163 "pole_floating_min_h_min_m", defaults.pole_floating_min_h_min_m
164 ),
165 "pole_isolated_radius_m": classification.get(
166 "pole_isolated_radius_m", defaults.pole_isolated_radius_m
167 ),
168 "dedup_radius_m": classification.get(
169 "dedup_radius_m", defaults.dedup_radius_m
170 ),
171 "delineator_h_min_m": delineator.get("h_min_m", defaults.delineator_h_min_m),
172 "delineator_h_max_m": delineator.get("h_max_m", defaults.delineator_h_max_m),
173 "delineator_max_footprint_m": delineator.get(
174 "max_footprint_m", defaults.delineator_max_footprint_m
175 ),
176 "delineator_relaxed_footprint_m": delineator.get(
177 "relaxed_footprint_m", defaults.delineator_relaxed_footprint_m
178 ),
179 "delineator_relaxed_min_verticality": delineator.get(
180 "relaxed_min_verticality", defaults.delineator_relaxed_min_verticality
181 ),
182 "delineator_relaxed_max_ring_fill_ratio": delineator.get(
183 "relaxed_max_ring_fill_ratio",
184 defaults.delineator_relaxed_max_ring_fill_ratio,
185 ),
186 "delineator_relaxed_min_hi_intensity_fraction": delineator.get(
187 "relaxed_min_hi_intensity_fraction",
188 defaults.delineator_relaxed_min_hi_intensity_fraction,
189 ),
190 "delineator_min_hi_intensity_fraction": delineator.get(
191 "min_hi_intensity_fraction", defaults.delineator_min_hi_intensity_fraction
192 ),
193 "delineator_min_points": delineator.get(
194 "min_points", defaults.delineator_min_points
195 ),
196 "sign_post_max_len_minor_m": sign_post.get(
197 "max_len_minor_m", defaults.sign_post_max_len_minor_m
198 ),
199 "sign_post_h_min_m": sign_post.get("h_min_m", defaults.sign_post_h_min_m),
200 "sign_post_h_max_m": sign_post.get("h_max_m", defaults.sign_post_h_max_m),
201 "sign_post_min_continuity": sign_post.get(
202 "min_continuity", defaults.sign_post_min_continuity
203 ),
204 "plate_hi_intensity_fraction": sign_post.get(
205 "plate_hi_intensity_fraction", defaults.plate_hi_intensity_fraction
206 ),
207 "plate_hi_intensity_fraction_weak": sign_post.get(
208 "plate_hi_intensity_fraction_weak", defaults.plate_hi_intensity_fraction_weak
209 ),
210 "sign_plate_upper_surplus_ratio": sign_post.get(
211 "plate_upper_surplus_ratio", defaults.sign_plate_upper_surplus_ratio
212 ),
213 "sign_min_upper_half_surplus": sign_post.get(
214 "min_upper_half_surplus", defaults.sign_min_upper_half_surplus
215 ),
216 "plate_min_core_rms_m": sign_post.get(
217 "plate_min_core_rms_m", defaults.plate_min_core_rms_m
218 ),
219 "bare_post_min_h_max_m": sign_post.get(
220 "bare_post_min_h_max_m", defaults.bare_post_min_h_max_m
221 ),
222 "bare_post_max_core_rms_m": sign_post.get(
223 "bare_post_max_core_rms_m", defaults.bare_post_max_core_rms_m
224 ),
225 "bare_post_min_verticality": sign_post.get(
226 "bare_post_min_verticality", defaults.bare_post_min_verticality
227 ),
228 "bare_post_min_points": sign_post.get(
229 "bare_post_min_points", defaults.bare_post_min_points
230 ),
231 "gantry_h_min_m": gantry.get("h_min_m", defaults.gantry_h_min_m),
232 "gantry_len_major_m": gantry.get("len_major_m", defaults.gantry_len_major_m),
233 "gantry_max_len_minor_m": gantry.get(
234 "max_len_minor_m", defaults.gantry_max_len_minor_m
235 ),
236 "gantry_pair_station_tolerance_m": gantry.get(
237 "pair_station_tolerance_m", defaults.gantry_pair_station_tolerance_m
238 ),
239 "gantry_pair_min_separation_m": gantry.get(
240 "pair_min_separation_m", defaults.gantry_pair_min_separation_m
241 ),
242 "gantry_overhead_h_min_m": gantry.get(
243 "overhead_h_min_m", defaults.gantry_overhead_h_min_m
244 ),
245 "gantry_pair_isolation_radius_m": gantry.get(
246 "pair_isolation_radius_m", defaults.gantry_pair_isolation_radius_m
247 ),
248 }
0
Importance #6: src/iolabs_point_cloud_detection_verticalsigns/_config_grid.py @@ -1,131 +0,0 @@
1"""Ground, occupancy grid, candidate band and clustering thresholds.
2
3Also the first classification gates and vehicle rejection.
4
5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.
8"""
9
10from typing import Any
11
12from iolabs.common import config_loader
13
14
15class GridFields(config_loader.ConfigModel):
16 """Ground, occupancy grid, candidate band and clustering thresholds.
17
18 Also the first classification gates and vehicle rejection.
19
20 Metres unless stated otherwise.
21 """
22
23 # Ground model
24 ground_cell_m: float = 0.75
25 ground_percentile: float = 8.0
26
27 # Occupancy grid for candidate cells
28 occupancy_cell_m: float = 0.15
29
30 # Height band for off-ground candidate points
31 min_height_m: float = 0.30
32 max_height_m: float = 10.0
33
34 # Seed-cell gates (vertical span and max height above ground)
35 seed_min_vertical_span_m: float = 0.80
36 seed_min_h_max_m: float = 0.90
37
38 # Delineator recall seed pass. German Leitpfosten are ~1.0 m and, when
39 # sparsely sampled at range, span only ~0.75 m inside a 0.15 m occupancy
40 # cell (base clipped by min_height_m=0.30), so they fall just under the
41 # 0.80 m primary span gate and never seed a cluster โ€” the round-4 recall
42 # gap. A second, relaxed seed pass recovers them, but is restricted to
43 # cells holding >= seed_bright_min_points retroreflective returns
44 # (intensity >= the segment's hi-intensity threshold): a Leitpfosten head
45 # is always retroreflective, so the extra candidate cells stay few and the
46 # existing delineator gates + FP defenses (brightness, footprint, density,
47 # corridor, ring/forest) decide the verdict.
48 seed_bright_min_vertical_span_m: float = 0.45
49 seed_bright_min_h_max_m: float = 0.60
50 seed_bright_min_points: int = 3
51
52 # DBSCAN clustering on seed-cell centres
53 cluster_eps_m: float = 0.45
54 cluster_min_samples: int = 1
55 cluster_hull_margin_m: float = 0.20
56
57 # Per-cluster feature bins
58 continuity_bin_m: float = 0.25
59
60 # Classification thresholds
61 reject_len_major_m: float = 6.0
62 reject_h_max_with_large_footprint_m: float = 4.5
63 min_continuity: float = 0.50
64 min_accept_h_max_m: float = 0.90
65
66 # Vehicle rejection
67 vehicle_h_min_m: float = 1.5
68 vehicle_h_max_m: float = 4.5
69 vehicle_len_major_m: float = 2.5
70 vehicle_len_minor_m: float = 1.5
71 vehicle_max_hi_intensity_fraction: float = 0.10
72
73
74def grid_kwargs(config: dict[str, Any], defaults: GridFields) -> dict[str, Any]:
75 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
76
77 Sections read: ``ground``, ``occupancy``, ``candidates``, ``clustering``,
78 ``classification``, ``vehicle``.
79
80 Args:
81 config: The nested config document, not a single section.
82 defaults: Instance supplying the fallback for every absent key.
83
84 Returns:
85 The ``GridFields`` keyword arguments, defaults filled in.
86 """
87 ground = config.get("ground", {})
88 occupancy = config.get("occupancy", {})
89 candidates = config.get("candidates", {})
90 clustering = config.get("clustering", {})
91 classification = config.get("classification", {})
92 vehicle = config.get("vehicle", {})
93 return {
94 "ground_cell_m": ground.get("cell_m", defaults.ground_cell_m),
95 "ground_percentile": ground.get("percentile", defaults.ground_percentile),
96 "occupancy_cell_m": occupancy.get("cell_m", defaults.occupancy_cell_m),
97 "min_height_m": candidates.get("min_height_m", defaults.min_height_m),
98 "max_height_m": candidates.get("max_height_m", defaults.max_height_m),
99 "seed_min_vertical_span_m": candidates.get(
100 "seed_min_vertical_span_m", defaults.seed_min_vertical_span_m
101 ),
102 "seed_min_h_max_m": candidates.get("seed_min_h_max_m", defaults.seed_min_h_max_m),
103 "seed_bright_min_vertical_span_m": candidates.get(
104 "seed_bright_min_vertical_span_m",
105 defaults.seed_bright_min_vertical_span_m,
106 ),
107 "seed_bright_min_h_max_m": candidates.get(
108 "seed_bright_min_h_max_m", defaults.seed_bright_min_h_max_m
109 ),
110 "seed_bright_min_points": candidates.get(
111 "seed_bright_min_points", defaults.seed_bright_min_points
112 ),
113 "cluster_eps_m": clustering.get("eps_m", defaults.cluster_eps_m),
114 "cluster_min_samples": clustering.get("min_samples", defaults.cluster_min_samples),
115 "cluster_hull_margin_m": clustering.get("hull_margin_m", defaults.cluster_hull_margin_m),
116 "continuity_bin_m": classification.get("continuity_bin_m", defaults.continuity_bin_m),
117 "reject_len_major_m": classification.get("reject_len_major_m", defaults.reject_len_major_m),
118 "reject_h_max_with_large_footprint_m": classification.get(
119 "reject_h_max_with_large_footprint_m",
120 defaults.reject_h_max_with_large_footprint_m,
121 ),
122 "min_continuity": classification.get("min_continuity", defaults.min_continuity),
123 "min_accept_h_max_m": classification.get("min_accept_h_max_m", defaults.min_accept_h_max_m),
124 "vehicle_h_min_m": vehicle.get("h_min_m", defaults.vehicle_h_min_m),
125 "vehicle_h_max_m": vehicle.get("h_max_m", defaults.vehicle_h_max_m),
126 "vehicle_len_major_m": vehicle.get("len_major_m", defaults.vehicle_len_major_m),
127 "vehicle_len_minor_m": vehicle.get("len_minor_m", defaults.vehicle_len_minor_m),
128 "vehicle_max_hi_intensity_fraction": vehicle.get(
129 "max_hi_intensity_fraction", defaults.vehicle_max_hi_intensity_fraction
130 ),
131 }
0
Importance #7: src/iolabs_point_cloud_detection_verticalsigns/_config_model.py @@ -1,48 +0,0 @@
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 the same default to the packaged JSON; the two
7sides must stay in lockstep, and ``tests/test_config_split.py`` fails if they
8drift. A key the detector modules read also needs its flat ``DetectorConfig``
9field and the ``*_kwargs`` line that maps it (see ``config.py``).
10"""
11
12from iolabs.common import config_loader
13
14from . import _model_devices, _model_grid, _model_road, _model_tree
15
16
17class VerticalSignsConfig(config_loader.ConfigModel):
18 """Every configuration section of the vertical-sign detector."""
19
20 ground: _model_grid.GroundConfig = _model_grid.GroundConfig()
21 occupancy: _model_grid.OccupancyConfig = _model_grid.OccupancyConfig()
22 candidates: _model_grid.CandidatesConfig = _model_grid.CandidatesConfig()
23 clustering: _model_grid.ClusteringConfig = _model_grid.ClusteringConfig()
24 classification: _model_grid.ClassificationConfig = _model_grid.ClassificationConfig()
25 corridor: _model_grid.CorridorConfig = _model_grid.CorridorConfig()
26 context: _model_grid.ContextConfig = _model_grid.ContextConfig()
27 delineator: _model_devices.DelineatorConfig = _model_devices.DelineatorConfig()
28 sign_post: _model_devices.SignPostConfig = _model_devices.SignPostConfig()
29 panel: _model_devices.PanelConfig = _model_devices.PanelConfig()
30 gantry: _model_devices.GantryConfig = _model_devices.GantryConfig()
31 repetitive_row: _model_devices.RepetitiveRowConfig = _model_devices.RepetitiveRowConfig()
32 road_context: _model_road.RoadContextConfig = _model_road.RoadContextConfig()
33 edge_line: _model_road.EdgeLineConfig = _model_road.EdgeLineConfig()
34 field_stake: _model_devices.FieldStakeConfig = _model_devices.FieldStakeConfig()
35 marker_extract: _model_devices.MarkerExtractConfig = _model_devices.MarkerExtractConfig()
36 tree: _model_tree.TreeConfig = _model_tree.TreeConfig()
37 tree_detection: _model_tree.TreeDetectionConfig = _model_tree.TreeDetectionConfig()
38 chroma_vegetation: _model_tree.ChromaVegetationConfig = _model_tree.ChromaVegetationConfig()
39 vehicle: _model_grid.VehicleConfig = _model_grid.VehicleConfig()
40 views: _model_road.ViewsConfig = _model_road.ViewsConfig()
41 perspective: _model_road.PerspectiveConfig = _model_road.PerspectiveConfig()
42 tree_instance: _model_tree.TreeInstanceConfig = _model_tree.TreeInstanceConfig()
43 conic_gate: _model_tree.ConicGateConfig = _model_tree.ConicGateConfig()
44 conifer_rule: _model_tree.ConiferRuleConfig = _model_tree.ConiferRuleConfig()
45 radius: _model_grid.RadiusConfig = _model_grid.RadiusConfig()
46 rail_halfpost: _model_devices.RailHalfpostConfig = _model_devices.RailHalfpostConfig()
47 reject_rescue: _model_devices.RejectRescueConfig = _model_devices.RejectRescueConfig()
48 tcs_ground: _model_tree.TcsGroundConfig = _model_tree.TcsGroundConfig()
0
Importance #8: src/iolabs_point_cloud_detection_verticalsigns/_config_perspective.py @@ -1,96 +0,0 @@
1"""Perspective-projection QC overlay cameras and coverage tolerances.
2
3One slice of the flat ``DetectorConfig``, moved out of
4``config.py`` verbatim. ``config.py`` recombines the slices and
5re-exports both names defined here.
6"""
7
8from typing import Any
9
10from iolabs.common import config_loader
11
12
13class PerspectiveFields(config_loader.ConfigModel):
14 """Perspective-projection QC overlay cameras and coverage tolerances.
15
16 Metres unless stated otherwise.
17 """
18
19 # Perspective-projection QC overlay (verticalsigns-perspective). A projected
20 # vertical-line sample is "visible" when its camera-space depth is within
21 # perspective_depth_tol_m of the rendered depth-buffer value; occluded
22 # samples are drawn faint at perspective_occluded_alpha.
23 perspective_depth_tol_m: float = 0.5
24 perspective_line_samples: int = 20
25 perspective_occluded_alpha: int = 90
26 perspective_solid_width_px: int = 3
27 perspective_halo_width_px: int = 6
28 perspective_base_marker_radius_px: int = 6
29 # Synthesized fallback cameras for detections that no Azure metadata camera
30 # covers (outside every frustum, or projecting onto a void/black background).
31 # An 'auto_back' camera sits perspective_back_distance_m behind the detection
32 # along the road axis at perspective_back_height_m above z_ground; an
33 # 'auto_context' camera sits farther back and higher for scene context.
34 # Uncovered detections within perspective_share_radius_m share one camera pair
35 # aimed at their centroid. A detection counts as covered by a camera when its
36 # projected vertical line lands on rendered geometry within
37 # perspective_coverage_tol_m of the depth buffer.
38 perspective_back_distance_m: float = 22.0
39 perspective_back_height_m: float = 4.0
40 perspective_context_distance_m: float = 40.0
41 perspective_context_height_m: float = 6.0
42 perspective_share_radius_m: float = 15.0
43 perspective_coverage_tol_m: float = 0.5
44
45
46def perspective_kwargs(config: dict[str, Any], defaults: PerspectiveFields) -> dict[str, Any]:
47 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
48
49 Sections read: ``perspective``.
50
51 Args:
52 config: The nested config document, not a single section.
53 defaults: Instance supplying the fallback for every absent key.
54
55 Returns:
56 The ``PerspectiveFields`` keyword arguments, defaults filled in.
57 """
58 perspective = config.get("perspective", {})
59 return {
60 "perspective_depth_tol_m": perspective.get(
61 "depth_tol_m", defaults.perspective_depth_tol_m
62 ),
63 "perspective_line_samples": perspective.get(
64 "line_samples", defaults.perspective_line_samples
65 ),
66 "perspective_occluded_alpha": perspective.get(
67 "occluded_alpha", defaults.perspective_occluded_alpha
68 ),
69 "perspective_solid_width_px": perspective.get(
70 "solid_width_px", defaults.perspective_solid_width_px
71 ),
72 "perspective_halo_width_px": perspective.get(
73 "halo_width_px", defaults.perspective_halo_width_px
74 ),
75 "perspective_base_marker_radius_px": perspective.get(
76 "base_marker_radius_px", defaults.perspective_base_marker_radius_px
77 ),
78 "perspective_back_distance_m": perspective.get(
79 "back_distance_m", defaults.perspective_back_distance_m
80 ),
81 "perspective_back_height_m": perspective.get(
82 "back_height_m", defaults.perspective_back_height_m
83 ),
84 "perspective_context_distance_m": perspective.get(
85 "context_distance_m", defaults.perspective_context_distance_m
86 ),
87 "perspective_context_height_m": perspective.get(
88 "context_height_m", defaults.perspective_context_height_m
89 ),
90 "perspective_share_radius_m": perspective.get(
91 "share_radius_m", defaults.perspective_share_radius_m
92 ),
93 "perspective_coverage_tol_m": perspective.get(
94 "coverage_tol_m", defaults.perspective_coverage_tol_m
95 ),
96 }
0
Importance #9: src/iolabs_point_cloud_detection_verticalsigns/_config_roadcontext.py @@ -1,374 +0,0 @@
1"""Road-context gate, driven-lane band and repetitive-row rejection.
2
3Also field-stake rows and embedded-marker extraction.
4
5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.
8"""
9
10from typing import Any
11
12from iolabs.common import config_loader
13
14
15class RoadContextFields(config_loader.ConfigModel):
16 """Road-context gate, driven-lane band and repetitive-row rejection.
17
18 Also field-stake rows and embedded-marker extraction.
19
20 Metres unless stated otherwise.
21 """
22
23 # Repetitive-row rejection: a noise-barrier (Lรคrmschutzwand) support row
24 # (segment 116) is >=4 slender clusters of similar height on a line at
25 # regular <=5 m spacing. Delineators repeat at 25-50 m so they never form
26 # such a chain and stay safe.
27 row_min_members: int = 4
28 row_max_spacing_m: float = 5.0
29 row_max_perp_spread_m: float = 1.5
30 row_max_h_max_range_m: float = 0.7
31 row_member_max_len_major_m: float = 2.0
32 row_member_max_len_minor_m: float = 0.8
33
34 # Road-context gate (AI3D-339 pass 7): a delineator with ZERO saturated
35 # returns within roadctx_radius_m is not beside a carriageway and cannot be
36 # road furniture. Presence only โ€” absolute counts run ~100x lower on the
37 # A1 branch-1 ramp than on the mainline, so no count threshold transfers.
38 # See roadctx.py.
39 roadctx_gate_enabled: bool = True
40 roadctx_saturation_intensity: float = 55000.0
41 roadctx_radius_m: float = 15.0
42 # Segments either side to pool: a candidate near a tile boundary otherwise
43 # sees a truncated disc and can read zero purely from tiling.
44 roadctx_neighbour_span: int = 1
45 # Domain guard: below this many saturated returns in the pooled
46 # neighbourhood the measurement is coverage noise, not evidence of "no
47 # road", and the gate disarms. See RoadContext.armed.
48 roadctx_min_neighbourhood_saturated: int = 1000
49 # Local-ext4 cache for the per-segment saturated-return arrays; empty falls
50 # back to a road_context/ directory beside the per-segment output dirs.
51 roadctx_cache_dir: str = ""
52
53 # Driven-lane band gate (AI3D-339 pass 8, Miro directive). A short
54 # candidate standing in the lane the survey vehicle drove is a vehicle, not
55 # road furniture. The pass-8 census killed the wider "between the two edge
56 # lines of the carriageway" form โ€” run4 is absent on A1 and a featureless
57 # full-tile rectangle on A4_5, and paint runs at uniform lane spacing right
58 # across the median. See edgeline.py and p8_edgeline_census_result.md.
59 edgeline_gate_enabled: bool = True
60 # run7 lane XML is the PRIMARY road model (Miro: "use the lines from
61 # run7" / "from the XML. Much more reliable"). See run7_xml.py.
62 edgeline_xml_enabled: bool = True
63 # Cross-file consensus: with many per-drive XMLs a point is on the road
64 # only if this fraction of the files covering it agree. One bad variant
65 # must not be able to put a median device on the carriageway.
66 edgeline_xml_min_agreement: float = 0.6
67 # A file whose band is further than this from the point abstains rather
68 # than voting "outside" โ€” it is describing a different stretch of road.
69 edgeline_xml_vote_slack_m: float = 3.0
70 edgeline_xml_max_distance_m: float = 60.0
71 edgeline_xml_station_tolerance_m: float = 2.0
72 edgeline_xml_station_step_m: float = 10.0
73 # A full carriageway, not a lane: the XML edges bound the whole thing.
74 edgeline_min_carriageway_width_m: float = 3.0
75 edgeline_max_carriageway_width_m: float = 20.0
76 # Paint extraction is demoted to a fallback for corridors with no lane
77 # XML, and is OFF by default per the run7 directive.
78 edgeline_paint_fallback_enabled: bool = False
79 # Paint band: height above the local DEM within which a return is road
80 # marking rather than a device face (a delineator's band sits at 0.7-0.9 m).
81 edgeline_paint_max_height_m: float = 0.35
82 edgeline_paint_min_height_m: float = -0.25
83 # Paint cut as a PERCENTILE of near-ground intensity, never a DN: measured
84 # p95 = 39.3k/39.5k/41.3k on three A4_5 segments, while the roadctx
85 # saturation cut (55000) shows only the single line nearest the drive line.
86 edgeline_paint_intensity_percentile: float = 95.0
87 edgeline_paint_subsample: int = 20
88 # Along-road window.
89 edgeline_station_len_m: float = 10.0
90 edgeline_min_window_returns: int = 2000
91 # Painted-line detection in the lateral histogram.
92 edgeline_lateral_bin_m: float = 0.10
93 edgeline_min_line_points: int = 40
94 edgeline_max_line_width_m: float = 1.5
95 edgeline_min_line_along_fill: float = 0.4
96 # Drive line = densest lateral bin of all near-ground returns.
97 edgeline_drive_line_bin_m: float = 0.5
98 # Band sanity: one or two lanes. Wider means a line was missed.
99 edgeline_min_band_width_m: float = 2.0
100 edgeline_max_band_width_m: float = 9.0
101 # INWARD margin. Delineators stand ON the paint line, so the margin must
102 # shrink the rejection zone, never grow it.
103 edgeline_inward_margin_m: float = 0.3
104 edgeline_min_coverage_frac: float = 0.6
105 # Axis sanity, replacing the tile-elongation guard that misfired on real
106 # 51x34 m tiles: the paint must be sharper ACROSS the chosen axis than
107 # along it (measured ~19x on A4_5).
108 edgeline_min_axis_contrast: float = 3.0
109 # Central-axis prior (cross_sections_run7_lanes_*.npz).
110 edgeline_axis_search_radius_m: float = 40.0
111 edgeline_axis_max_angle_cos: float = 0.8
112 edgeline_axis_max_distance_m: float = 150.0
113 # Overhead exemption; type-based exemption in classify.py covers the rest.
114 edgeline_exempt_h_max_m: float = 4.5
115 # Corroboration: a transient exists in one driving pass only. Rejection
116 # requires this AND on-road position; position alone is a flag.
117 edgeline_reject_requires_transient: bool = True
118 edgeline_transient_max_records: int = 1
119 # Far-from-edge-line filter. Delineators stand 0.5-2 m off the carriageway
120 # edge; a "delineator" tens of metres away is a plantation or field stake
121 # (the class reject_rescue readmits). Default 30.0 m sits between real
122 # ramp posts at junctions with fragmentary XML coverage (p50 3.3 m / max
123 # 26.9 m with roleless edges included; A4_5 segs 131-135) and the
124 # false-positive stake rows (35-50 m on A4_5 038/049 and A1 branch-1
125 # 007/008). Measures against ALL XML edge features including roleless
126 # ramp edges. See edgedist.py.
127 edgeline_far_filter_enabled: bool = True
128 edgeline_far_max_distance_m: float = 30.0
129 # Also measure against painted lane-line families (Center Lines, Central
130 # Axis, Single-Side Central Axis). A delineator beside a painted line is
131 # near a road even where no Axis-of-the-Edge was extracted; this can only
132 # reduce false removals. Does not leak into the carriageway band model.
133 edgeline_far_include_lane_lines: bool = True
134 # Second, tighter far-from-edge cut for the 15-30 m band. Real ramp posts
135 # whose XML ramps are missing sit in that band with roadctx_n_sat 200-57k;
136 # reject-rescue stake rows in fields sit there with sat 2-130. Kill when
137 # screen distance exceeds the tighter cut AND measured saturation is
138 # below the paved-surface floor. See edgedist.py.
139 edgeline_far_tier2_enabled: bool = True
140 edgeline_far_tier2_distance_m: float = 15.0
141 edgeline_far_tier2_max_saturation: int = 150
142
143 # Field-stake rows: road-context failures that are phase-locked at stake
144 # spacing (A1 072/073 agricultural row at 5.8 m; A4_5 plantation rows at
145 # 4-5 m) are emitted as the experimental "field_stake_row" class instead of
146 # being dropped. min_members counts the whole row, so >=3 neighbours.
147 field_stake_row_emit: bool = True
148 field_stake_min_members: int = 4
149 field_stake_min_spacing_m: float = 2.0
150 field_stake_max_spacing_m: float = 10.0
151 field_stake_max_spacing_cv: float = 0.35
152
153 # Embedded-marker extraction: a bright vertical sign/delineator that DBSCAN
154 # glued onto an adjacent guardrail/barrier gets rejected as a large
155 # footprint. Scan the along-axis brightness profile of such rejected
156 # clusters for a compact, salient, retroreflective panel (segment 006).
157 marker_extract_min_len_major_m: float = 6.0
158 marker_extract_bright_h_min_m: float = 1.5
159 marker_extract_min_bright_points: int = 400
160 marker_extract_window_m: float = 2.5
161 marker_extract_min_bright_fraction: float = 0.45
162 marker_extract_min_h_max_m: float = 1.6
163 # Embedded-marker validation (defect class 3). The extracted window must be a
164 # genuine off-ground marker, not a flat bright road-surface artifact glued to a
165 # barrier. Require real vertical extent (points spanning at least this many
166 # metres) AND, for a window emitted as a "sign", genuine plate geometry โ€” a
167 # thin, slender slab (plate_thickness_m <= sign_max_plate_thickness_m and
168 # len_minor <= sign_post_max_len_minor_m). Segment 079's on-road paint blob
169 # (len_minor 2.06 m, plate_thickness 0.16 m) fails both; segment 006's real
170 # guide board (0.45 m, 0.005 m) passes. NB: an on-road-fraction guard is NOT
171 # used here because 006's window also reads on_road_fraction 1.0 โ€” plate
172 # geometry, not road overlap, is the true separator.
173 marker_extract_min_vertical_span_m: float = 0.5
174
175
176def road_context_kwargs(config: dict[str, Any], defaults: RoadContextFields) -> dict[str, Any]:
177 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
178
179 Sections read: ``repetitive_row``, ``road_context``, ``edge_line``, ``field_stake``,
180 ``marker_extract``.
181
182 Args:
183 config: The nested config document, not a single section.
184 defaults: Instance supplying the fallback for every absent key.
185
186 Returns:
187 The ``RoadContextFields`` keyword arguments, defaults filled in.
188 """
189 row = config.get("repetitive_row", {})
190 roadctx = config.get("road_context", {})
191 edgeline = config.get("edge_line", {})
192 stake = config.get("field_stake", {})
193 marker = config.get("marker_extract", {})
194 return {
195 "row_min_members": row.get("min_members", defaults.row_min_members),
196 "row_max_spacing_m": row.get("max_spacing_m", defaults.row_max_spacing_m),
197 "row_max_perp_spread_m": row.get(
198 "max_perp_spread_m", defaults.row_max_perp_spread_m
199 ),
200 "row_max_h_max_range_m": row.get(
201 "max_h_max_range_m", defaults.row_max_h_max_range_m
202 ),
203 "row_member_max_len_major_m": row.get(
204 "member_max_len_major_m", defaults.row_member_max_len_major_m
205 ),
206 "row_member_max_len_minor_m": row.get(
207 "member_max_len_minor_m", defaults.row_member_max_len_minor_m
208 ),
209 "roadctx_gate_enabled": roadctx.get(
210 "gate_enabled", defaults.roadctx_gate_enabled
211 ),
212 "roadctx_saturation_intensity": roadctx.get(
213 "saturation_intensity", defaults.roadctx_saturation_intensity
214 ),
215 "roadctx_radius_m": roadctx.get("radius_m", defaults.roadctx_radius_m),
216 "roadctx_neighbour_span": roadctx.get(
217 "neighbour_span", defaults.roadctx_neighbour_span
218 ),
219 "roadctx_cache_dir": roadctx.get("cache_dir", defaults.roadctx_cache_dir),
220 "roadctx_min_neighbourhood_saturated": roadctx.get(
221 "min_neighbourhood_saturated",
222 defaults.roadctx_min_neighbourhood_saturated,
223 ),
224 "edgeline_gate_enabled": edgeline.get(
225 "gate_enabled", defaults.edgeline_gate_enabled
226 ),
227 "edgeline_xml_enabled": edgeline.get(
228 "xml_enabled", defaults.edgeline_xml_enabled
229 ),
230 "edgeline_xml_min_agreement": edgeline.get(
231 "xml_min_agreement", defaults.edgeline_xml_min_agreement
232 ),
233 "edgeline_xml_vote_slack_m": edgeline.get(
234 "xml_vote_slack_m", defaults.edgeline_xml_vote_slack_m
235 ),
236 "edgeline_xml_max_distance_m": edgeline.get(
237 "xml_max_distance_m", defaults.edgeline_xml_max_distance_m
238 ),
239 "edgeline_xml_station_tolerance_m": edgeline.get(
240 "xml_station_tolerance_m", defaults.edgeline_xml_station_tolerance_m
241 ),
242 "edgeline_xml_station_step_m": edgeline.get(
243 "xml_station_step_m", defaults.edgeline_xml_station_step_m
244 ),
245 "edgeline_min_carriageway_width_m": edgeline.get(
246 "min_carriageway_width_m", defaults.edgeline_min_carriageway_width_m
247 ),
248 "edgeline_max_carriageway_width_m": edgeline.get(
249 "max_carriageway_width_m", defaults.edgeline_max_carriageway_width_m
250 ),
251 "edgeline_paint_fallback_enabled": edgeline.get(
252 "paint_fallback_enabled", defaults.edgeline_paint_fallback_enabled
253 ),
254 "edgeline_paint_max_height_m": edgeline.get(
255 "paint_max_height_m", defaults.edgeline_paint_max_height_m
256 ),
257 "edgeline_paint_min_height_m": edgeline.get(
258 "paint_min_height_m", defaults.edgeline_paint_min_height_m
259 ),
260 "edgeline_paint_intensity_percentile": edgeline.get(
261 "paint_intensity_percentile", defaults.edgeline_paint_intensity_percentile
262 ),
263 "edgeline_paint_subsample": edgeline.get(
264 "paint_subsample", defaults.edgeline_paint_subsample
265 ),
266 "edgeline_station_len_m": edgeline.get(
267 "station_len_m", defaults.edgeline_station_len_m
268 ),
269 "edgeline_min_window_returns": edgeline.get(
270 "min_window_returns", defaults.edgeline_min_window_returns
271 ),
272 "edgeline_lateral_bin_m": edgeline.get(
273 "lateral_bin_m", defaults.edgeline_lateral_bin_m
274 ),
275 "edgeline_min_line_points": edgeline.get(
276 "min_line_points", defaults.edgeline_min_line_points
277 ),
278 "edgeline_max_line_width_m": edgeline.get(
279 "max_line_width_m", defaults.edgeline_max_line_width_m
280 ),
281 "edgeline_min_line_along_fill": edgeline.get(
282 "min_line_along_fill", defaults.edgeline_min_line_along_fill
283 ),
284 "edgeline_drive_line_bin_m": edgeline.get(
285 "drive_line_bin_m", defaults.edgeline_drive_line_bin_m
286 ),
287 "edgeline_min_band_width_m": edgeline.get(
288 "min_band_width_m", defaults.edgeline_min_band_width_m
289 ),
290 "edgeline_max_band_width_m": edgeline.get(
291 "max_band_width_m", defaults.edgeline_max_band_width_m
292 ),
293 "edgeline_inward_margin_m": edgeline.get(
294 "inward_margin_m", defaults.edgeline_inward_margin_m
295 ),
296 "edgeline_min_coverage_frac": edgeline.get(
297 "min_coverage_frac", defaults.edgeline_min_coverage_frac
298 ),
299 "edgeline_min_axis_contrast": edgeline.get(
300 "min_axis_contrast", defaults.edgeline_min_axis_contrast
301 ),
302 "edgeline_axis_search_radius_m": edgeline.get(
303 "axis_search_radius_m", defaults.edgeline_axis_search_radius_m
304 ),
305 "edgeline_axis_max_angle_cos": edgeline.get(
306 "axis_max_angle_cos", defaults.edgeline_axis_max_angle_cos
307 ),
308 "edgeline_axis_max_distance_m": edgeline.get(
309 "axis_max_distance_m", defaults.edgeline_axis_max_distance_m
310 ),
311 "edgeline_exempt_h_max_m": edgeline.get(
312 "exempt_h_max_m", defaults.edgeline_exempt_h_max_m
313 ),
314 "edgeline_reject_requires_transient": edgeline.get(
315 "reject_requires_transient", defaults.edgeline_reject_requires_transient
316 ),
317 "edgeline_transient_max_records": edgeline.get(
318 "transient_max_records", defaults.edgeline_transient_max_records
319 ),
320 "edgeline_far_filter_enabled": edgeline.get(
321 "far_filter_enabled", defaults.edgeline_far_filter_enabled
322 ),
323 "edgeline_far_max_distance_m": edgeline.get(
324 "far_max_distance_m", defaults.edgeline_far_max_distance_m
325 ),
326 "edgeline_far_include_lane_lines": edgeline.get(
327 "far_include_lane_lines", defaults.edgeline_far_include_lane_lines
328 ),
329 "edgeline_far_tier2_enabled": edgeline.get(
330 "far_tier2_enabled", defaults.edgeline_far_tier2_enabled
331 ),
332 "edgeline_far_tier2_distance_m": edgeline.get(
333 "far_tier2_distance_m", defaults.edgeline_far_tier2_distance_m
334 ),
335 "edgeline_far_tier2_max_saturation": edgeline.get(
336 "far_tier2_max_saturation", defaults.edgeline_far_tier2_max_saturation
337 ),
338 "field_stake_row_emit": stake.get(
339 "row_emit", defaults.field_stake_row_emit
340 ),
341 "field_stake_min_members": stake.get(
342 "min_members", defaults.field_stake_min_members
343 ),
344 "field_stake_min_spacing_m": stake.get(
345 "min_spacing_m", defaults.field_stake_min_spacing_m
346 ),
347 "field_stake_max_spacing_m": stake.get(
348 "max_spacing_m", defaults.field_stake_max_spacing_m
349 ),
350 "field_stake_max_spacing_cv": stake.get(
351 "max_spacing_cv", defaults.field_stake_max_spacing_cv
352 ),
353 "marker_extract_min_len_major_m": marker.get(
354 "min_len_major_m", defaults.marker_extract_min_len_major_m
355 ),
356 "marker_extract_bright_h_min_m": marker.get(
357 "bright_h_min_m", defaults.marker_extract_bright_h_min_m
358 ),
359 "marker_extract_min_bright_points": marker.get(
360 "min_bright_points", defaults.marker_extract_min_bright_points
361 ),
362 "marker_extract_window_m": marker.get(
363 "window_m", defaults.marker_extract_window_m
364 ),
365 "marker_extract_min_bright_fraction": marker.get(
366 "min_bright_fraction", defaults.marker_extract_min_bright_fraction
367 ),
368 "marker_extract_min_h_max_m": marker.get(
369 "min_h_max_m", defaults.marker_extract_min_h_max_m
370 ),
371 "marker_extract_min_vertical_span_m": marker.get(
372 "min_vertical_span_m", defaults.marker_extract_min_vertical_span_m
373 ),
374 }
0
Importance #10: src/iolabs_point_cloud_detection_verticalsigns/_config_stages.py @@ -1,241 +0,0 @@
1"""Opt-in post-classification stages.
2
3The rail-relative half-post pass, the reject-rescue second look and
4the ML verifier.
5
6One slice of the flat ``DetectorConfig``, moved out of
7``config.py`` verbatim. ``config.py`` recombines the slices and
8re-exports both names defined here.
9"""
10
11from typing import Any
12
13from iolabs.common import config_loader
14
15
16class StageFields(config_loader.ConfigModel):
17 """Opt-in post-classification stages.
18
19 The rail-relative half-post pass, the reject-rescue second look and
20 the ML verifier.
21
22 Metres unless stated otherwise.
23 """
24
25 # Rail-relative half-post stage (see railpost.py; AI3D-339 pass 10). A
26 # guardrail-mounted delineator body is invisible to the main path: it fuses
27 # with the W-beam into one 45 m blob at seeding. This stage searches the
28 # band above each rail's measured beam crest, given guardrail models from
29 # the guardrails repo. ~91% of A4_5 is railed, so the class is the dominant
30 # delineator morphology there, not an edge case.
31 #
32 # Every constant is FROZEN from the pass-8 A4_5 probe and its pass-9 A1
33 # re-run, which applied the gate unchanged โ€” the panel's "twice-transferred"
34 # requirement. They are config keys so the reserve burn can toggle them,
35 # not because they are open for tuning.
36 #
37 # prime (n_sat >= 1 AND nrec >= 2) is a CONFIDENCE MARKER, NEVER A GATE:
38 # the pass-9 control arm measured the non-prime tail at 43% real, which
39 # makes prime a ~2.2x precision-ranking device. Gating on it would throw
40 # away a near-coin-flip tail.
41 #
42 # OFF by default: validation needs the ratified truth set.
43 rail_halfpost_stage: bool = False
44 # Root searched for **/segment_<id>/guardrails.json (the guardrails repo
45 # writes one output root per worker: out_w0/, out_w1/, ...). Empty disables
46 # the stage even when the flag is on.
47 rail_halfpost_models_dir: str = ""
48 # Band geometry (probe constants). The 0.15 m floor is calibrated: the
49 # W-beam's own returns reach ~0.20 m above the fitted top, and below that
50 # floor every cluster in the band fuses into one blob per rail.
51 rail_halfpost_band_lat_m: float = 0.80
52 rail_halfpost_band_z_lo_m: float = 0.15
53 rail_halfpost_band_z_hi_m: float = 1.50
54 rail_halfpost_sample_step_m: float = 0.10
55 rail_halfpost_cluster_cell_m: float = 0.15
56 rail_halfpost_min_emit_points: int = 8
57 rail_halfpost_ground_cell_m: float = 2.0
58 rail_halfpost_ground_percentile: float = 10.0
59 rail_halfpost_saturation_intensity: float = 55000.0
60 # Acceptance gate (pass-8, transferred to A1 unchanged in pass 9).
61 rail_halfpost_h_min_m: float = 0.20
62 rail_halfpost_h_max_m: float = 0.80
63 rail_halfpost_max_lateral_m: float = 0.50
64 rail_halfpost_max_width_m: float = 0.20
65 rail_halfpost_min_points: int = 15
66 rail_halfpost_min_z_extent_m: float = 0.10
67 rail_halfpost_dedupe_m: float = 1.5
68 # Confidence marker only โ€” see above.
69 rail_halfpost_prime_min_sat: int = 1
70 rail_halfpost_prime_min_records: int = 2
71
72 # Reject-rescue second-look stage (see rescue.py; AI3D-339 pass 10). The
73 # pass-9 sieve's stratum A, ported as a detector stage: a label-free
74 # physical screen over clusters the detector rejected with a reason that
75 # named no positive counter-indication. Seven clusters called vegetation
76 # over the lifetime of the loop were later overturned to real devices, and
77 # the criteria below are the profile those seven share, with each threshold
78 # anchored to a percentile of the detector's OWN accepted delineators on the
79 # same run โ€” never to a judged label (out_eval/pass9/p9_sieve.py).
80 #
81 # Brightness is deliberately NOT a gate: three of the seven overturns were
82 # explicitly unsaturated. It is a rank bonus in the sieve and nothing here.
83 #
84 # OFF by default: validation needs the ratified truth set.
85 reject_rescue_stage: bool = False
86 rescue_h_min_m: float = 0.85
87 rescue_h_max_m: float = 1.60
88 rescue_min_verticality: float = 0.90
89 rescue_max_core_rms_m: float = 0.20
90 rescue_min_h_over_width: float = 1.40
91 rescue_min_records: int = 2
92 rescue_min_roadctx_sat: int = 17
93 rescue_min_continuity: float = 0.80
94 rescue_min_decile_fill: float = 0.60
95 rescue_min_points: int = 30
96 # Two rescues this close describe one physical object; keep the better one.
97 rescue_merge_radius_m: float = 1.0
98 # A rescue within this distance of something already accepted is not a
99 # rescue, it is a duplicate.
100 rescue_accepted_exclusion_m: float = 2.0
101 # Sieve's PER_SEGMENT_CAP was a crop-budget device for a judge pool, not a
102 # physical criterion, so it does not ship as one: 0 means no cap.
103 rescue_per_segment_cap: int = 0
104
105 # ML verifier stage (see ml.py). When enabled and a model file resolves,
106 # every accepted detection gets an "ml_confidence" = P(real) in the JSON and
107 # detections scoring below ml_veto_threshold are dropped with reason
108 # ml_vetoed (logged in clusters.csv). Enabled by default but a pure no-op
109 # when no model is present, so a fresh checkout behaves exactly as before.
110 # A negative ml_veto_threshold means "use the threshold in the model
111 # bundle"; ml_model_path empty means "resolve models/latest.json".
112 ml_verifier_enabled: bool = True
113 ml_veto_threshold: float = -1.0
114 ml_model_path: str = ""
115 # The verifier was trained on corridor-bearing A4_5 data with its veto
116 # threshold anchored to the minimum P(real) among training reals (0.62).
117 # On a run4-less dataset the model runs out-of-domain: measured on
118 # Abschnitt 1, all five adversarially judged-real signs of the segment-048
119 # family scored P 0.51-0.59 and were vetoed. When True (default), segments
120 # without run4 road-surface files score-and-annotate but do not veto;
121 # corridor-bearing segments (all of A4_5) are byte-identical either way.
122 ml_veto_requires_corridor: bool = True
123
124
125def stage_kwargs(config: dict[str, Any], defaults: StageFields) -> dict[str, Any]:
126 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
127
128 Sections read: ``classification``, ``rail_halfpost``, ``reject_rescue``.
129
130 Args:
131 config: The nested config document, not a single section.
132 defaults: Instance supplying the fallback for every absent key.
133
134 Returns:
135 The ``StageFields`` keyword arguments, defaults filled in.
136 """
137 classification = config.get("classification", {})
138 railpost = config.get("rail_halfpost", {})
139 rescue = config.get("reject_rescue", {})
140 return {
141 "rail_halfpost_stage": railpost.get("enabled", defaults.rail_halfpost_stage),
142 "rail_halfpost_models_dir": railpost.get(
143 "models_dir", defaults.rail_halfpost_models_dir
144 ),
145 "rail_halfpost_band_lat_m": railpost.get(
146 "band_lat_m", defaults.rail_halfpost_band_lat_m
147 ),
148 "rail_halfpost_band_z_lo_m": railpost.get(
149 "band_z_lo_m", defaults.rail_halfpost_band_z_lo_m
150 ),
151 "rail_halfpost_band_z_hi_m": railpost.get(
152 "band_z_hi_m", defaults.rail_halfpost_band_z_hi_m
153 ),
154 "rail_halfpost_sample_step_m": railpost.get(
155 "sample_step_m", defaults.rail_halfpost_sample_step_m
156 ),
157 "rail_halfpost_cluster_cell_m": railpost.get(
158 "cluster_cell_m", defaults.rail_halfpost_cluster_cell_m
159 ),
160 "rail_halfpost_min_emit_points": railpost.get(
161 "min_emit_points", defaults.rail_halfpost_min_emit_points
162 ),
163 "rail_halfpost_ground_cell_m": railpost.get(
164 "ground_cell_m", defaults.rail_halfpost_ground_cell_m
165 ),
166 "rail_halfpost_ground_percentile": railpost.get(
167 "ground_percentile", defaults.rail_halfpost_ground_percentile
168 ),
169 "rail_halfpost_saturation_intensity": railpost.get(
170 "saturation_intensity", defaults.rail_halfpost_saturation_intensity
171 ),
172 "rail_halfpost_h_min_m": railpost.get(
173 "h_min_m", defaults.rail_halfpost_h_min_m
174 ),
175 "rail_halfpost_h_max_m": railpost.get(
176 "h_max_m", defaults.rail_halfpost_h_max_m
177 ),
178 "rail_halfpost_max_lateral_m": railpost.get(
179 "max_lateral_m", defaults.rail_halfpost_max_lateral_m
180 ),
181 "rail_halfpost_max_width_m": railpost.get(
182 "max_width_m", defaults.rail_halfpost_max_width_m
183 ),
184 "rail_halfpost_min_points": railpost.get(
185 "min_points", defaults.rail_halfpost_min_points
186 ),
187 "rail_halfpost_min_z_extent_m": railpost.get(
188 "min_z_extent_m", defaults.rail_halfpost_min_z_extent_m
189 ),
190 "rail_halfpost_dedupe_m": railpost.get(
191 "dedupe_m", defaults.rail_halfpost_dedupe_m
192 ),
193 "rail_halfpost_prime_min_sat": railpost.get(
194 "prime_min_sat", defaults.rail_halfpost_prime_min_sat
195 ),
196 "rail_halfpost_prime_min_records": railpost.get(
197 "prime_min_records", defaults.rail_halfpost_prime_min_records
198 ),
199 "reject_rescue_stage": rescue.get("enabled", defaults.reject_rescue_stage),
200 "rescue_h_min_m": rescue.get("h_min_m", defaults.rescue_h_min_m),
201 "rescue_h_max_m": rescue.get("h_max_m", defaults.rescue_h_max_m),
202 "rescue_min_verticality": rescue.get(
203 "min_verticality", defaults.rescue_min_verticality
204 ),
205 "rescue_max_core_rms_m": rescue.get(
206 "max_core_rms_m", defaults.rescue_max_core_rms_m
207 ),
208 "rescue_min_h_over_width": rescue.get(
209 "min_h_over_width", defaults.rescue_min_h_over_width
210 ),
211 "rescue_min_records": rescue.get("min_records", defaults.rescue_min_records),
212 "rescue_min_roadctx_sat": rescue.get(
213 "min_roadctx_sat", defaults.rescue_min_roadctx_sat
214 ),
215 "rescue_min_continuity": rescue.get(
216 "min_continuity", defaults.rescue_min_continuity
217 ),
218 "rescue_min_decile_fill": rescue.get(
219 "min_decile_fill", defaults.rescue_min_decile_fill
220 ),
221 "rescue_min_points": rescue.get("min_points", defaults.rescue_min_points),
222 "rescue_merge_radius_m": rescue.get(
223 "merge_radius_m", defaults.rescue_merge_radius_m
224 ),
225 "rescue_accepted_exclusion_m": rescue.get(
226 "accepted_exclusion_m", defaults.rescue_accepted_exclusion_m
227 ),
228 "rescue_per_segment_cap": rescue.get(
229 "per_segment_cap", defaults.rescue_per_segment_cap
230 ),
231 "ml_verifier_enabled": classification.get(
232 "ml_verifier_enabled", defaults.ml_verifier_enabled
233 ),
234 "ml_veto_threshold": classification.get(
235 "ml_veto_threshold", defaults.ml_veto_threshold
236 ),
237 "ml_model_path": classification.get("ml_model_path", defaults.ml_model_path),
238 "ml_veto_requires_corridor": classification.get(
239 "ml_veto_requires_corridor", defaults.ml_veto_requires_corridor
240 ),
241 }
0
Importance #11: src/iolabs_point_cloud_detection_verticalsigns/_config_treedetect.py @@ -1,146 +0,0 @@
1"""Experimental tree detection and TCS ground filtering of the DEM input.
2
3One slice of the flat ``DetectorConfig``, moved out of
4``config.py`` verbatim. ``config.py`` recombines the slices and
5re-exports both names defined here.
6"""
7
8from typing import Any
9
10from iolabs.common import config_loader
11
12
13class TreeDetectionFields(config_loader.ConfigModel):
14 """Experimental tree detection and TCS ground filtering of the DEM input.
15
16 Metres unless stated otherwise.
17 """
18
19 # Experimental vegetation (tree) detection path (Part B). Master flag off by
20 # default; enabled via a config override for the tree run. A coarser DBSCAN
21 # and a wider (20 m) corridor run SEPARATELY from the sign path, and a
22 # dedicated vegetation RF (models/latest_vegetation.json) decides tree-vs-not.
23 # Candidates sitting directly above road-surface cells (a bridge/elevated
24 # deck, segment 033) are rejected by the on-road-fraction bridge guard.
25 tree_detection_enabled: bool = False
26 tree_max_dist_to_road_m: float = 20.0
27 tree_seed_min_vertical_span_m: float = 1.5
28 tree_seed_points_above_m: float = 2.0
29 tree_eps_m: float = 1.5
30 tree_min_samples: int = 3
31 tree_hull_margin_m: float = 0.5
32 tree_min_points: int = 60
33 tree_bridge_max_on_road_fraction: float = 0.6
34 tree_dedup_radius_m: float = 2.0
35 tree_min_confidence: float = -1.0
36 tree_model_path: str = ""
37 # Hedge split: every accepted tree cluster is put through the instance
38 # splitter's band (hedge) rule, and a grounded, low, long, stemless,
39 # flat-topped one is emitted as "medium_vegetation" (LAS 4) instead of
40 # "tree" (LAS 5). OFF by default (Miro, AI3D-373): whatever the tree
41 # stage accepts IS a tree -- a 3 m flat-topped band of greenery is high
42 # vegetation to the annotators, and the ground is often cut off so the
43 # trunks that would tell a tree from a hedge are not in the cloud. The
44 # rule stays available for datasets where hedges must go to LAS 4.
45 #
46 # This is the ONLY hedge knob under "tree_detection": it is on/off and
47 # nothing else. Every threshold the rule reads lives in the tree_instance
48 # slice, because the rule itself belongs to the instance splitter and the
49 # two callers must not be able to drift apart -- see _config_treeinstance:
50 # ``ti_hedge_*`` (ground gap, height, length, area, continuity, top relief,
51 # stems per 10 m, stem score bar), ``ti_min_cluster_points`` (the point
52 # floor below which the verdict abstains as "too_few_points"), and the stem
53 # band ``ti_stem_band_*`` / ``ti_stem_exg_bonus`` that produce the seeds the
54 # stemless conjunct counts. JSON: {"tree_instance": {"hedge_max_height_m":
55 # ...}}, not {"tree_detection": {...}}.
56 tree_hedge_split_enabled: bool = False
57
58 # TCS (tablecloth) ground filtering, Option C (AI3D-339). When enabled the
59 # p8 DEM is built from TCS-ground-classified points only, so height-above-
60 # ground stops being biased upward by parked vehicles and low canopy. This
61 # repoints the DEM INPUT ONLY -- the candidate accumulation keeps reading
62 # the original run3 files, because TCS drops vegetation as non-ground and
63 # feeding cleaned clouds to the candidate path would erase every tree.
64 # Profile is FORKED from tablecloth's defaults, which are tuned lip-first
65 # for pavement-edge retention (max_window 3.0 m lets vehicles survive into
66 # the surface); these are the wider road-corridor values.
67 tcs_ground_enabled: bool = False
68 tcs_mechanism: str = "smrf_numpy"
69 tcs_cell_m: float = 0.20
70 tcs_slope_threshold: float = 0.30
71 tcs_max_elev_diff_m: float = 0.15
72 tcs_smrf_max_window_m: float = 6.0
73 tcs_elev_scalar: float = 0.0
74 tcs_pit_fill_enabled: bool = True
75 # Where the ground-only *_run3_ground_points.npz intermediates are written.
76 # Empty means "beside the output segment dir". Point this at local ext4 --
77 # the 9p /mnt/d share is far too slow for rewriting whole clouds.
78 tcs_cache_dir: str = ""
79
80
81def tree_detection_kwargs(config: dict[str, Any], defaults: TreeDetectionFields) -> dict[str, Any]:
82 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
83
84 Sections read: ``tree_detection``, ``tcs_ground``.
85
86 Args:
87 config: The nested config document, not a single section.
88 defaults: Instance supplying the fallback for every absent key.
89
90 Returns:
91 The ``TreeDetectionFields`` keyword arguments, defaults filled in.
92 """
93 tree_detection = config.get("tree_detection", {})
94 tcs_ground = config.get("tcs_ground", {})
95 return {
96 "tree_detection_enabled": tree_detection.get(
97 "enabled", defaults.tree_detection_enabled
98 ),
99 "tree_max_dist_to_road_m": tree_detection.get(
100 "max_dist_to_road_m", defaults.tree_max_dist_to_road_m
101 ),
102 "tree_seed_min_vertical_span_m": tree_detection.get(
103 "seed_min_vertical_span_m", defaults.tree_seed_min_vertical_span_m
104 ),
105 "tree_seed_points_above_m": tree_detection.get(
106 "seed_points_above_m", defaults.tree_seed_points_above_m
107 ),
108 "tree_eps_m": tree_detection.get("eps_m", defaults.tree_eps_m),
109 "tree_min_samples": tree_detection.get(
110 "min_samples", defaults.tree_min_samples
111 ),
112 "tree_hull_margin_m": tree_detection.get(
113 "hull_margin_m", defaults.tree_hull_margin_m
114 ),
115 "tree_min_points": tree_detection.get("min_points", defaults.tree_min_points),
116 "tree_bridge_max_on_road_fraction": tree_detection.get(
117 "bridge_max_on_road_fraction", defaults.tree_bridge_max_on_road_fraction
118 ),
119 "tree_dedup_radius_m": tree_detection.get(
120 "dedup_radius_m", defaults.tree_dedup_radius_m
121 ),
122 "tree_min_confidence": tree_detection.get(
123 "min_confidence", defaults.tree_min_confidence
124 ),
125 "tree_model_path": tree_detection.get("model_path", defaults.tree_model_path),
126 "tree_hedge_split_enabled": tree_detection.get(
127 "hedge_split_enabled", defaults.tree_hedge_split_enabled
128 ),
129 "tcs_ground_enabled": tcs_ground.get("enabled", defaults.tcs_ground_enabled),
130 "tcs_mechanism": tcs_ground.get("mechanism", defaults.tcs_mechanism),
131 "tcs_cell_m": tcs_ground.get("cell_m", defaults.tcs_cell_m),
132 "tcs_slope_threshold": tcs_ground.get(
133 "slope_threshold", defaults.tcs_slope_threshold
134 ),
135 "tcs_max_elev_diff_m": tcs_ground.get(
136 "max_elev_diff_m", defaults.tcs_max_elev_diff_m
137 ),
138 "tcs_smrf_max_window_m": tcs_ground.get(
139 "smrf_max_window_m", defaults.tcs_smrf_max_window_m
140 ),
141 "tcs_elev_scalar": tcs_ground.get("elev_scalar", defaults.tcs_elev_scalar),
142 "tcs_pit_fill_enabled": tcs_ground.get(
143 "pit_fill_enabled", defaults.tcs_pit_fill_enabled
144 ),
145 "tcs_cache_dir": tcs_ground.get("cache_dir", defaults.tcs_cache_dir),
146 }
0
Importance #12: src/iolabs_point_cloud_detection_verticalsigns/_model_base.py @@ -0,0 +1,67 @@
1"""Field-declaration helper shared by the ``_model_<topic>`` config slices.
2
3The detector reads a FLAT config (``config.ground_cell_m``) while the packaged
4``verticalsigns.default.json`` โ€” and every user override file โ€” is grouped into
5sections (``{"ground": {"cell_m": 0.75}}``). :func:`section_field` is what joins
6the two: each flat field declares the JSON section and key it comes from right
7where it declares its type and default, so a new config key costs exactly two
8edits (the field here, the same key in the JSON) and no separate mapping table.
9
10:class:`iolabs_point_cloud_detection_verticalsigns._config.DetectorConfig`
11walks that metadata to translate a nested document into flat keyword arguments
12(``DetectorConfig.from_mapping``) and back (``DetectorConfig.to_document``).
13"""
14
15from __future__ import annotations
16
17from typing import Any
18
19import pydantic
20
21_SECTION_METADATA_KEY = "config_section_path"
22
23
24def section_field(path: str, default: Any, **constraints: Any) -> Any:
25 """Declare a flat field carrying the ``"<section>.<key>"`` it is loaded from.
26
27 Args:
28 path: Dotted location in the nested config document, e.g.
29 ``"ground.cell_m"``. The section must exist in
30 ``verticalsigns.default.json`` and the key must be spelled exactly
31 as the JSON spells it.
32 default: The field default, which must equal the packaged JSON value.
33 constraints: Extra ``pydantic.Field`` arguments, e.g. ``ge=0.0``.
34
35 Returns:
36 The ``pydantic.Field`` descriptor for the field.
37
38 Raises:
39 ValueError: *path* is not a ``section.key`` pair.
40 """
41 section, _, key = path.partition(".")
42 if not section or not key or "." in key:
43 raise ValueError(f"section_field path must be 'section.key', got {path!r}")
44 return pydantic.Field(
45 default,
46 json_schema_extra={_SECTION_METADATA_KEY: [section, key]},
47 **constraints,
48 )
49
50
51def section_path(field: pydantic.fields.FieldInfo) -> tuple[str, str]:
52 """Return the ``(section, key)`` a :func:`section_field` field was declared with.
53
54 Args:
55 field: The ``pydantic.fields.FieldInfo`` of a flat config field.
56
57 Returns:
58 The section name and the key inside it.
59
60 Raises:
61 ValueError: The field was not declared with :func:`section_field`.
62 """
63 extra = field.json_schema_extra
64 path = extra.get(_SECTION_METADATA_KEY) if isinstance(extra, dict) else None
65 if not isinstance(path, list) or len(path) != 2:
66 raise ValueError("config field was not declared with section_field()")
67 return str(path[0]), str(path[1])
0
Importance #13: src/iolabs_point_cloud_detection_verticalsigns/_model_conic.py @@ -0,0 +1,122 @@
1"""The colour-free conic gate and the conifer rule that rides on it.
2
3One slice of the flat ``DetectorConfig``. Every field declares, via
4``section_field``, the ``verticalsigns.default.json`` section and key it is
5loaded from; ``_config`` recombines the slices into the model.
6"""
7
8from iolabs.common import config_loader
9
10from ._model_base import section_field
11
12
13class VerticalSignsConicFields(config_loader.ConfigModel):
14 """The colour-free conic gate and the conifer rule that rides on it.
15
16 Metres unless stated otherwise.
17 """
18
19 # Colour-free conic gate (AI3D-339): an OR-bypass around the vegetation RF
20 # for conifers. The RF cannot pass them (its positives contained none, and
21 # crown_isotropy is information-free for cone-vs-pole), so a rule is the
22 # only path that surfaces them. TWO-CUE by design -- shape AND surface
23 # texture -- because a single cue family cannot separate foliage from a
24 # mast. SHIPS OFF; thresholds below are unvalidated seeds pending the
25 # real-distribution dump, and emissions are tagged reason="conic_rule".
26 conic_gate_enabled: bool = section_field("conic_gate.enabled", False)
27 conic_taper_slope_max: float = section_field("conic_gate.taper_slope_max", -0.4)
28 # The taper must survive dropping any single decile. Measured on real
29 # A4_5 data, every cluster that faked a cone had its whole slope carried
30 # by one decile -- a ground skirt at the base or one twig at the top.
31 conic_taper_slope_robust_max: float = section_field("conic_gate.taper_slope_robust_max", -0.3)
32 conic_apex_deg_min: float = section_field("conic_gate.apex_deg_min", 5.0)
33 conic_apex_deg_max: float = section_field("conic_gate.apex_deg_max", 35.0)
34 conic_h_over_width_min: float = section_field("conic_gate.h_over_width_min", 1.5)
35 conic_h_over_width_max: float = section_field("conic_gate.h_over_width_max", 12.0)
36 # Texture conjunct: foliage is scattering-rough, a pole/mast is smooth.
37 # Reads the EXISTING eigenfeature fields. Disable to A/B the shape cue
38 # alone during diagnostics; it is on whenever the gate itself is on.
39 conic_texture_cue_enabled: bool = section_field("conic_gate.texture_cue_enabled", True)
40 conic_change_of_curvature_min: float = section_field("conic_gate.change_of_curvature_min", 0.06)
41 conic_omnivariance_min: float = section_field("conic_gate.omnivariance_min", 0.10)
42 conic_max_hi_intensity_fraction: float = section_field(
43 "conic_gate.max_hi_intensity_fraction", 0.2
44 )
45 conic_h_max_min_m: float = section_field("conic_gate.h_max_min_m", 2.5)
46 conic_max_on_road_fraction: float = section_field("conic_gate.max_on_road_fraction", 0.6)
47 # Abstention guard -- an occlusion-starved radius profile must not be
48 # allowed to fake a conifer's taper.
49 conic_min_decile_fill_fraction: float = section_field(
50 "conic_gate.min_decile_fill_fraction", 0.8
51 )
52 # Minimum crown footprint. A taper says how the radius CHANGES with height
53 # but says nothing about absolute size, so a 0.34 x 0.18 m post 3 m tall
54 # satisfies every shape test while being far too thin to be a crown.
55 # Calibrated on the 143-segment A4_5 sweep: the three thinnest conic
56 # emissions (0.061 / 0.177 / 0.256 m2) were independently judged posts or
57 # bare stems in visual review, while 47 of the 51 clusters the trained
58 # vegetation RF accepted sit above 0.5 m2.
59 conic_min_crown_area_m2: float = section_field("conic_gate.min_crown_area_m2", 0.3)
60
61 # --- conifer rule (AI3D-339) -------------------------------------------
62 # A SECOND, independent bypass. The conic rule above selects for foliage
63 # reaching the ground -- shrub mounds, hedge banks -- because it fits the
64 # taper over the whole cluster. A conifer carrying its crown above a bare
65 # trunk has the opposite profile and is structurally rejected there. This
66 # rule reads the crown-relative fields instead, so it can accept one.
67 #
68 # These thresholds are MORPHOLOGICAL PRIORS, not fitted values: the corpus
69 # contains a single visually-confirmed clean conifer, which is far too few
70 # to calibrate against without overfitting. They are deliberately loose,
71 # to be narrowed once emissions have been reviewed.
72 conifer_rule_enabled: bool = section_field("conifer_rule.enabled", False)
73 # THE DISCRIMINATOR, and it is not a shape term. Thirteen candidates were
74 # rendered as 360-degree orbits and labelled by three independent blind
75 # judges; no shape feature separated the five confirmed conifers from the
76 # six confirmed non-conifers (stem_ratio: conifers 0.46-2.08, others
77 # 0.96-1.64 -- fully overlapping). Every judge instead gave the same
78 # reason, "densely filled" versus "see-through twiggy", and a density
79 # BAND separates the labelled set perfectly:
80 #
81 # conifers 154 191 208 278 332
82 # leaf-off 98 116 130 (bare April twigs return little)
83 # hedge/thicket 679 745 853 (a solid mass, not a tree)
84 #
85 # Physically: a conifer is dense foliage on an OPEN branching tree, so it
86 # sits between bare deciduous and a solid hedge. Unlike the shape terms
87 # these bounds ARE fitted -- to 11 labels, which is few -- so they are set
88 # at the midpoints of the observed gaps to maximise margin, and both
89 # contested candidates fall outside the band.
90 conifer_min_volumetric_density: float = section_field(
91 "conifer_rule.min_volumetric_density", 140.0
92 )
93 conifer_max_volumetric_density: float = section_field(
94 "conifer_rule.max_volumetric_density", 380.0
95 )
96 # Shape sanity only; NOT the discriminator (see above). Kept loose enough
97 # to admit every confirmed conifer, including merged pairs whose base is
98 # widened by the neighbour they were clustered with.
99 conifer_max_stem_ratio: float = section_field("conifer_rule.max_stem_ratio", 2.2)
100 # A point at the top rather than a flat or broadening crown.
101 conifer_max_apex_ratio: float = section_field("conifer_rule.max_apex_ratio", 0.75)
102 # The crown limb must actually taper.
103 conifer_max_crown_taper: float = section_field("conifer_rule.max_crown_taper", -0.10)
104 # The crown must sit low enough to be a cone, not a mushroom.
105 conifer_max_crown_base_frac: float = section_field("conifer_rule.max_crown_base_frac", 0.55)
106 # Slenderness of the whole object: a spire, not a bush and not a mast.
107 conifer_h_over_width_min: float = section_field("conifer_rule.h_over_width_min", 2.0)
108 conifer_h_over_width_max: float = section_field("conifer_rule.h_over_width_max", 15.0)
109 conifer_h_max_min_m: float = section_field("conifer_rule.h_max_min_m", 2.0)
110 # Foliage is scattering-rough; a pole or a fence face is smooth.
111 conifer_min_change_of_curvature: float = section_field(
112 "conifer_rule.min_change_of_curvature", 0.04
113 )
114 # Not retroreflective, not over the carriageway, not starved of deciles.
115 conifer_max_hi_intensity_fraction: float = section_field(
116 "conifer_rule.max_hi_intensity_fraction", 0.2
117 )
118 conifer_max_on_road_fraction: float = section_field("conifer_rule.max_on_road_fraction", 0.6)
119 conifer_min_decile_fill_fraction: float = section_field(
120 "conifer_rule.min_decile_fill_fraction", 0.8
121 )
122 conifer_min_crown_area_m2: float = section_field("conifer_rule.min_crown_area_m2", 0.2)
0
Importance #14: src/iolabs_point_cloud_detection_verticalsigns/_model_corridor.py @@ -0,0 +1,121 @@
1"""Road corridor rasterization and on-carriageway rejection.
2
3Also plate planarity, the bright-panel class and the free-space ring.
4
5One slice of the flat ``DetectorConfig``. Every field declares, via
6``section_field``, the ``verticalsigns.default.json`` section and key it is
7loaded from; ``_config`` recombines the slices into the model.
8"""
9
10from iolabs.common import config_loader
11
12from ._model_base import section_field
13
14
15class VerticalSignsCorridorFields(config_loader.ConfigModel):
16 """Road corridor rasterization and on-carriageway rejection.
17
18 Also plate planarity, the bright-panel class and the free-space ring.
19
20 Metres unless stated otherwise.
21 """
22
23 # Road corridor (rasterized on the ground-grid geometry).
24 max_dist_to_road_m: float = section_field("corridor.max_dist_to_road_m", 10.0)
25 on_carriageway_dist_m: float = section_field("corridor.on_carriageway_dist_m", 0.25)
26 on_carriageway_exempt_h_max_m: float = section_field(
27 "corridor.on_carriageway_exempt_h_max_m", 4.5
28 )
29 # Carriageway isolation: run4 over-extends the fitted road plane onto verge /
30 # field-track areas with a sparse point density (segment 000). Keep only
31 # cells whose run4 count clears a segment-adaptive density floor
32 # (max of an absolute floor and a fraction of the p95 cell count), then keep
33 # the connected component(s) covering the main carriageway.
34 corridor_density_min_points: float = section_field("corridor.density_min_points", 8.0)
35 corridor_density_frac_p95: float = section_field("corridor.density_frac_p95", 0.06)
36 # Cap on the p95-scaled density floor. On heavily-overscanned segments the
37 # main carriageway core is sampled by many overlapping run4 passes, so its
38 # p95 cell count balloons (segment 134: p95~8100 โ†’ floor 487) and the floor
39 # over-drops legitimately-paved but less-densely-scanned branch roads / gore
40 # aprons / ramps (134's apron cells hold ~170-210 returns). The cap keeps the
41 # floor at a road-vs-extrapolation boundary (~150) regardless of how dense the
42 # core is. It only lowers the floor where density_frac_p95*p95 exceeds it, so
43 # genuinely sparse segments (000's vineyard field track, floor 152, field
44 # cells <150) are unchanged and their extrapolated planes stay dropped.
45 corridor_density_max_points: float = section_field("corridor.density_max_points", 150.0)
46 corridor_component_min_area_frac: float = section_field(
47 "corridor.component_min_area_frac", 0.15
48 )
49 # A dense run4 component is kept when it is either a decent fraction of the
50 # largest (component_min_area_frac) OR clears an absolute cell-area floor. A
51 # branch road / apron forms its own component disconnected from the main
52 # carriageway across the curb gap; on a long junction tile it is far smaller
53 # than the through-road, so the fractional test alone drops it. run4 holds
54 # road-surface points only, so a dense component of this size is road.
55 corridor_component_min_area_cells: int = section_field("corridor.component_min_area_cells", 40)
56 # On-carriageway rejection: a cluster whose footprint sits (almost) entirely
57 # over genuine road cells is a vehicle / on-road object, rejected for every
58 # class except tall gantry legs (h_max >= on_carriageway_exempt_h_max_m).
59 # Edge delineators keep a mixed footprint and stay below this fraction.
60 on_carriageway_road_fraction: float = section_field(
61 "corridor.on_carriageway_road_fraction", 0.7
62 )
63 # An on-carriageway cluster is only kept if it is a genuine marker: either
64 # volumetrically dense (a static post/plate packs points) or brightly
65 # retroreflective (a wide guide panel overhanging the edge, segment 006).
66 # A dull, sparse blob on the carriageway is a vehicle / debris smear.
67 min_volumetric_density: float = section_field("classification.min_volumetric_density", 8000.0)
68 on_carriageway_bright_frac: float = section_field("corridor.on_carriageway_bright_frac", 0.5)
69 # Delineator-shape exemption from on-carriageway rejection. The corridor
70 # density cap can extend the kept road mask onto paved shoulders / medians,
71 # so genuine edge delineators end up sitting (almost) entirely over road
72 # cells and get swept up by the on-carriageway rejection (segments 076, 123).
73 # A moving-vehicle smear is never a sub-delineator-height, sub-0.65 m,
74 # near-perfectly-vertical retroreflective column, so a cluster matching that
75 # delineator signature is exempt and allowed to reach the delineator gates.
76 # The len_major cap (0.65 m) sits below the 114/130 vehicle-smear footprints
77 # (1.25 x 0.66 / 1.28 x 0.77), so those FPs stay rejected.
78 on_carriageway_delineator_max_len_major_m: float = section_field(
79 "corridor.on_carriageway_delineator_max_len_major_m", 0.65
80 )
81 on_carriageway_delineator_min_verticality: float = section_field(
82 "corridor.on_carriageway_delineator_min_verticality", 0.95
83 )
84
85 # Plate planarity: a real sign plate is a thin slab, so the smallest 3D
86 # covariance eigenvalue of its upper-half points (plate_thickness_m) is small.
87 # Vegetation clumps are volumetric and thick. Gate the sign class on it.
88 sign_max_plate_thickness_m: float = section_field("sign_post.max_plate_thickness_m", 0.15)
89
90 # Bright panel (segment 114): a real chevron/warning panel (Richtungstafel)
91 # can sit below the sign_post_h_min_m post-height floor (a low roadside
92 # panel, not a tall post-mounted plate). It is still a thin, bright, planar
93 # slab of plausible plate width, so gate it on brightness, thinness, height,
94 # width and vertical continuity directly rather than routing it through the
95 # post logic.
96 panel_min_hi: float = section_field("panel.min_hi", 0.40)
97 panel_max_thickness_m: float = section_field("panel.max_thickness_m", 0.20)
98 panel_h_min_m: float = section_field("panel.h_min_m", 0.9)
99 # A genuine chevron panel is a WIDE board (segment 114's reads 2.95 m).
100 # The 1.5 m floor keeps narrow bright low posts/plates (segment 134's
101 # 1.25 m roadside marker) out of the panel class.
102 panel_len_major_min_m: float = section_field("panel.len_major_min_m", 1.5)
103 panel_len_major_max_m: float = section_field("panel.len_major_max_m", 5.0)
104
105 # Free-space ring: real plate-less posts (sign_post/pole_other/delineator)
106 # stand clear, so a cylindrical ring around the cluster axis holds few
107 # non-cluster candidate points. Bush interiors, saplings and forest trunks
108 # sit inside filled rings. Also reject a plate-less candidate embedded in a
109 # forest context (several tall neighbouring clusters nearby).
110 ring_r_inner_m: float = section_field("context.ring_r_inner_m", 0.5)
111 ring_r_outer_m: float = section_field("context.ring_r_outer_m", 1.5)
112 ring_h_min_m: float = section_field("context.ring_h_min_m", 0.5)
113 ring_h_max_m: float = section_field("context.ring_h_max_m", 2.5)
114 # Ring fill measured as the ratio of non-cluster ring points to the cluster's
115 # own point count; a sapling/trunk embedded in foliage has a ring several
116 # times denser than itself, a real clear-standing post has a near-empty ring.
117 ring_max_fill_ratio: float = section_field("context.ring_max_fill_ratio", 2.0)
118 ring_min_points: int = section_field("context.ring_min_points", 40)
119 forest_min_neighbors: int = section_field("context.forest_min_neighbors", 3)
120 forest_radius_m: float = section_field("context.forest_radius_m", 8.0)
121 forest_neighbor_min_h_max_m: float = section_field("context.forest_neighbor_min_h_max_m", 2.0)
0
Importance #15: src/iolabs_point_cloud_detection_verticalsigns/_model_devices.py @@ -1,140 +1,158 @@
1"""Per-device acceptance gates and the two probe stages.1"""Per-device thresholds for delineators, sign posts and gantries.
22
3One slice of the nested :class:`VerticalSignsConfig` model tree; the sections3Also isolated-floating-pole rejection and duplicate suppression.
4mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines4
5the slices.5One slice of the flat ``DetectorConfig``. Every field declares, via
6``section_field``, the ``verticalsigns.default.json`` section and key it is
7loaded from; ``_config`` recombines the slices into the model.
6"""8"""
79
8from iolabs.common import config_loader10from iolabs.common import config_loader
911
1012from ._model_base import section_field
11class DelineatorConfig(config_loader.ConfigModel):13
12 """Delineator (Leitpfosten) acceptance gates."""14
1315class VerticalSignsDeviceFields(config_loader.ConfigModel):
14 h_min_m: float = 0.716 """Per-device thresholds for delineators, sign posts and gantries.
15 h_max_m: float = 1.517
16 max_footprint_m: float = 0.4518 Also isolated-floating-pole rejection and duplicate suppression.
17 relaxed_footprint_m: float = 0.8519
18 relaxed_min_verticality: float = 0.8520 Metres unless stated otherwise.
19 relaxed_max_ring_fill_ratio: float = 1.021 """
20 relaxed_min_hi_intensity_fraction: float = 0.1522
21 min_hi_intensity_fraction: float = 0.0823 # Delineator (Leitpfosten). The height ceiling (1.5 m) and footprint cap
22 min_points: int = 30024 # (0.45 m) admit taller guide posts and the mild along-track smear that gore
2325 # posts pick up in MLS (segment 131's junction posts read 0.42 m major,
2426 # h 1.2-1.5); real Leitpfosten cores stay ~0.12 m so the cap change does not
25class SignPostConfig(config_loader.ConfigModel):27 # widen the class into vehicles/vegetation.
26 """Sign-post and plate acceptance gates."""28 delineator_h_min_m: float = section_field("delineator.h_min_m", 0.7)
2729 delineator_h_max_m: float = section_field("delineator.h_max_m", 1.5)
28 max_len_minor_m: float = 0.830 delineator_max_footprint_m: float = section_field("delineator.max_footprint_m", 0.45)
29 h_min_m: float = 1.531 # Relaxed footprint band for a delineator whose along-track MLS smear at a
30 h_max_m: float = 6.032 # junction/gore pushes its major extent past the tight 0.45 m cap (segment
31 min_continuity: float = 0.633 # 134's splitter-island posts read 0.47-0.63 m major). Only admitted when the
32 plate_hi_intensity_fraction: float = 0.434 # cluster is strongly vertical (a genuine post), so a flat bright road-marking
33 plate_hi_intensity_fraction_weak: float = 0.335 # fragment (verticality ~0.1) can never sneak in through the wider cap. Purely
34 plate_upper_surplus_ratio: float = 2.036 # additive: clusters at or under delineator_max_footprint_m keep the original
35 min_upper_half_surplus: float = 0.337 # (verticality-free) path, so no existing detection is affected.
36 plate_min_core_rms_m: float = 0.138 # 0.65 -> 0.85 (AI3D-339 pass 3): Abschnitt-1 Leitpfosten merge with verge
37 max_plate_thickness_m: float = 0.1539 # grass into 0.67-0.83 m clusters that keep verticality ~0.99; the 0.65 cap
38 bare_post_min_h_max_m: float = 4.540 # was the single failing conjunct for 8 adversarially judged-real posts.
39 bare_post_max_core_rms_m: float = 0.06541 # At 0.85: A4_5 +3 judged-real delineators / 0 lost; A1 +~18 judged-real vs
40 bare_post_min_verticality: float = 0.942 # +5 judged-veg. Real (0.66-0.83) and FP (0.68-0.85) footprints fully
41 bare_post_min_points: int = 45043 # overlap, so no tighter cap separates them โ€” the veg leak is a texture
4244 # problem (multi-radius plate regularity, task #14), not a threshold one.
4345 delineator_relaxed_footprint_m: float = section_field("delineator.relaxed_footprint_m", 0.85)
44class PanelConfig(config_loader.ConfigModel):46 delineator_relaxed_min_verticality: float = section_field(
45 """Large panel acceptance gates."""47 "delineator.relaxed_min_verticality", 0.85
4648 )
47 min_hi: float = 0.449 # The wider relaxed band admits more smear, so it is guarded harder than the
48 max_thickness_m: float = 0.250 # compact path: the post must stand clear (a near-empty free-space ring, so a
49 h_min_m: float = 0.951 # bright speck embedded in roadside vegetation โ€” segment 084 โ€” is rejected)
50 len_major_min_m: float = 1.552 # and be clearly retroreflective (a higher brightness floor than the compact
51 len_major_max_m: float = 5.053 # 0.08, so a modest-brightness on-carriageway edge feature โ€” segment 096 โ€” is
5254 # rejected). Genuine gore/island posts pass both (ring ~0, hi 0.28-0.66).
5355 delineator_relaxed_max_ring_fill_ratio: float = section_field(
54class GantryConfig(config_loader.ConfigModel):56 "delineator.relaxed_max_ring_fill_ratio", 1.0
55 """Gantry leg and pairing gates."""57 )
5658 delineator_relaxed_min_hi_intensity_fraction: float = section_field(
57 h_min_m: float = 4.559 "delineator.relaxed_min_hi_intensity_fraction", 0.15
58 len_major_m: float = 8.060 )
59 max_len_minor_m: float = 6.061 delineator_min_hi_intensity_fraction: float = section_field(
60 pair_station_tolerance_m: float = 5.062 "delineator.min_hi_intensity_fraction", 0.08
61 pair_min_separation_m: float = 3.063 )
62 overhead_h_min_m: float = 4.564 # Real Leitpfosten return a few hundred points; sub-~300 bright specks are
63 pair_isolation_radius_m: float = 8.065 # reflective vegetation/debris (segment 048 FP had ~100; segment 084's bright
6466 # speck embedded in verge scrub, newly reachable once the corridor keeps
6567 # branch roads, had 239). Every genuine delineator across the dataset returns
66class RepetitiveRowConfig(config_loader.ConfigModel):68 # >=371, so the 300 floor drops those specks with margin to spare.
67 """Repetitive-row (guardrail post series) grouping."""69 delineator_min_points: int = section_field("delineator.min_points", 300)
6870
69 min_members: int = 471 # Sign post / plate
70 max_spacing_m: float = 5.072 sign_post_max_len_minor_m: float = section_field("sign_post.max_len_minor_m", 0.8)
71 max_perp_spread_m: float = 1.573 sign_post_h_min_m: float = section_field("sign_post.h_min_m", 1.5)
72 max_h_max_range_m: float = 0.774 sign_post_h_max_m: float = section_field("sign_post.h_max_m", 6.0)
73 member_max_len_major_m: float = 2.075 sign_post_min_continuity: float = section_field("sign_post.min_continuity", 0.60)
74 member_max_len_minor_m: float = 0.876 # Plate evidence needs strong retroreflectivity: verified real sign plates
7577 # (segments 006/030/132/134) return an upper-half high-intensity fraction of
7678 # 0.44-0.94, while every dull false-positive "sign" (vegetation mounds,
77class FieldStakeConfig(config_loader.ConfigModel):79 # crash-cushion / truck-rear slabs, forest trunks, vegetation bands) sits at
78 """Field-stake row emission gates."""80 # <=0.35. The gate is set at 0.40 so plate evidence requires a genuine bright
7981 # panel; the weak path allows a moderately-bright, upper-piled plate.
80 row_emit: bool = True82 plate_hi_intensity_fraction: float = section_field(
81 min_members: int = 483 "sign_post.plate_hi_intensity_fraction", 0.40
82 min_spacing_m: float = 2.084 )
83 max_spacing_m: float = 10.085 plate_hi_intensity_fraction_weak: float = section_field(
84 max_spacing_cv: float = 0.3586 "sign_post.plate_hi_intensity_fraction_weak", 0.30
8587 )
8688 # Upper-half point pile-up ratio required as weak-plate evidence and as
87class MarkerExtractConfig(config_loader.ConfigModel):89 # plate *shape*. Raised to 2.0 so a mere ~1.7 surplus (roadside bush crowns,
88 """Bright marker extraction from rejected clusters."""90 # segment 048 FPs) no longer counts as a plate; real plates pile far more
8991 # returns up high (good signs sit at 2.8-4.6, or carry a broad bright core).
90 min_len_major_m: float = 6.092 sign_plate_upper_surplus_ratio: float = section_field(
91 bright_h_min_m: float = 1.593 "sign_post.plate_upper_surplus_ratio", 2.0
92 min_bright_points: int = 40094 )
93 window_m: float = 2.595 # A genuine plate sits high on its post, so the upper half must hold at least
94 min_bright_fraction: float = 0.4596 # as many returns as ~1/3 of the lower half. Low-lying bright blobs at the
95 min_h_max_m: float = 1.697 # foot of a vehicle/truck (segment 106 FPs at ~0.09) are not plates.
96 min_vertical_span_m: float = 0.598 sign_min_upper_half_surplus: float = section_field("sign_post.min_upper_half_surplus", 0.30)
9799 # A real sign PLATE spreads returns laterally (broad core) or piles them in
98100 # the upper half; brightness alone on a tight thin core is a reflective
99class RailHalfpostConfig(config_loader.ConfigModel):101 # post/speck, not a plate โ€” route it to the (stricter) bare-post path.
100 """Guardrail half-post probe stage."""102 plate_min_core_rms_m: float = section_field("sign_post.plate_min_core_rms_m", 0.10)
101103
102 band_lat_m: float = 0.8104 # Bare posts (no plate evidence) must be tall, tight, vertical, and
103 band_z_hi_m: float = 1.5105 # well-sampled. 0.065 m tightness rejects tall roadside vegetation (whose
104 band_z_lo_m: float = 0.15106 # per-bin core reaches ~0.17 m); real marker posts sit near ~0.04 m. The
105 cluster_cell_m: float = 0.15107 # point-count floor rejects small bright reflective specks (~<450 returns).
106 dedupe_m: float = 1.5108 # Plate-less posts below gantry-leg height are indistinguishable from tree
107 enabled: bool = False109 # guards / fence posts by LiDAR geometry alone (confirmed FP in seg 132).
108 ground_cell_m: float = 2.0110 bare_post_min_h_max_m: float = section_field("sign_post.bare_post_min_h_max_m", 4.5)
109 ground_percentile: float = 10.0111 bare_post_max_core_rms_m: float = section_field("sign_post.bare_post_max_core_rms_m", 0.065)
110 h_max_m: float = 0.8112 bare_post_min_verticality: float = section_field("sign_post.bare_post_min_verticality", 0.90)
111 h_min_m: float = 0.2113 bare_post_min_points: int = section_field("sign_post.bare_post_min_points", 450)
112 max_lateral_m: float = 0.5114
113 max_width_m: float = 0.2115 # Isolated floating-pole rejection (far-range boundary ghost, defect class 1a).
114 min_emit_points: int = 8116 # A "floating" pole_other whose base sits well off the ground (h_min high โ€” no
115 min_points: int = 15117 # ground-connected shaft, just an upper vertical smear) is a range-smear
116 min_z_extent_m: float = 0.1118 # artifact at the far edge of dense coverage (segments 005, 015: a lone
117 models_dir: str = ""119 # ~10 m column floating over the carriageway vanishing point) UNLESS it is one
118 prime_min_records: int = 2120 # of several such columns clustered together (a genuine gantry-leg / mast group
119 prime_min_sat: int = 1121 # โ€” segments 046, 066, 025). Verified across the full sweep: the only isolated
120 sample_step_m: float = 0.1122 # floating poles (no floating-pole neighbour within pole_isolated_radius_m) are
121 saturation_intensity: float = 55000.0123 # exactly the 005/015 ghosts; every real gantry-leg pole has >=1 neighbour.
122124 pole_floating_min_h_min_m: float = section_field(
123125 "classification.pole_floating_min_h_min_m", 3.5
124class RejectRescueConfig(config_loader.ConfigModel):126 )
125 """Reject-rescue stage gates."""127 pole_isolated_radius_m: float = section_field("classification.pole_isolated_radius_m", 8.0)
126128
127 accepted_exclusion_m: float = 2.0129 # Post-classification duplicate suppression (defect class 4). Two detections
128 enabled: bool = False130 # within dedup_radius_m XY of each other describe the same physical marker
129 h_max_m: float = 1.6131 # (e.g. a striped gore post firing both a delineator and a sign); keep the
130 h_min_m: float = 0.85132 # higher-priority type (sign > delineator > sign_post > pole_other >
131 max_core_rms_m: float = 0.2133 # gantry_or_gate), breaking ties by point count, and drop the other.
132 merge_radius_m: float = 1.0134 dedup_radius_m: float = section_field("classification.dedup_radius_m", 0.8)
133 min_continuity: float = 0.8135
134 min_decile_fill: float = 0.6136 # Gantry / gate
135 min_h_over_width: float = 1.4137 gantry_h_min_m: float = section_field("gantry.h_min_m", 4.5)
136 min_points: int = 30138 gantry_len_major_m: float = section_field("gantry.len_major_m", 8.0)
137 min_records: int = 2139 # A road-spanning overhead beam is thin; a tilted reflective truck-trailer
138 min_roadctx_sat: int = 17140 # slab (segment 106) is broad (len_minor ~9.8 m). Cap the single-cluster
139 min_verticality: float = 0.9141 # overhead_span footprint minor extent (real gantry cluster ~4.75 m).
140 per_segment_cap: int = 0142 gantry_max_len_minor_m: float = section_field("gantry.max_len_minor_m", 6.0)
143 gantry_pair_station_tolerance_m: float = section_field("gantry.pair_station_tolerance_m", 5.0)
144 # Narrow overhead gates (segment 066: two ~10 m retroreflective legs ~3.7 m
145 # apart straddling a ramp) must still pair, so the minimum lateral
146 # separation is 3.0 m; the overhead-return test guards against false pairs.
147 gantry_pair_min_separation_m: float = section_field("gantry.pair_min_separation_m", 3.0)
148 gantry_overhead_h_min_m: float = section_field("gantry.overhead_h_min_m", 4.5)
149 # A synthesized gantry from a pair of tall posts is only trustworthy when the
150 # pair is ISOLATED โ€” no third tall post nearby. Two ~10 m legs straddling a
151 # ramp with nothing between them is a real gate (segment 066); three-or-more
152 # tall columns clustered at one station are a post row / mast group whose
153 # pairwise "span" crosses empty air (segments 046, 025 โ€” the QC ghosts). If a
154 # third tall post lies within this radius of the pair midpoint, the pairing is
155 # rejected. (The overhead middle-of-span test cannot separate these โ€” verified
156 # from points: 066's real gate also has an empty mid-span, so post COUNT, not
157 # overhead support, is the discriminator.)
158 gantry_pair_isolation_radius_m: float = section_field("gantry.pair_isolation_radius_m", 8.0)
Importance #16: src/iolabs_point_cloud_detection_verticalsigns/_model_evidence.py @@ -0,0 +1,186 @@
1"""Evidence-level thresholds: sentinels, vetoes and reference percentiles.
2
3Covers the verticality sentinel, tier-2 robust extent statistics,
4retroreflectivity references, the single-record transient and
5vegetation-texture vetoes, the delineator lattice and tree emission.
6
7One slice of the flat ``DetectorConfig``. Every field declares, via
8``section_field``, the ``verticalsigns.default.json`` section and key it is
9loaded from; ``_config`` recombines the slices into the model.
10"""
11
12from iolabs.common import config_loader
13
14from ._model_base import section_field
15
16
17class VerticalSignsEvidenceFields(config_loader.ConfigModel):
18 """Evidence-level thresholds: sentinels, vetoes and reference percentiles.
19
20 Covers the verticality sentinel, tier-2 robust extent statistics,
21 retroreflectivity references, the single-record transient and
22 vegetation-texture vetoes, the delineator lattice and tree emission.
23
24 Metres unless stated otherwise.
25 """
26
27 # Verticality sentinel fix (F1, AI3D-339 pass 10). features.py::_verticality
28 # used to return a hard 0.0 for any cluster with len_minor > 0.8 m, which
29 # every verticality-reading acceptance gate then read as "measured
30 # horizontal". 64.5% of A1 fused clusters were hit and 81% of the
31 # unclassified rejects were caused by it; see p10_veto_rootcause.md ยง2 (H2)
32 # and p10_f2_disposition.md (F1: SHIP, 2/2 judge-confirmed recoveries,
33 # measured FP exposure 1 cluster in 21 623). ON by default โ€” the panel
34 # pre-cleared this one. False is the kill-switch: byte-identical to the
35 # pre-fix detector.
36 verticality_sentinel_fix: bool = section_field("classification.verticality_sentinel_fix", True)
37
38 # Tier-2 robust extent statistics (AI3D-339 pass 10). h_max, len_major and
39 # len_minor are sample EXTREMA, monotone non-decreasing in the number of
40 # points, and every acceptance window bounds them from above โ€” so fusing
41 # more records into a cluster can only push a device out of its window.
42 # That is the FUSED-RUN VETO (p10_veto_rootcause.md ยง0). Turning this on
43 # makes the delineator height band, the two delineator footprint windows
44 # and the sign_post slender test read density-invariant twins (an upper
45 # height quantile, p1-p99 projection ranges) instead. It WIDENS NO WINDOW:
46 # the constants were calibrated on typical fused clusters and a robust
47 # statistic pulls the outlier-driven cases back toward typical, so the FP
48 # surface cannot grow. Measured on the reserve burn: 4 real / 0 FP as the
49 # sole attributed component (p10_burn_report.md), so it ships ON per the
50 # pass-10 terminal panel directive (p10_panel_verdict.md closing item 1).
51 # The twin columns are computed and written to clusters.csv either way.
52 robust_extent_stats: bool = section_field("classification.robust_extent_stats", True)
53 robust_h_max_percentile: float = section_field(
54 "classification.robust_h_max_percentile", 98.0, ge=0.0, le=100.0
55 )
56 robust_extent_lo_percentile: float = section_field(
57 "classification.robust_extent_lo_percentile", 1.0, ge=0.0, le=100.0
58 )
59 robust_extent_hi_percentile: float = section_field(
60 "classification.robust_extent_hi_percentile", 99.0, ge=0.0, le=100.0
61 )
62
63 # Absolute retroreflectivity reference: high percentile of the ALL-points
64 # intensity histogram (a stable, non-degenerate reference โ€” unlike the old
65 # p98-of-candidates, which collapsed when a segment had no bright object).
66 # p99.5 lands at near-saturated lane paint, above the delineator reflectors
67 # (~p95-p98 on this sensor), so it is set at p98 to keep retroreflective
68 # markers separable from diffuse vegetation (bush fraction stays ~0.00).
69 hi_intensity_all_points_percentile: float = section_field(
70 "classification.hi_intensity_all_points_percentile", 98.0, ge=0.0, le=100.0
71 )
72
73 # Bright-SEED percentile split (AI3D-339 pass 2). The p98 reference above
74 # is self-referential for seeding: one bright guide panel can push p98
75 # above a weakly sampled Leitpfosten head, so whole 50 m post lattices
76 # never seed (adversarially judged: 37 real objects recovered at p95 on
77 # A4_5). This percentile feeds ONLY the seed pass's bright_counts;
78 # hi_intensity_fraction (a frozen RF-verifier input) and every
79 # classification brightness floor stay on the p98 reference above.
80 # None inherits hi_intensity_all_points_percentile (byte-identical to the
81 # pre-split detector). Default 95 after the A4_5 census + adversarial
82 # judging: +29 judged-real delineators, +3 sub-noise FPs, and the 4 sign
83 # losses were each visually confirmed FPs (ghost, vegetation, smear,
84 # gore paint).
85 seed_bright_percentile: float | None = section_field(
86 "classification.seed_bright_percentile", 95.0
87 )
88
89 # Single-record transient veto (AI3D-339 pass 3). A moving vehicle exists in
90 # exactly one driving pass, so its cluster has n_records_present == 1 โ€”
91 # while 95% of accepted delineators (static roadside inventory) are seen by
92 # 2+ records. Visual audit of all 12 accepted A4_5 signs found 6 moving
93 # vehicles (trucks/cars caught by the bright_panel / embedded_bright_marker
94 # rules): every one single-record, panel-like (verticality <= 0.07), 2.9 m+
95 # long and under 2.0 m tall. Every judged-real sign was either multi-record
96 # or post-vertical (the s134 gore beacon: nrec=1 but verticality 0.9999), so
97 # the conjunction below has wide margins on both sides. h_max cap protects
98 # large genuine panels; verticality cap protects post-mounted plates.
99 # False restores the byte-identical pre-veto detector.
100 single_record_transient_veto: bool = section_field(
101 "classification.single_record_transient_veto", True
102 )
103 transient_max_verticality: float = section_field(
104 "classification.transient_max_verticality", 0.3
105 )
106 transient_min_len_major_m: float = section_field(
107 "classification.transient_min_len_major_m", 2.0
108 )
109 transient_max_h_max_m: float = section_field("classification.transient_max_h_max_m", 2.5)
110
111 # Vegetation-texture veto (AI3D-339 pass 4). The pass-3 footprint
112 # relaxation and A1 veto-off admitted 9 adversarially judged vegetation
113 # FPs (scrub bands, retroreflective tree shelters). Signature: a thick
114 # upper half (plate_thickness_m โ€” a bush or plastic tube is a blob, not a
115 # sheet) AND near-total upper-half brightness at the seed threshold
116 # (hi_intensity_fraction_seed โ€” shelters/bright scrub are uniformly
117 # reflective, while a real marker is bright-head-dark-post or a thin
118 # plate protected by the thickness conjunct). Calibrated on
119 # pipeline-computed values of the 118 judged pass-3 clusters โ€” an earlier
120 # zbin_count_cv conjunct measured on an offline instrument did NOT
121 # transfer to exact cluster points (its separation came from
122 # neighbourhood context) and cost 3 judged reals in the validation
123 # re-run; this pair is derived from the production feature values
124 # themselves. Kills 6/9 accepted veg FPs (both segment-038 shelter
125 # cones, both veg-leaning disputeds, one newly judged shelter trunk in
126 # segment 049) with 0/57 judged reals lost; binding real ag12 (plates on
127 # mast) sits at seed fraction 0.650 vs the 0.668 cut. False restores the
128 # pre-veto detector byte-identically.
129 veg_texture_veto: bool = section_field("classification.veg_texture_veto", True)
130 veg_texture_min_plate_thickness_m: float = section_field(
131 "classification.veg_texture_min_plate_thickness_m", 0.05
132 )
133 veg_texture_min_hi_seed_fraction: float = section_field(
134 "classification.veg_texture_min_hi_seed_fraction", 0.668
135 )
136
137 # Corridor-level delineator-lattice admission (see lattice.py). After all
138 # segments of an invocation are written, accepted delineators seed chain
139 # growth (StVO/HLB row prior: regular spacing, 3-50 m by curvature) over a
140 # strictly gated pool of rejected clusters; pool members phase-locking
141 # into a chain with >= lattice_min_anchors accepted anchors are admitted
142 # as reason "delineator_lattice". Gates were derived on the pass-5 A1
143 # instrument and validated against a position-randomised null: 1-2-anchor
144 # chains are chance at the observed candidate density (their admissions
145 # judged 6/6 vegetation) while >= 4-anchor chains admitted 8 judged-real
146 # posts of 9 candidates; the one vegetation admission had no bright
147 # returns at all, which the hi_seed >= 0.15 + plate <= 0.05 pool gates
148 # remove (every judged-real admission: hi_seed >= 0.18, plate <= 0.04).
149 # h_max window brackets the HLB 1.00 m post. False = no post-pass,
150 # byte-identical outputs.
151 lattice_admission: bool = section_field("classification.lattice_admission", True)
152 lattice_min_anchors: int = section_field("classification.lattice_min_anchors", 4)
153 lattice_snap_m: float = section_field("classification.lattice_snap_m", 3.0)
154 lattice_max_skip: int = section_field("classification.lattice_max_skip", 6)
155 lattice_min_seed_spacing_m: float = section_field(
156 "classification.lattice_min_seed_spacing_m", 15.0
157 )
158 lattice_max_seed_spacing_m: float = section_field(
159 "classification.lattice_max_seed_spacing_m", 60.0
160 )
161 lattice_max_spacing_resid: float = section_field(
162 "classification.lattice_max_spacing_resid", 0.15
163 )
164 lattice_pool_h_max_min_m: float = section_field("classification.lattice_pool_h_max_min_m", 0.8)
165 lattice_pool_h_max_max_m: float = section_field("classification.lattice_pool_h_max_max_m", 1.4)
166 lattice_pool_max_len_major_m: float = section_field(
167 "classification.lattice_pool_max_len_major_m", 1.2
168 )
169 lattice_pool_min_verticality: float = section_field(
170 "classification.lattice_pool_min_verticality", 0.85
171 )
172 lattice_pool_min_points: int = section_field("classification.lattice_pool_min_points", 20)
173 lattice_pool_max_plate_thickness_m: float = section_field(
174 "classification.lattice_pool_max_plate_thickness_m", 0.05
175 )
176 lattice_pool_min_hi_seed_fraction: float = section_field(
177 "classification.lattice_pool_min_hi_seed_fraction", 0.15
178 )
179
180 # Experimental: surface the existing tree-rejection logic as opt-in "tree"
181 # detections instead of silently discarding those clusters. When true,
182 # clusters rejected with reason tree_crown_isotropic, tree_crown_green, or
183 # forest_context are emitted as type "tree" detections (see classify.py's
184 # TREE_REJECT_REASONS) rather than dropped. Off by default so normal runs
185 # are unaffected.
186 emit_trees: bool = section_field("classification.emit_trees", False)
0
Importance #17: src/iolabs_point_cloud_detection_verticalsigns/_model_grid.py @@ -1,152 +1,77 @@
1"""Grid, candidate, classification, radius and corridor config sections.1"""Ground, occupancy grid, candidate band and clustering thresholds.
22
3One slice of the nested :class:`VerticalSignsConfig` model tree; the sections3Also the first classification gates and vehicle rejection.
4mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines4
5the slices.5One slice of the flat ``DetectorConfig``. Every field declares, via
6``section_field``, the ``verticalsigns.default.json`` section and key it is
7loaded from; ``_config`` recombines the slices into the model.
6"""8"""
79
8from iolabs.common import config_loader10from iolabs.common import config_loader
911
1012from ._model_base import section_field
11class GroundConfig(config_loader.ConfigModel):13
12 """Ground-model raster cell size and percentile."""14
1315class VerticalSignsGridFields(config_loader.ConfigModel):
14 cell_m: float = 0.7516 """Ground, occupancy grid, candidate band and clustering thresholds.
15 percentile: float = 8.017
1618 Also the first classification gates and vehicle rejection.
1719
18class OccupancyConfig(config_loader.ConfigModel):20 Metres unless stated otherwise.
19 """Occupancy grid used to find candidate cells."""21 """
2022
21 cell_m: float = 0.1523 # Ground model
2224 ground_cell_m: float = section_field("ground.cell_m", 0.75, gt=0.0)
2325 ground_percentile: float = section_field("ground.percentile", 8.0, ge=0.0, le=100.0)
24class CandidatesConfig(config_loader.ConfigModel):26
25 """Height band and seed-cell gates for candidate points."""27 # Occupancy grid for candidate cells
2628 occupancy_cell_m: float = section_field("occupancy.cell_m", 0.15, gt=0.0)
27 min_height_m: float = 0.329
28 max_height_m: float = 10.030 # Height band for off-ground candidate points
29 seed_min_vertical_span_m: float = 0.831 min_height_m: float = section_field("candidates.min_height_m", 0.30)
30 seed_min_h_max_m: float = 0.932 max_height_m: float = section_field("candidates.max_height_m", 10.0)
31 seed_bright_min_vertical_span_m: float = 0.4533
32 seed_bright_min_h_max_m: float = 0.634 # Seed-cell gates (vertical span and max height above ground)
33 seed_bright_min_points: int = 335 seed_min_vertical_span_m: float = section_field("candidates.seed_min_vertical_span_m", 0.80)
3436 seed_min_h_max_m: float = section_field("candidates.seed_min_h_max_m", 0.90)
3537
36class ClusteringConfig(config_loader.ConfigModel):38 # Delineator recall seed pass. German Leitpfosten are ~1.0 m and, when
37 """DBSCAN clustering of seed-cell centres."""39 # sparsely sampled at range, span only ~0.75 m inside a 0.15 m occupancy
3840 # cell (base clipped by min_height_m=0.30), so they fall just under the
39 eps_m: float = 0.4541 # 0.80 m primary span gate and never seed a cluster โ€” the round-4 recall
40 min_samples: int = 142 # gap. A second, relaxed seed pass recovers them, but is restricted to
41 hull_margin_m: float = 0.243 # cells holding >= seed_bright_min_points retroreflective returns
4244 # (intensity >= the segment's hi-intensity threshold): a Leitpfosten head
4345 # is always retroreflective, so the extra candidate cells stay few and the
44class ClassificationConfig(config_loader.ConfigModel):46 # existing delineator gates + FP defenses (brightness, footprint, density,
45 """Cluster-level accept/reject gates and ML verifier wiring."""47 # corridor, ring/forest) decide the verdict.
4648 seed_bright_min_vertical_span_m: float = section_field(
47 continuity_bin_m: float = 0.2549 "candidates.seed_bright_min_vertical_span_m", 0.45
48 reject_len_major_m: float = 6.050 )
49 reject_h_max_with_large_footprint_m: float = 4.551 seed_bright_min_h_max_m: float = section_field("candidates.seed_bright_min_h_max_m", 0.60)
50 min_continuity: float = 0.552 seed_bright_min_points: int = section_field("candidates.seed_bright_min_points", 3)
51 min_accept_h_max_m: float = 0.953
52 core_rms_bin_m: float = 0.2554 # DBSCAN clustering on seed-cell centres
53 core_rms_h_min_m: float = 0.355 cluster_eps_m: float = section_field("clustering.eps_m", 0.45)
54 core_rms_h_cap_m: float = 3.056 cluster_min_samples: int = section_field("clustering.min_samples", 1)
55 hi_intensity_all_points_percentile: float = 98.057 cluster_hull_margin_m: float = section_field("clustering.hull_margin_m", 0.20)
56 min_volumetric_density: float = 8000.058
57 pole_floating_min_h_min_m: float = 3.559 # Per-cluster feature bins
58 pole_isolated_radius_m: float = 8.060 continuity_bin_m: float = section_field("classification.continuity_bin_m", 0.25)
59 dedup_radius_m: float = 0.861
60 emit_trees: bool = False62 # Classification thresholds
61 ml_verifier_enabled: bool = True63 reject_len_major_m: float = section_field("classification.reject_len_major_m", 6.0)
62 ml_veto_threshold: float = -1.064 reject_h_max_with_large_footprint_m: float = section_field(
63 ml_model_path: str = ""65 "classification.reject_h_max_with_large_footprint_m", 4.5
64 lattice_admission: bool = True66 )
65 lattice_max_seed_spacing_m: float = 60.067 min_continuity: float = section_field("classification.min_continuity", 0.50)
66 lattice_max_skip: int = 668 min_accept_h_max_m: float = section_field("classification.min_accept_h_max_m", 0.90)
67 lattice_max_spacing_resid: float = 0.1569
68 lattice_min_anchors: int = 470 # Vehicle rejection
69 lattice_min_seed_spacing_m: float = 15.071 vehicle_h_min_m: float = section_field("vehicle.h_min_m", 1.5)
70 lattice_pool_h_max_max_m: float = 1.472 vehicle_h_max_m: float = section_field("vehicle.h_max_m", 4.5)
71 lattice_pool_h_max_min_m: float = 0.873 vehicle_len_major_m: float = section_field("vehicle.len_major_m", 2.5)
72 lattice_pool_max_len_major_m: float = 1.274 vehicle_len_minor_m: float = section_field("vehicle.len_minor_m", 1.5)
73 lattice_pool_max_plate_thickness_m: float = 0.0575 vehicle_max_hi_intensity_fraction: float = section_field(
74 lattice_pool_min_hi_seed_fraction: float = 0.1576 "vehicle.max_hi_intensity_fraction", 0.10
75 lattice_pool_min_points: int = 2077 )
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
Importance #18: src/iolabs_point_cloud_detection_verticalsigns/_model_perspective.py @@ -0,0 +1,59 @@
1"""Perspective-projection QC overlay cameras and per-detection QC views.
2
3One slice of the flat ``DetectorConfig``. Every field declares, via
4``section_field``, the ``verticalsigns.default.json`` section and key it is
5loaded from; ``_config`` recombines the slices into the model.
6"""
7
8from iolabs.common import config_loader
9
10from ._model_base import section_field
11
12
13class VerticalSignsPerspectiveFields(config_loader.ConfigModel):
14 """Perspective-projection QC overlay cameras and coverage tolerances.
15
16 Metres unless stated otherwise.
17 """
18
19 # Perspective-projection QC overlay (verticalsigns-perspective). A projected
20 # vertical-line sample is "visible" when its camera-space depth is within
21 # perspective_depth_tol_m of the rendered depth-buffer value; occluded
22 # samples are drawn faint at perspective_occluded_alpha.
23 perspective_depth_tol_m: float = section_field("perspective.depth_tol_m", 0.5)
24 perspective_line_samples: int = section_field("perspective.line_samples", 20)
25 perspective_occluded_alpha: int = section_field("perspective.occluded_alpha", 90)
26 perspective_solid_width_px: int = section_field("perspective.solid_width_px", 3)
27 perspective_halo_width_px: int = section_field("perspective.halo_width_px", 6)
28 perspective_base_marker_radius_px: int = section_field("perspective.base_marker_radius_px", 6)
29 # Synthesized fallback cameras for detections that no Azure metadata camera
30 # covers (outside every frustum, or projecting onto a void/black background).
31 # An 'auto_back' camera sits perspective_back_distance_m behind the detection
32 # along the road axis at perspective_back_height_m above z_ground; an
33 # 'auto_context' camera sits farther back and higher for scene context.
34 # Uncovered detections within perspective_share_radius_m share one camera pair
35 # aimed at their centroid. A detection counts as covered by a camera when its
36 # projected vertical line lands on rendered geometry within
37 # perspective_coverage_tol_m of the depth buffer.
38 perspective_back_distance_m: float = section_field("perspective.back_distance_m", 22.0)
39 perspective_back_height_m: float = section_field("perspective.back_height_m", 4.0)
40 perspective_context_distance_m: float = section_field("perspective.context_distance_m", 40.0)
41 perspective_context_height_m: float = section_field("perspective.context_height_m", 6.0)
42 perspective_share_radius_m: float = section_field("perspective.share_radius_m", 15.0)
43 perspective_coverage_tol_m: float = section_field("perspective.coverage_tol_m", 0.5)
44
45
46class VerticalSignsViewsFields(config_loader.ConfigModel):
47 """Per-detection QC view rendering (``verticalsigns-views``).
48
49 Metres unless stated otherwise. The view renderer reads these from the
50 nested document rather than off the flat config, so they are declared here
51 only to keep the packaged JSON and the model in lockstep.
52 """
53
54 views_near_radius_m: float = section_field("views.near_radius_m", 45.0)
55 views_fov_deg: float = section_field("views.fov_deg", 55.0)
56 views_splat: int = section_field("views.splat", 2)
57 views_image_width: int = section_field("views.image_width", 1100)
58 views_image_height: int = section_field("views.image_height", 750)
59 views_view_names: tuple[str, ...] = section_field("views.view_names", ("back", "side"))
0
Importance #19: src/iolabs_point_cloud_detection_verticalsigns/_model_road.py @@ -1,99 +1,215 @@
1"""Road-context, edge-line and QC rendering config sections.1"""Road-context gate, driven-lane band and repetitive-row rejection.
22
3One slice of the nested :class:`VerticalSignsConfig` model tree; the sections3Also field-stake rows and embedded-marker extraction.
4mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines4
5the slices.5One slice of the flat ``DetectorConfig``. Every field declares, via
6``section_field``, the ``verticalsigns.default.json`` section and key it is
7loaded from; ``_config`` recombines the slices into the model.
6"""8"""
79
8from iolabs.common import config_loader10from iolabs.common import config_loader
911
12from ._model_base import section_field
1013
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
2914
15class VerticalSignsRoadContextFields(config_loader.ConfigModel):
16 """Road-context gate, driven-lane band and repetitive-row rejection.
3017
31class EdgeLineConfig(config_loader.ConfigModel):18 Also field-stake rows and embedded-marker extraction.
32 """Edge-line paint detection and far-distance filtering."""
3319
34 gate_enabled: bool = True20 Metres unless stated otherwise.
35 paint_max_height_m: float = 0.3521 """
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
7222
23 # Repetitive-row rejection: a noise-barrier (Lรคrmschutzwand) support row
24 # (segment 116) is >=4 slender clusters of similar height on a line at
25 # regular <=5 m spacing. Delineators repeat at 25-50 m so they never form
26 # such a chain and stay safe.
27 row_min_members: int = section_field("repetitive_row.min_members", 4)
28 row_max_spacing_m: float = section_field("repetitive_row.max_spacing_m", 5.0)
29 row_max_perp_spread_m: float = section_field("repetitive_row.max_perp_spread_m", 1.5)
30 row_max_h_max_range_m: float = section_field("repetitive_row.max_h_max_range_m", 0.7)
31 row_member_max_len_major_m: float = section_field("repetitive_row.member_max_len_major_m", 2.0)
32 row_member_max_len_minor_m: float = section_field("repetitive_row.member_max_len_minor_m", 0.8)
7333
74class ViewsConfig(config_loader.ConfigModel):34 # Road-context gate (AI3D-339 pass 7): a delineator with ZERO saturated
75 """Rendered QC view cameras and image size."""35 # returns within roadctx_radius_m is not beside a carriageway and cannot be
36 # road furniture. Presence only โ€” absolute counts run ~100x lower on the
37 # A1 branch-1 ramp than on the mainline, so no count threshold transfers.
38 # See roadctx.py.
39 roadctx_gate_enabled: bool = section_field("road_context.gate_enabled", True)
40 roadctx_saturation_intensity: float = section_field(
41 "road_context.saturation_intensity", 55000.0
42 )
43 roadctx_radius_m: float = section_field("road_context.radius_m", 15.0)
44 # Segments either side to pool: a candidate near a tile boundary otherwise
45 # sees a truncated disc and can read zero purely from tiling.
46 roadctx_neighbour_span: int = section_field("road_context.neighbour_span", 1)
47 # Domain guard: below this many saturated returns in the pooled
48 # neighbourhood the measurement is coverage noise, not evidence of "no
49 # road", and the gate disarms. See RoadContext.armed.
50 roadctx_min_neighbourhood_saturated: int = section_field(
51 "road_context.min_neighbourhood_saturated", 1000
52 )
53 # Local-ext4 cache for the per-segment saturated-return arrays; empty falls
54 # back to a road_context/ directory beside the per-segment output dirs.
55 roadctx_cache_dir: str = section_field("road_context.cache_dir", "")
7656
77 near_radius_m: float = 45.057 # Driven-lane band gate (AI3D-339 pass 8, Miro directive). A short
78 fov_deg: float = 55.058 # candidate standing in the lane the survey vehicle drove is a vehicle, not
79 splat: int = 259 # road furniture. The pass-8 census killed the wider "between the two edge
80 image_width: int = 110060 # lines of the carriageway" form โ€” run4 is absent on A1 and a featureless
81 image_height: int = 75061 # full-tile rectangle on A4_5, and paint runs at uniform lane spacing right
82 view_names: tuple[str, ...] = ("back", "side")62 # across the median. See edgeline.py and p8_edgeline_census_result.md.
63 edgeline_gate_enabled: bool = section_field("edge_line.gate_enabled", True)
64 # run7 lane XML is the PRIMARY road model (Miro: "use the lines from
65 # run7" / "from the XML. Much more reliable"). See run7_xml.py.
66 edgeline_xml_enabled: bool = section_field("edge_line.xml_enabled", True)
67 # Cross-file consensus: with many per-drive XMLs a point is on the road
68 # only if this fraction of the files covering it agree. One bad variant
69 # must not be able to put a median device on the carriageway.
70 edgeline_xml_min_agreement: float = section_field("edge_line.xml_min_agreement", 0.6)
71 # A file whose band is further than this from the point abstains rather
72 # than voting "outside" โ€” it is describing a different stretch of road.
73 edgeline_xml_vote_slack_m: float = section_field("edge_line.xml_vote_slack_m", 3.0)
74 edgeline_xml_max_distance_m: float = section_field("edge_line.xml_max_distance_m", 60.0)
75 edgeline_xml_station_tolerance_m: float = section_field(
76 "edge_line.xml_station_tolerance_m", 2.0
77 )
78 edgeline_xml_station_step_m: float = section_field("edge_line.xml_station_step_m", 10.0)
79 # A full carriageway, not a lane: the XML edges bound the whole thing.
80 edgeline_min_carriageway_width_m: float = section_field(
81 "edge_line.min_carriageway_width_m", 3.0
82 )
83 edgeline_max_carriageway_width_m: float = section_field(
84 "edge_line.max_carriageway_width_m", 20.0
85 )
86 # Paint extraction is demoted to a fallback for corridors with no lane
87 # XML, and is OFF by default per the run7 directive.
88 edgeline_paint_fallback_enabled: bool = section_field("edge_line.paint_fallback_enabled", False)
89 # Paint band: height above the local DEM within which a return is road
90 # marking rather than a device face (a delineator's band sits at 0.7-0.9 m).
91 edgeline_paint_max_height_m: float = section_field("edge_line.paint_max_height_m", 0.35)
92 edgeline_paint_min_height_m: float = section_field("edge_line.paint_min_height_m", -0.25)
93 # Paint cut as a PERCENTILE of near-ground intensity, never a DN: measured
94 # p95 = 39.3k/39.5k/41.3k on three A4_5 segments, while the roadctx
95 # saturation cut (55000) shows only the single line nearest the drive line.
96 edgeline_paint_intensity_percentile: float = section_field(
97 "edge_line.paint_intensity_percentile", 95.0, ge=0.0, le=100.0
98 )
99 edgeline_paint_subsample: int = section_field("edge_line.paint_subsample", 20)
100 # Along-road window.
101 edgeline_station_len_m: float = section_field("edge_line.station_len_m", 10.0)
102 edgeline_min_window_returns: int = section_field("edge_line.min_window_returns", 2000)
103 # Painted-line detection in the lateral histogram.
104 edgeline_lateral_bin_m: float = section_field("edge_line.lateral_bin_m", 0.10)
105 edgeline_min_line_points: int = section_field("edge_line.min_line_points", 40)
106 edgeline_max_line_width_m: float = section_field("edge_line.max_line_width_m", 1.5)
107 edgeline_min_line_along_fill: float = section_field("edge_line.min_line_along_fill", 0.4)
108 # Drive line = densest lateral bin of all near-ground returns.
109 edgeline_drive_line_bin_m: float = section_field("edge_line.drive_line_bin_m", 0.5)
110 # Band sanity: one or two lanes. Wider means a line was missed.
111 edgeline_min_band_width_m: float = section_field("edge_line.min_band_width_m", 2.0)
112 edgeline_max_band_width_m: float = section_field("edge_line.max_band_width_m", 9.0)
113 # INWARD margin. Delineators stand ON the paint line, so the margin must
114 # shrink the rejection zone, never grow it.
115 edgeline_inward_margin_m: float = section_field("edge_line.inward_margin_m", 0.3)
116 edgeline_min_coverage_frac: float = section_field("edge_line.min_coverage_frac", 0.6)
117 # Axis sanity, replacing the tile-elongation guard that misfired on real
118 # 51x34 m tiles: the paint must be sharper ACROSS the chosen axis than
119 # along it (measured ~19x on A4_5).
120 edgeline_min_axis_contrast: float = section_field("edge_line.min_axis_contrast", 3.0)
121 # Central-axis prior (cross_sections_run7_lanes_*.npz).
122 edgeline_axis_search_radius_m: float = section_field("edge_line.axis_search_radius_m", 40.0)
123 edgeline_axis_max_angle_cos: float = section_field("edge_line.axis_max_angle_cos", 0.8)
124 edgeline_axis_max_distance_m: float = section_field("edge_line.axis_max_distance_m", 150.0)
125 # Overhead exemption; type-based exemption in classify.py covers the rest.
126 edgeline_exempt_h_max_m: float = section_field("edge_line.exempt_h_max_m", 4.5)
127 # Corroboration: a transient exists in one driving pass only. Rejection
128 # requires this AND on-road position; position alone is a flag.
129 edgeline_reject_requires_transient: bool = section_field(
130 "edge_line.reject_requires_transient", True
131 )
132 edgeline_transient_max_records: int = section_field("edge_line.transient_max_records", 1)
133 # Far-from-edge-line filter. Delineators stand 0.5-2 m off the carriageway
134 # edge; a "delineator" tens of metres away is a plantation or field stake
135 # (the class reject_rescue readmits). Default 30.0 m sits between real
136 # ramp posts at junctions with fragmentary XML coverage (p50 3.3 m / max
137 # 26.9 m with roleless edges included; A4_5 segs 131-135) and the
138 # false-positive stake rows (35-50 m on A4_5 038/049 and A1 branch-1
139 # 007/008). Measures against ALL XML edge features including roleless
140 # ramp edges. See edgedist.py.
141 edgeline_far_filter_enabled: bool = section_field("edge_line.far_filter_enabled", True)
142 edgeline_far_max_distance_m: float = section_field("edge_line.far_max_distance_m", 30.0)
143 # Also measure against painted lane-line families (Center Lines, Central
144 # Axis, Single-Side Central Axis). A delineator beside a painted line is
145 # near a road even where no Axis-of-the-Edge was extracted; this can only
146 # reduce false removals. Does not leak into the carriageway band model.
147 edgeline_far_include_lane_lines: bool = section_field("edge_line.far_include_lane_lines", True)
148 # Second, tighter far-from-edge cut for the 15-30 m band. Real ramp posts
149 # whose XML ramps are missing sit in that band with roadctx_n_sat 200-57k;
150 # reject-rescue stake rows in fields sit there with sat 2-130. Kill when
151 # screen distance exceeds the tighter cut AND measured saturation is
152 # below the paved-surface floor. See edgedist.py.
153 edgeline_far_tier2_enabled: bool = section_field("edge_line.far_tier2_enabled", True)
154 edgeline_far_tier2_distance_m: float = section_field("edge_line.far_tier2_distance_m", 15.0)
155 edgeline_far_tier2_max_saturation: int = section_field(
156 "edge_line.far_tier2_max_saturation", 150
157 )
83158
159 # Field-stake rows: road-context failures that are phase-locked at stake
160 # spacing (A1 072/073 agricultural row at 5.8 m; A4_5 plantation rows at
161 # 4-5 m) are emitted as the experimental "field_stake_row" class instead of
162 # being dropped. min_members counts the whole row, so >=3 neighbours.
163 field_stake_row_emit: bool = section_field("field_stake.row_emit", True)
164 field_stake_min_members: int = section_field("field_stake.min_members", 4)
165 field_stake_min_spacing_m: float = section_field("field_stake.min_spacing_m", 2.0)
166 field_stake_max_spacing_m: float = section_field("field_stake.max_spacing_m", 10.0)
167 field_stake_max_spacing_cv: float = section_field("field_stake.max_spacing_cv", 0.35)
84168
85class PerspectiveConfig(config_loader.ConfigModel):169 # Embedded-marker extraction: a bright vertical sign/delineator that DBSCAN
86 """Perspective-projection QC overlay cameras and tolerances."""170 # glued onto an adjacent guardrail/barrier gets rejected as a large
171 # footprint. Scan the along-axis brightness profile of such rejected
172 # clusters for a compact, salient, retroreflective panel (segment 006).
173 marker_extract_min_len_major_m: float = section_field("marker_extract.min_len_major_m", 6.0)
174 marker_extract_bright_h_min_m: float = section_field("marker_extract.bright_h_min_m", 1.5)
175 marker_extract_min_bright_points: int = section_field("marker_extract.min_bright_points", 400)
176 marker_extract_window_m: float = section_field("marker_extract.window_m", 2.5)
177 marker_extract_min_bright_fraction: float = section_field(
178 "marker_extract.min_bright_fraction", 0.45
179 )
180 marker_extract_min_h_max_m: float = section_field("marker_extract.min_h_max_m", 1.6)
181 # Embedded-marker validation (defect class 3). The extracted window must be a
182 # genuine off-ground marker, not a flat bright road-surface artifact glued to a
183 # barrier. Require real vertical extent (points spanning at least this many
184 # metres) AND, for a window emitted as a "sign", genuine plate geometry โ€” a
185 # thin, slender slab (plate_thickness_m <= sign_max_plate_thickness_m and
186 # len_minor <= sign_post_max_len_minor_m). Segment 079's on-road paint blob
187 # (len_minor 2.06 m, plate_thickness 0.16 m) fails both; segment 006's real
188 # guide board (0.45 m, 0.005 m) passes. NB: an on-road-fraction guard is NOT
189 # used here because 006's window also reads on_road_fraction 1.0 โ€” plate
190 # geometry, not road overlap, is the true separator.
191 marker_extract_min_vertical_span_m: float = section_field(
192 "marker_extract.min_vertical_span_m", 0.5
193 )
87194
88 depth_tol_m: float = 0.5195 # Legacy ``road_context`` copies of the ``edge_line`` keys of the same name.
89 line_samples: int = 20196 # The detector reads the ``edge_line`` fields above; these are declared so
90 occluded_alpha: int = 90197 # the packaged JSON keeps validating, and so an override file written
91 solid_width_px: int = 3198 # against the old section spelling is still accepted rather than rejected.
92 halo_width_px: int = 6199 roadctx_xml_enabled: bool = section_field("road_context.xml_enabled", True)
93 base_marker_radius_px: int = 6200 roadctx_xml_min_agreement: float = section_field("road_context.xml_min_agreement", 0.6)
94 back_distance_m: float = 22.0201 roadctx_xml_vote_slack_m: float = section_field("road_context.xml_vote_slack_m", 3.0)
95 back_height_m: float = 4.0202 roadctx_xml_max_distance_m: float = section_field("road_context.xml_max_distance_m", 60.0)
96 context_distance_m: float = 40.0203 roadctx_xml_station_tolerance_m: float = section_field(
97 context_height_m: float = 6.0204 "road_context.xml_station_tolerance_m", 2.0
98 share_radius_m: float = 15.0205 )
99 coverage_tol_m: float = 0.5206 roadctx_xml_station_step_m: float = section_field("road_context.xml_station_step_m", 10.0)
207 roadctx_min_carriageway_width_m: float = section_field(
208 "road_context.min_carriageway_width_m", 3.0
209 )
210 roadctx_max_carriageway_width_m: float = section_field(
211 "road_context.max_carriageway_width_m", 20.0
212 )
213 roadctx_paint_fallback_enabled: bool = section_field(
214 "road_context.paint_fallback_enabled", False
215 )
Importance #20: src/iolabs_point_cloud_detection_verticalsigns/_model_stages.py @@ -0,0 +1,130 @@
1"""Opt-in post-classification stages.
2
3The rail-relative half-post pass, the reject-rescue second look and
4the ML verifier.
5
6One slice of the flat ``DetectorConfig``. Every field declares, via
7``section_field``, the ``verticalsigns.default.json`` section and key it is
8loaded from; ``_config`` recombines the slices into the model.
9"""
10
11from iolabs.common import config_loader
12
13from ._model_base import section_field
14
15
16class VerticalSignsStageFields(config_loader.ConfigModel):
17 """Opt-in post-classification stages.
18
19 The rail-relative half-post pass, the reject-rescue second look and
20 the ML verifier.
21
22 Metres unless stated otherwise.
23 """
24
25 # Rail-relative half-post stage (see railpost.py; AI3D-339 pass 10). A
26 # guardrail-mounted delineator body is invisible to the main path: it fuses
27 # with the W-beam into one 45 m blob at seeding. This stage searches the
28 # band above each rail's measured beam crest, given guardrail models from
29 # the guardrails repo. ~91% of A4_5 is railed, so the class is the dominant
30 # delineator morphology there, not an edge case.
31 #
32 # Every constant is FROZEN from the pass-8 A4_5 probe and its pass-9 A1
33 # re-run, which applied the gate unchanged โ€” the panel's "twice-transferred"
34 # requirement. They are config keys so the reserve burn can toggle them,
35 # not because they are open for tuning.
36 #
37 # prime (n_sat >= 1 AND nrec >= 2) is a CONFIDENCE MARKER, NEVER A GATE:
38 # the pass-9 control arm measured the non-prime tail at 43% real, which
39 # makes prime a ~2.2x precision-ranking device. Gating on it would throw
40 # away a near-coin-flip tail.
41 #
42 # OFF by default: validation needs the ratified truth set.
43 rail_halfpost_stage: bool = section_field("rail_halfpost.enabled", False)
44 # Root searched for **/segment_<id>/guardrails.json (the guardrails repo
45 # writes one output root per worker: out_w0/, out_w1/, ...). Empty disables
46 # the stage even when the flag is on.
47 rail_halfpost_models_dir: str = section_field("rail_halfpost.models_dir", "")
48 # Band geometry (probe constants). The 0.15 m floor is calibrated: the
49 # W-beam's own returns reach ~0.20 m above the fitted top, and below that
50 # floor every cluster in the band fuses into one blob per rail.
51 rail_halfpost_band_lat_m: float = section_field("rail_halfpost.band_lat_m", 0.80)
52 rail_halfpost_band_z_lo_m: float = section_field("rail_halfpost.band_z_lo_m", 0.15)
53 rail_halfpost_band_z_hi_m: float = section_field("rail_halfpost.band_z_hi_m", 1.50)
54 rail_halfpost_sample_step_m: float = section_field("rail_halfpost.sample_step_m", 0.10)
55 rail_halfpost_cluster_cell_m: float = section_field(
56 "rail_halfpost.cluster_cell_m", 0.15, gt=0.0
57 )
58 rail_halfpost_min_emit_points: int = section_field("rail_halfpost.min_emit_points", 8)
59 rail_halfpost_ground_cell_m: float = section_field("rail_halfpost.ground_cell_m", 2.0, gt=0.0)
60 rail_halfpost_ground_percentile: float = section_field(
61 "rail_halfpost.ground_percentile", 10.0, ge=0.0, le=100.0
62 )
63 rail_halfpost_saturation_intensity: float = section_field(
64 "rail_halfpost.saturation_intensity", 55000.0
65 )
66 # Acceptance gate (pass-8, transferred to A1 unchanged in pass 9).
67 rail_halfpost_h_min_m: float = section_field("rail_halfpost.h_min_m", 0.20)
68 rail_halfpost_h_max_m: float = section_field("rail_halfpost.h_max_m", 0.80)
69 rail_halfpost_max_lateral_m: float = section_field("rail_halfpost.max_lateral_m", 0.50)
70 rail_halfpost_max_width_m: float = section_field("rail_halfpost.max_width_m", 0.20)
71 rail_halfpost_min_points: int = section_field("rail_halfpost.min_points", 15)
72 rail_halfpost_min_z_extent_m: float = section_field("rail_halfpost.min_z_extent_m", 0.10)
73 rail_halfpost_dedupe_m: float = section_field("rail_halfpost.dedupe_m", 1.5)
74 # Confidence marker only โ€” see above.
75 rail_halfpost_prime_min_sat: int = section_field("rail_halfpost.prime_min_sat", 1)
76 rail_halfpost_prime_min_records: int = section_field("rail_halfpost.prime_min_records", 2)
77
78 # Reject-rescue second-look stage (see rescue.py; AI3D-339 pass 10). The
79 # pass-9 sieve's stratum A, ported as a detector stage: a label-free
80 # physical screen over clusters the detector rejected with a reason that
81 # named no positive counter-indication. Seven clusters called vegetation
82 # over the lifetime of the loop were later overturned to real devices, and
83 # the criteria below are the profile those seven share, with each threshold
84 # anchored to a percentile of the detector's OWN accepted delineators on the
85 # same run โ€” never to a judged label (out_eval/pass9/p9_sieve.py).
86 #
87 # Brightness is deliberately NOT a gate: three of the seven overturns were
88 # explicitly unsaturated. It is a rank bonus in the sieve and nothing here.
89 #
90 # OFF by default: validation needs the ratified truth set.
91 reject_rescue_stage: bool = section_field("reject_rescue.enabled", False)
92 rescue_h_min_m: float = section_field("reject_rescue.h_min_m", 0.85)
93 rescue_h_max_m: float = section_field("reject_rescue.h_max_m", 1.60)
94 rescue_min_verticality: float = section_field("reject_rescue.min_verticality", 0.90)
95 rescue_max_core_rms_m: float = section_field("reject_rescue.max_core_rms_m", 0.20)
96 rescue_min_h_over_width: float = section_field("reject_rescue.min_h_over_width", 1.40)
97 rescue_min_records: int = section_field("reject_rescue.min_records", 2)
98 rescue_min_roadctx_sat: int = section_field("reject_rescue.min_roadctx_sat", 17)
99 rescue_min_continuity: float = section_field("reject_rescue.min_continuity", 0.80)
100 rescue_min_decile_fill: float = section_field("reject_rescue.min_decile_fill", 0.60)
101 rescue_min_points: int = section_field("reject_rescue.min_points", 30)
102 # Two rescues this close describe one physical object; keep the better one.
103 rescue_merge_radius_m: float = section_field("reject_rescue.merge_radius_m", 1.0)
104 # A rescue within this distance of something already accepted is not a
105 # rescue, it is a duplicate.
106 rescue_accepted_exclusion_m: float = section_field("reject_rescue.accepted_exclusion_m", 2.0)
107 # Sieve's PER_SEGMENT_CAP was a crop-budget device for a judge pool, not a
108 # physical criterion, so it does not ship as one: 0 means no cap.
109 rescue_per_segment_cap: int = section_field("reject_rescue.per_segment_cap", 0)
110
111 # ML verifier stage (see ml.py). When enabled and a model file resolves,
112 # every accepted detection gets an "ml_confidence" = P(real) in the JSON and
113 # detections scoring below ml_veto_threshold are dropped with reason
114 # ml_vetoed (logged in clusters.csv). Enabled by default but a pure no-op
115 # when no model is present, so a fresh checkout behaves exactly as before.
116 # A negative ml_veto_threshold means "use the threshold in the model
117 # bundle"; ml_model_path empty means "resolve models/latest.json".
118 ml_verifier_enabled: bool = section_field("classification.ml_verifier_enabled", True)
119 ml_veto_threshold: float = section_field("classification.ml_veto_threshold", -1.0)
120 ml_model_path: str = section_field("classification.ml_model_path", "")
121 # The verifier was trained on corridor-bearing A4_5 data with its veto
122 # threshold anchored to the minimum P(real) among training reals (0.62).
123 # On a run4-less dataset the model runs out-of-domain: measured on
124 # Abschnitt 1, all five adversarially judged-real signs of the segment-048
125 # family scored P 0.51-0.59 and were vetoed. When True (default), segments
126 # without run4 road-surface files score-and-annotate but do not veto;
127 # corridor-bearing segments (all of A4_5) are byte-identical either way.
128 ml_veto_requires_corridor: bool = section_field(
129 "classification.ml_veto_requires_corridor", True
130 )
0
Importance #21: src/iolabs_point_cloud_detection_verticalsigns/_model_tree.py @@ -1,180 +0,0 @@
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 #22: src/iolabs_point_cloud_detection_verticalsigns/_model_treedetect.py @@ -0,0 +1,87 @@
1"""Experimental tree detection and TCS ground filtering of the DEM input.
2
3One slice of the flat ``DetectorConfig``. Every field declares, via
4``section_field``, the ``verticalsigns.default.json`` section and key it is
5loaded from; ``_config`` recombines the slices into the model.
6"""
7
8from typing import Literal, TypeAlias
9
10from iolabs.common import config_loader
11
12from ._model_base import section_field
13
14#: Ground-filter mechanism, spelled exactly as ``iolabs_point_cloud_tablecloth`` types it.
15TcsMechanism: TypeAlias = Literal["none", "smrf_numpy", "csf_cloth"]
16
17
18class VerticalSignsTreeDetectionFields(config_loader.ConfigModel):
19 """Experimental tree detection and TCS ground filtering of the DEM input.
20
21 Metres unless stated otherwise.
22 """
23
24 # Experimental vegetation (tree) detection path (Part B). Master flag off by
25 # default; enabled via a config override for the tree run. A coarser DBSCAN
26 # and a wider (20 m) corridor run SEPARATELY from the sign path, and a
27 # dedicated vegetation RF (models/latest_vegetation.json) decides tree-vs-not.
28 # Candidates sitting directly above road-surface cells (a bridge/elevated
29 # deck, segment 033) are rejected by the on-road-fraction bridge guard.
30 tree_detection_enabled: bool = section_field("tree_detection.enabled", False)
31 tree_max_dist_to_road_m: float = section_field("tree_detection.max_dist_to_road_m", 20.0)
32 tree_seed_min_vertical_span_m: float = section_field(
33 "tree_detection.seed_min_vertical_span_m", 1.5
34 )
35 tree_seed_points_above_m: float = section_field("tree_detection.seed_points_above_m", 2.0)
36 tree_eps_m: float = section_field("tree_detection.eps_m", 1.5)
37 tree_min_samples: int = section_field("tree_detection.min_samples", 3)
38 tree_hull_margin_m: float = section_field("tree_detection.hull_margin_m", 0.5)
39 tree_min_points: int = section_field("tree_detection.min_points", 60)
40 tree_bridge_max_on_road_fraction: float = section_field(
41 "tree_detection.bridge_max_on_road_fraction", 0.6
42 )
43 tree_dedup_radius_m: float = section_field("tree_detection.dedup_radius_m", 2.0)
44 tree_min_confidence: float = section_field("tree_detection.min_confidence", -1.0)
45 tree_model_path: str = section_field("tree_detection.model_path", "")
46 # Hedge split: every accepted tree cluster is put through the instance
47 # splitter's band (hedge) rule, and a grounded, low, long, stemless,
48 # flat-topped one is emitted as "medium_vegetation" (LAS 4) instead of
49 # "tree" (LAS 5). OFF by default (Miro, AI3D-373): whatever the tree
50 # stage accepts IS a tree -- a 3 m flat-topped band of greenery is high
51 # vegetation to the annotators, and the ground is often cut off so the
52 # trunks that would tell a tree from a hedge are not in the cloud. The
53 # rule stays available for datasets where hedges must go to LAS 4.
54 #
55 # This is the ONLY hedge knob under "tree_detection": it is on/off and
56 # nothing else. Every threshold the rule reads lives in the tree_instance
57 # slice, because the rule itself belongs to the instance splitter and the
58 # two callers must not be able to drift apart -- see _model_treeinstance:
59 # ``ti_hedge_*`` (ground gap, height, length, area, continuity, top relief,
60 # stems per 10 m, stem score bar), ``ti_min_cluster_points`` (the point
61 # floor below which the verdict abstains as "too_few_points"), and the stem
62 # band ``ti_stem_band_*`` / ``ti_stem_exg_bonus`` that produce the seeds the
63 # stemless conjunct counts. JSON: {"tree_instance": {"hedge_max_height_m":
64 # ...}}, not {"tree_detection": {...}}.
65 tree_hedge_split_enabled: bool = section_field("tree_detection.hedge_split_enabled", False)
66
67 # TCS (tablecloth) ground filtering, Option C (AI3D-339). When enabled the
68 # p8 DEM is built from TCS-ground-classified points only, so height-above-
69 # ground stops being biased upward by parked vehicles and low canopy. This
70 # repoints the DEM INPUT ONLY -- the candidate accumulation keeps reading
71 # the original run3 files, because TCS drops vegetation as non-ground and
72 # feeding cleaned clouds to the candidate path would erase every tree.
73 # Profile is FORKED from tablecloth's defaults, which are tuned lip-first
74 # for pavement-edge retention (max_window 3.0 m lets vehicles survive into
75 # the surface); these are the wider road-corridor values.
76 tcs_ground_enabled: bool = section_field("tcs_ground.enabled", False)
77 tcs_mechanism: TcsMechanism = section_field("tcs_ground.mechanism", "smrf_numpy")
78 tcs_cell_m: float = section_field("tcs_ground.cell_m", 0.20, gt=0.0)
79 tcs_slope_threshold: float = section_field("tcs_ground.slope_threshold", 0.30)
80 tcs_max_elev_diff_m: float = section_field("tcs_ground.max_elev_diff_m", 0.15)
81 tcs_smrf_max_window_m: float = section_field("tcs_ground.smrf_max_window_m", 6.0)
82 tcs_elev_scalar: float = section_field("tcs_ground.elev_scalar", 0.0)
83 tcs_pit_fill_enabled: bool = section_field("tcs_ground.pit_fill_enabled", True)
84 # Where the ground-only *_run3_ground_points.npz intermediates are written.
85 # Empty means "beside the output segment dir". Point this at local ext4 --
86 # the 9p /mnt/d share is far too slow for rewriting whole clouds.
87 tcs_cache_dir: str = section_field("tcs_ground.cache_dir", "")
0
Importance #23: src/iolabs_point_cloud_detection_verticalsigns/_model_treeinstance.py @@ -0,0 +1,345 @@
1"""Per-point tree instance splitting of merged canopy blobs.
2
3One slice of the flat ``DetectorConfig``. Every field declares, via
4``section_field``, the ``verticalsigns.default.json`` section and key it is
5loaded from; ``_config`` recombines the slices into the model.
6"""
7
8from iolabs.common import config_loader
9
10from ._model_base import section_field
11
12
13class VerticalSignsTreeInstanceFields(config_loader.ConfigModel):
14 """Stem-seeded instance splitting of a single ``type: "tree"`` detection.
15
16 Metres unless stated otherwise.
17 """
18
19 # Master flag for future in-run wiring (detect.py emitting per-instance
20 # ids). The offline splitter script drives tree_instances.py directly and
21 # ignores this, exactly as tree_detection_enabled gates only the in-run
22 # vegetation path.
23 tree_instance_enabled: bool = section_field("tree_instance.enabled", False)
24
25 # Local ground. One z_ground per detection is fine for a 5 m crown and
26 # wrong for a 40 m blob on an embankment: a 10% slope moves true ground by
27 # 3 m over 30 m, which alone pushes the far end's trunks entirely out of
28 # the stem band. Above the footprint threshold the ground is re-estimated
29 # per XY cell as a low percentile of z, then replaced by the MINIMUM of
30 # that percentile over a window_m neighbourhood. The minimum is what makes
31 # it robust: a cell under a dense crown has no ground return and a cell
32 # holding a trunk has that trunk mixed into its percentile, so cell errors
33 # are one-signed (always too high) and the neighbourhood's best-observed
34 # ground is the right pick. window_m trades a constant downhill bias on a
35 # slope (harmless - the crown base is measured on the same normalized
36 # heights) against reaching a real ground cell from under a crown.
37 # Cell size is deliberately coarse: 2 m cells keep enough returns per cell
38 # for a percentile to mean anything on 200-500 pt/m2 MLS.
39 ti_local_ground_footprint_m: float = section_field(
40 "tree_instance.local_ground_footprint_m", 15.0
41 )
42 ti_local_ground_cell_m: float = section_field("tree_instance.local_ground_cell_m", 2.0, gt=0.0)
43 ti_local_ground_percentile: float = section_field(
44 "tree_instance.local_ground_percentile", 5.0, ge=0.0, le=100.0
45 )
46 ti_local_ground_window_m: float = section_field("tree_instance.local_ground_window_m", 6.0)
47
48 # Crown base and the stem band. The treeX/Point2Tree literature slices a
49 # FIXED 1-4 m trunk band, which is calibrated on forest inventory plots.
50 # Roadside trees in this corpus are 3-7 m tall with crown base near 2 m, so
51 # a fixed band is ~50% foliage and the stem cluster drowns in leaves. The
52 # band top is therefore the estimated crown base: the lowest height above
53 # which the 0.25 m density profile stays at density_frac of its peak for
54 # run_bins consecutive bins (a persistent ramp, not a single noisy bin).
55 # crown_base_min_m keeps a sparse-trunk tree from collapsing the band to
56 # nothing; band_cap_m keeps a tall tree's band inside the literature range
57 # where a stem is still straight. A band thinner than min_thickness_m
58 # cannot support a vertical-reach test, so the cluster gets no seeds at all
59 # rather than seeds fitted to 20 cm of trunk.
60 ti_crown_base_bin_m: float = section_field("tree_instance.crown_base_bin_m", 0.25)
61 ti_crown_base_density_frac: float = section_field("tree_instance.crown_base_density_frac", 0.35)
62 ti_crown_base_run_bins: int = section_field("tree_instance.crown_base_run_bins", 3)
63 ti_crown_base_min_m: float = section_field("tree_instance.crown_base_min_m", 1.2)
64 ti_stem_band_low_m: float = section_field("tree_instance.stem_band_low_m", 0.5)
65 ti_stem_band_cap_m: float = section_field("tree_instance.stem_band_cap_m", 4.0)
66 ti_stem_band_min_thickness_m: float = section_field(
67 "tree_instance.stem_band_min_thickness_m", 0.7
68 )
69
70 # Stem seeds: 2D DBSCAN on the band's XY, then a four-cue evidence score.
71 # eps_m is a trunk-scale neighbourhood (0.35 m spans a 0.7 m trunk, wider
72 # than anything in this corpus) so two stems 3 m apart never chain.
73 # max_diameter_m is the hard foliage gate: a band blob whose horizontal RMS
74 # radius exceeds half of it is a bush or a hedge cross-section, not a stem,
75 # and no amount of verticality may rescue it. min_vertical_reach is the
76 # fraction of the band a seed must span - a stem is a column through the
77 # whole band, low scrub only touches its bottom. exg_bonus is the only
78 # colour term: bark is measurably less green than the crown around it, but
79 # RGB is not universal in this corpus (several datasets carry intensity
80 # only), so colour may add at most this much and never gates.
81 ti_stem_eps_m: float = section_field("tree_instance.stem_eps_m", 0.35)
82 ti_stem_min_samples: int = section_field("tree_instance.stem_min_samples", 20)
83 ti_stem_max_diameter_m: float = section_field("tree_instance.stem_max_diameter_m", 1.2)
84 ti_stem_min_vertical_reach: float = section_field("tree_instance.stem_min_vertical_reach", 0.5)
85 ti_stem_min_verticality: float = section_field("tree_instance.stem_min_verticality", 0.6)
86 ti_stem_min_score: float = section_field("tree_instance.stem_min_score", 0.45)
87 ti_stem_exg_bonus: float = section_field("tree_instance.stem_exg_bonus", 0.1)
88 # Two stems closer than merge_dist_m are one stem that DBSCAN split (a
89 # forked trunk, or a stem seen from two scan passes) and are merged.
90 # Survivors closer than uncertain_dist_m are kept as separate instances but
91 # demote the owning cluster to 'uncertain': at that spacing the geometry
92 # cannot say whether it is one multi-stem tree or two, and the caller must
93 # be told rather than shown a confident two-way split.
94 # v3 run evidence: DBSCAN pile-ups put 3+ "stems" inside ~1 m on sparse
95 # scatter, so the merge radius is wider than the classic 0.8 m occlusion
96 # split. Pairs surviving the merge but closer than uncertain_dist_m demote
97 # the cluster verdict instead โ€” one multi-stem tree and two touching trees
98 # are the same picture at that spacing.
99 ti_stem_merge_dist_m: float = section_field("tree_instance.stem_merge_dist_m", 1.2)
100 ti_stem_uncertain_dist_m: float = section_field("tree_instance.stem_uncertain_dist_m", 2.0)
101
102 # Crown-apex fallback seeding. Stem seeding assumes a clean trunk band,
103 # which is a forest-plot assumption: on A1 roadside MLS 43% of clusters
104 # yielded ZERO seeds because the vegetation is bushy to the ground and
105 # every band blob fails the stem diameter gate. The fallback rasterizes the
106 # top surface (max height per cell_m cell), fills single-cell holes,
107 # smooths it with a normalized gaussian (sigma in metres) and takes the
108 # local maxima as seeds. min_separation_m is both the maxima window and the
109 # distance inside which an apex is considered the same tree as an already
110 # accepted stem (and dropped) - roughly the smallest crown worth splitting
111 # off. min_prominence_m is the rise above the lowest cell in that window: a
112 # bump smaller than this is crown texture, not a second tree. min_height_m
113 # keeps the pass off knee-high scrub. trigger_span_m is when the fallback
114 # runs at all: no stem seeds, or fewer than one stem per this much major
115 # axis, because a single trunk cannot own 20 m of continuous canopy.
116 # seed_radius_m collects the source points around an apex in (x, y, height)
117 # space, which lets the existing voxel-graph dijkstra grow apex seeds
118 # unchanged. confidence_scale is the standing discount on an apex-seeded
119 # instance: the apex is where the canopy is highest, which is where a tree
120 # usually is - but a wide crown can carry two.
121 ti_apex_fallback_enabled: bool = section_field("tree_instance.apex_fallback_enabled", True)
122 ti_apex_cell_m: float = section_field("tree_instance.apex_cell_m", 0.5, gt=0.0)
123 ti_apex_smooth_sigma_m: float = section_field("tree_instance.apex_smooth_sigma_m", 0.7)
124 ti_apex_min_separation_m: float = section_field("tree_instance.apex_min_separation_m", 2.5)
125 ti_apex_min_prominence_m: float = section_field("tree_instance.apex_min_prominence_m", 0.8)
126 ti_apex_min_height_m: float = section_field("tree_instance.apex_min_height_m", 2.0)
127 ti_apex_trigger_span_m: float = section_field("tree_instance.apex_trigger_span_m", 8.0)
128 ti_apex_seed_radius_m: float = section_field("tree_instance.apex_seed_radius_m", 0.6)
129 ti_apex_confidence_scale: float = section_field("tree_instance.apex_confidence_scale", 0.6)
130 # Seed damper. The v2 run painted 3-7 instances onto 900-3,000 point sparse
131 # scatters (three seeds inside 2 m on a 1,200 point blob), because both
132 # seeders answer "where is the local evidence" and neither asks whether the
133 # cluster holds enough returns to BE that many trees. A fully scanned
134 # roadside tree in this corpus is thousands of points, so the number of
135 # kept seeds (stem and apex together, best score first) is capped at
136 # n_points / min_points_per_instance - at least one, so a small tree is
137 # never damped away. Together with the min_instance_points floor this
138 # collapses sparse scatter to 0-1 instances instead of a micro-thicket.
139 ti_min_points_per_instance: int = section_field("tree_instance.min_points_per_instance", 1200)
140
141 # Seedless single. A compact, ground-connected, tree-height cluster that
142 # yielded no seed from EITHER mechanism is emitted as one instance covering
143 # all of it instead of abstaining: the detector already asserted "tree",
144 # and an isolated crown with no recoverable stem is far more often one
145 # small tree than a mistake. Deliberately low confidence - the reasoning is
146 # thin and the caller must be able to see that. The footprint bound is what
147 # keeps it honest: above it the cluster certainly holds several trees and
148 # the old 'partial' abstention is still the right answer.
149 # The 0.35-confidence single fired on junk in the v2 run (wire scraps,
150 # facade slivers), so three cheap shape conjuncts were added: p95 height
151 # (not p99, which one stray return can carry), plan aspect - a tree crown
152 # is not a 4:1 sliver - and a point floor, since a genuine crown scanned by
153 # MLS is never a few hundred returns. Failing any of them the cluster is
154 # 'uncertain' again, which is an abstention and not a deletion.
155 ti_seedless_single_max_footprint_m: float = section_field(
156 "tree_instance.seedless_single_max_footprint_m", 10.0
157 )
158 ti_seedless_single_min_height_m: float = section_field(
159 "tree_instance.seedless_single_min_height_m", 1.5
160 )
161 ti_seedless_single_max_height_m: float = section_field(
162 "tree_instance.seedless_single_max_height_m", 25.0
163 )
164 ti_seedless_single_confidence: float = section_field(
165 "tree_instance.seedless_single_confidence", 0.35
166 )
167 ti_seedless_min_p95_h_m: float = section_field("tree_instance.seedless_min_p95_h_m", 2.0)
168 ti_seedless_max_aspect: float = section_field("tree_instance.seedless_max_aspect", 2.5)
169 ti_seedless_min_points: int = section_field("tree_instance.seedless_min_points", 800)
170
171 # Junk guards, all evaluated BEFORE seeding. float_fragment_min_h_m: a tree
172 # is attached to the ground it grows out of, so its 5th height percentile
173 # is near zero even when the trunk was never scanned; a catenary wire, a
174 # mast head or a facade scrap has nothing below 3 m and is 'non_tree'.
175 # min_tree_footprint_m: below it the cluster is a pole cross-section with
176 # nothing to split - 'uncertain', never 'non_tree', because the module may
177 # not delete anything on size. max_tree_footprint_m / megacluster_points
178 # mark detector mask leakage (the first run produced a 5.4M-point,
179 # 63 x 82 m blob holding a road and a roof); such a cluster never gets the
180 # apex fallback and is never reported better than 'partial'. The planar
181 # test is the one that can refuse it outright: the fraction of points in
182 # planar_cell_m cells whose height spread is under planar_max_spread_m.
183 # Vegetation cannot be flat at metre scale, so a fraction above
184 # planar_fraction_min is a roof or a road; it is asked only of footprints
185 # above planar_min_footprint_m, where a flat patch cannot be a crown.
186 # float_fragment_p25_h_m is the same guard read on the MASS rather than on
187 # the tail: a facade arc or a wire bundle with a handful of low returns
188 # under it passes the p5 test and is still not a tree, because a quarter of
189 # a tree's returns are never above 4 m of its own crown base. Kept separate
190 # from float_fragment_min_h_m so the two can be tuned apart.
191 ti_float_fragment_min_h_m: float = section_field("tree_instance.float_fragment_min_h_m", 3.0)
192 ti_float_fragment_p25_h_m: float = section_field("tree_instance.float_fragment_p25_h_m", 4.0)
193 ti_min_tree_footprint_m: float = section_field("tree_instance.min_tree_footprint_m", 1.5)
194 # 60, not 45: the v3 run showed 45 catching a genuine 47 m merged
195 # vegetation complex (segment_014) and suppressing its apex fallback, while
196 # every true leak seen so far is either far larger (63 x 82 m) or dies on
197 # the planarity / megacluster-points guards anyway.
198 ti_max_tree_footprint_m: float = section_field("tree_instance.max_tree_footprint_m", 60.0)
199 ti_megacluster_points: int = section_field("tree_instance.megacluster_points", 1_000_000)
200 ti_planar_min_footprint_m: float = section_field("tree_instance.planar_min_footprint_m", 12.0)
201 ti_planar_cell_m: float = section_field("tree_instance.planar_cell_m", 1.0, gt=0.0)
202 ti_planar_max_spread_m: float = section_field("tree_instance.planar_max_spread_m", 0.3)
203 ti_planar_fraction_min: float = section_field("tree_instance.planar_fraction_min", 0.55)
204
205 # Hedge verdict: ONE rule, the wide continuous band. A hedge row is a
206 # FIRST-CLASS output class, not a failure, and splitting it into "trees"
207 # every few metres is the most expensive mistake this module can make.
208 # The v1/v2 pair of aspect-driven rules got this exactly backwards on real
209 # data - they fired ONCE over 24 segments, on a 2.2 x 0.8 m fragment 14 m
210 # up, while textbook bands (25.8 x 17.6 m at 4.2 m tall, 41.7 x 26.1 m)
211 # were sliced into straight-cut fake tree slabs. Aspect was the culprit:
212 # a real clipped band is as often stubby as it is thin, so it is gone as a
213 # criterion. What is left is what a hedge actually is, all conjunctive:
214 # grounded p5 of height below max_ground_gap_m - foliage runs
215 # down to the ground, unlike a facade or wire scrap;
216 # low p99 height at most max_height_m;
217 # long major axis at least min_length_m;
218 # substantial occupied plan area at least min_area_m2, so a thin
219 # sliver cannot qualify on length alone;
220 # continuous at least min_continuity of the continuity_bin_m bins
221 # along the major axis hold points (two crowns 18 m
222 # apart have a band's extent and none of its substance);
223 # FLAT-TOPPED p90 - p10 of the smoothed crown-surface cell heights
224 # is at most max_top_relief_m. This is the conjunct
225 # that replaces aspect and separates a clipped band
226 # from a row of distinct crowns, whose tops undulate by
227 # metres between crown and gap;
228 # stemless fewer than max_seed_per_10m stem seeds per 10 m of
229 # length - a planted avenue has trunks along it and is
230 # never a hedge, however neatly it is clipped.
231 # The stemless conjunct counts only stems scoring at least
232 # stem_score_min: the v3 run showed sparse foliage shattering into weak
233 # "trunklets" (3 low-score seeds on a 26 m clipped band) that defeated
234 # the rule and got the band sliced anyway. A real avenue trunk scores
235 # well above this; band-noise blobs do not.
236 # max_height_m is 7.5, not 5.0: A1 carries uncut continuous vegetation
237 # walls up to ~7 m (segment_070) that are bands in every other conjunct;
238 # the flat-top relief test is what keeps genuine tree rows out.
239 ti_hedge_max_ground_gap_m: float = section_field("tree_instance.hedge_max_ground_gap_m", 2.0)
240 ti_hedge_max_height_m: float = section_field("tree_instance.hedge_max_height_m", 7.5)
241 ti_hedge_min_length_m: float = section_field("tree_instance.hedge_min_length_m", 8.0)
242 ti_hedge_min_area_m2: float = section_field("tree_instance.hedge_min_area_m2", 20.0)
243 ti_hedge_min_continuity: float = section_field("tree_instance.hedge_min_continuity", 0.75)
244 ti_hedge_continuity_bin_m: float = section_field("tree_instance.hedge_continuity_bin_m", 1.0)
245 ti_hedge_max_top_relief_m: float = section_field("tree_instance.hedge_max_top_relief_m", 1.5)
246 ti_hedge_max_seed_per_10m: float = section_field("tree_instance.hedge_max_seed_per_10m", 1.0)
247 ti_hedge_stem_score_min: float = section_field("tree_instance.hedge_stem_score_min", 0.6)
248
249 # Crown assignment. Points are voxelized and each voxel is given to the
250 # graph-nearest seed, so a crown is grown through its own occupied space
251 # instead of by straight-line distance: a low branch reaching across a
252 # neighbour's trunk stays with the tree it hangs from. max_gap_m is how far
253 # the graph may jump across empty space between voxel centroids - large
254 # enough to close occlusion shadows in a single crown, small enough that
255 # two crowns separated by a real gap stay separate components, and anything
256 # left disconnected abstains rather than being handed to the nearest seed.
257 # max_graph_dist_m bounds the PATH LENGTH of one instance; beyond it a
258 # voxel is unreachable even along a connected path. It is deliberately
259 # generous, because the path from a stem seed at the ground up through a
260 # 13 m crown is 13 m of graph before the crown even starts to spread.
261 # max_claim_radius_m is the crown-radius bound and is HORIZONTAL: the plan
262 # distance from a voxel to its owning seed. That distinction is the whole
263 # rule. Capping the GRAPH distance at 9 m (v2) sent every tall crown to
264 # ABSTAIN_UNREACHABLE - a canopy 8-13 m up is more than 9 m of path from a
265 # seed on the ground, so only the understory fringe was ever assigned and
266 # point-weighted abstention regressed. Capping the horizontal distance
267 # instead still kills what the cap was FOR (a seed walking 20-30 m of
268 # connected roadside band laterally and calling the chain one tree: those
269 # chains are horizontal) while a tall tree stays fully reachable, because
270 # no crown is nine metres wide about its own trunk.
271 # max_gap_m 1.25, not 0.6: v3's renders still showed dense canopy tops gray
272 # ABOVE their own assigned understory โ€” one-sided MLS leaves the mid-story
273 # so sparse that 0.6 m cannot bridge it, so the crown top was disconnected
274 # from its trunk. Lateral crown-to-crown bridging this may add is bounded
275 # by the horizontal claim radius below.
276 ti_assign_voxel_m: float = section_field("tree_instance.assign_voxel_m", 0.3)
277 ti_assign_max_gap_m: float = section_field("tree_instance.assign_max_gap_m", 1.25)
278 ti_assign_max_graph_dist_m: float = section_field("tree_instance.assign_max_graph_dist_m", 30.0)
279 ti_max_claim_radius_m: float = section_field("tree_instance.max_claim_radius_m", 9.0)
280 # Ambiguity between the best two seeds, as a normalized distance margin.
281 # Below the floor the point is still assigned (dropping it would punch a
282 # hole through the middle of every merged canopy) but it drags the owning
283 # instance's confidence down. low_evidence_abstain turns the same band into
284 # a hard abstention for callers who would rather lose the seam than
285 # mislabel it.
286 ti_low_evidence_margin: float = section_field("tree_instance.low_evidence_margin", 0.05)
287 ti_low_evidence_abstain: bool = section_field("tree_instance.low_evidence_abstain", False)
288
289 # Verdict thresholds. min_cluster_points is a floor on stem detection, NOT
290 # a tree-vs-not test: below it the band holds too few returns for DBSCAN to
291 # form any cluster, so the splitter abstains and leaves the detection whole.
292 # Small conifers must survive this - they are reported 'uncertain', never
293 # dropped. single_tree_footprint_m separates "one tree whose stem is
294 # occluded" (abstain, 'uncertain') from "a big canopy that clearly holds
295 # several trees but yields no stem" (abstain, 'partial').
296 ti_min_cluster_points: int = section_field("tree_instance.min_cluster_points", 150)
297 ti_single_tree_footprint_m: float = section_field("tree_instance.single_tree_footprint_m", 8.0)
298 ti_partial_abstain_fraction: float = section_field(
299 "tree_instance.partial_abstain_fraction", 0.2
300 )
301 # Instance sanity floor. An instance owning a few dozen points is a branch
302 # tip, not a tree, and the first run asserted several of those. Both forms
303 # are needed: the absolute one catches micro-instances everywhere, the
304 # relative one catches a 300-point splinter off a 200k-point blob. The
305 # absolute floor is internally capped at half the cluster so it can never
306 # erase a genuinely small detection. Dropped points abstain under
307 # ABSTAIN_LOW_EVIDENCE.
308 ti_min_instance_points: int = section_field("tree_instance.min_instance_points", 120)
309 ti_min_instance_fraction: float = section_field("tree_instance.min_instance_fraction", 0.01)
310 # Instance SHAPE floor, applied to the grown instance rather than to its
311 # seed. Wires, poles, facade arcs and planar scan stripes survive every
312 # cluster-level guard when they arrive mixed into a vegetation cluster, and
313 # v2 painted them as trees: straight horizontal wire lines, a pole column,
314 # a scan stripe. All three are recognisable from the instance's own points.
315 # max_linearity is the share of variance on the first principal axis of the
316 # instance in (x, y, height): a wire or a pole is a 1D object and sits
317 # above 0.92, a crown of any species is nowhere near it. min_minor_m is the
318 # minor plan extent - a crown is a blob, not a ribbon - and
319 # min_vertical_m rejects a flat sheet with no vertical structure. Dropped
320 # instances give their points back as ABSTAIN_LOW_EVIDENCE.
321 # min_thickness_share is the complementary 2D refusal: a planar sheet (road
322 # scan stripes on a slope, a facade panel) is not 1D, so it passes the
323 # linearity test โ€” but its SMALLEST principal axis carries almost no
324 # variance. A crown is thick in all three axes; a sheet is not.
325 ti_instance_max_linearity: float = section_field("tree_instance.instance_max_linearity", 0.92)
326 ti_instance_min_minor_m: float = section_field("tree_instance.instance_min_minor_m", 1.0)
327 ti_instance_min_vertical_m: float = section_field("tree_instance.instance_min_vertical_m", 1.5)
328 ti_instance_min_thickness_share: float = section_field(
329 "tree_instance.instance_min_thickness_share", 0.02
330 )
331 # Instance confidence is seed evidence blended with how unambiguous its
332 # points were (seed_weight is the seed's share), then scaled by size -
333 # min(1, n / size_ref_points) ** 0.3, so a few hundred points cannot look
334 # like a fully observed tree - and by provenance. confidence_max applies to
335 # everything and is below 1.0 on purpose: a geometric splitter with no
336 # ground truth is never certain, and the first run emitting 1.00 on wire
337 # fragments is exactly how a downstream consumer learns to distrust the
338 # number. fallback_max is the tighter cap on apex-seeded and seedless
339 # instances.
340 ti_confidence_seed_weight: float = section_field("tree_instance.confidence_seed_weight", 0.6)
341 ti_confidence_size_ref_points: float = section_field(
342 "tree_instance.confidence_size_ref_points", 2000.0
343 )
344 ti_confidence_max: float = section_field("tree_instance.confidence_max", 0.95)
345 ti_confidence_fallback_max: float = section_field("tree_instance.confidence_fallback_max", 0.9)
0
Importance #24: src/iolabs_point_cloud_detection_verticalsigns/_model_vegetation.py @@ -0,0 +1,150 @@
1"""Tree rejection, chromaticity vegetation reject and radius fitting.
2
3Also core compactness and the crown-circle overlay knobs.
4
5One slice of the flat ``DetectorConfig``. Every field declares, via
6``section_field``, the ``verticalsigns.default.json`` section and key it is
7loaded from; ``_config`` recombines the slices into the model.
8"""
9
10from iolabs.common import config_loader
11
12from ._model_base import section_field
13
14
15class VerticalSignsVegetationFields(config_loader.ConfigModel):
16 """Tree rejection, chromaticity vegetation reject and radius fitting.
17
18 Also core compactness and the crown-circle overlay knobs.
19
20 Metres unless stated otherwise.
21 """
22
23 # Tree rejection
24 tree_crown_h_min_m: float = section_field("tree.crown_h_min_m", 2.0)
25 tree_crown_max_area_m2: float = section_field("tree.crown_max_area_m2", 4.0)
26 tree_isotropy_ratio: float = section_field("tree.isotropy_ratio", 0.75)
27 tree_greenness_hint: float = section_field("tree.greenness_hint", 0.45)
28
29 # Chromaticity vegetation reject (experimental, opt-in per dataset).
30 #
31 # A SEPARATE lever from tree_greenness_hint above. That one thresholds the
32 # legacy `greenness`, which is normalized by a SEGMENT-WIDE RGB max, so one
33 # retroreflective sign in the segment deflates every other cluster's value.
34 # These thresholds read `greenness_exg`, a per-point chromaticity that has
35 # no cross-cluster coupling and lives in a completely different numeric
36 # range (foliage ~0.05-0.4, not ~0.45). Never copy a value between the two.
37 #
38 # Off by default. RGB is not universal in this corpus: several datasets
39 # carry intensity only, or write a constant RGB sentinel. On any of those,
40 # ExG is identically 0 (see features.excess_green_chromaticity), and
41 # classify._chroma_vegetation additionally requires greenness_exg > 0, so it
42 # is a structural no-op there regardless of how these are tuned.
43 #
44 # Thresholds fitted on the A1 corpus (139 segments, 37,627 clusters โ€” see
45 # docs/research/greenness-exg-phase6.md) against three measured populations:
46 # tree crowns (n=7), accepted man-made detections (n=28), and tree trunks
47 # (n=37). Chosen so COLOUR ALONE separates greenery from both of the others,
48 # with the geometric cue as an independent second barrier rather than as the
49 # thing carrying the whole decision.
50 #
51 # feature man-made trunks crowns threshold
52 # greenness_exg max 0.1667 max 0.0455 min 0.0909 0.155 (*)
53 # greenness_exg_iqr max 0.2945 max 0.1530 min 0.1917 0.210 (*)
54 # plate_thickness_m max 0.130 max 0.094 min 0.236 0.175
55 #
56 # (*) READ THESE TWO ROWS CAREFULLY: the threshold does NOT sit in a gap.
57 # Man-made reach 0.1667 on ExG and 0.2945 on IQR, i.e. ABOVE both gates.
58 # Neither colour cue separates the populations on its own. What excludes
59 # every man-made and trunk cluster is that no single one is high on BOTH
60 # axes โ€” the max-ExG row and the max-IQR row are different clusters. So the
61 # conjunction is load-bearing, and neither gate may be relaxed on the
62 # strength of the other. Only plate_thickness_m has a true single-axis gap.
63 #
64 # Result: 5/7 crowns selected, 0/28 man-made, 0/37 trunks. The two crowns
65 # dropped (ExG 0.091 and 0.119) are the least green; A1 is an October
66 # capture, so senescent crowns are the expected loss.
67 #
68 # min_change_of_curvature is deliberately INERT at 0.20. That cue turned out
69 # to be anti-discriminative: man-made clusters reach 0.0815 and trunks 0.1743,
70 # both ABOVE the crown p25 of 0.0273, so an OR-branch on curvature admits
71 # exactly what the rule is meant to exclude. It is kept (rather than deleted)
72 # so a genuinely isotropic clump could still qualify, and so the key stays
73 # configurable.
74 #
75 # "Inert" is scoped, not absolute: corpus-wide 2,584 of 37,627 clusters do
76 # clear 0.20 (max 0.3141), but every one is already rejected by geometry.
77 # Among ACCEPTED man-made the max is 0.0815, and among the 18 clusters this
78 # veto may act on it is 0.0106 โ€” ~19x under the gate. The branch cannot fire
79 # on anything the rule can reach, which is the property that matters.
80 #
81 # Earlier drafts got two of these badly wrong in opposite directions:
82 # exg_iqr_min=0.06 sat BELOW the dark man-made IQR median (0.120), where
83 # 8-bit ExG quantization noise alone clears it; and
84 # min_change_of_curvature=0.12 was picked from the feature's [0, 1/3] range
85 # when real crowns only reach 0.051.
86 #
87 # max_hi_intensity_fraction stays anchored to config rather than data: it
88 # matches delineator_min_hi_intensity_fraction, the weakest brightness at
89 # which anything here may claim to be a man-made reflector.
90 chroma_veg_enabled: bool = section_field("chroma_vegetation.enabled", False)
91 chroma_veg_exg_min: float = section_field("chroma_vegetation.exg_min", 0.155)
92 chroma_veg_exg_iqr_min: float = section_field("chroma_vegetation.exg_iqr_min", 0.210)
93 chroma_veg_max_hi_intensity_fraction: float = section_field(
94 "chroma_vegetation.max_hi_intensity_fraction", 0.08
95 )
96 chroma_veg_min_change_of_curvature: float = section_field(
97 "chroma_vegetation.min_change_of_curvature", 0.20
98 )
99 chroma_veg_min_plate_thickness_m: float = section_field(
100 "chroma_vegetation.min_plate_thickness_m", 0.175
101 )
102
103 # Core compactness: per-height-bin XY RMS radius over the near-ground core.
104 core_rms_bin_m: float = section_field("classification.core_rms_bin_m", 0.25)
105 core_rms_h_min_m: float = section_field("classification.core_rms_h_min_m", 0.30)
106 core_rms_h_cap_m: float = section_field("classification.core_rms_h_cap_m", 3.0)
107
108 # Circle-fit radius estimation (radius.py). Per-height-bin Taubin circle fits
109 # replace the RMS-from-centroid for the *emitted* radii (pole radius_m, tree
110 # trunk_radius_m). A bin is accepted only when its points lie tight on a
111 # well-covered arc, so a bush with no coherent trunk yields radius 0.0. The
112 # ClusterFeatures RMS values are untouched (the .joblib classifiers use them).
113 radius_fit_bin_m: float = section_field("radius.fit_bin_m", 0.25)
114 radius_fit_min_bin_points: int = section_field("radius.fit_min_bin_points", 8)
115 radius_fit_min_arc_deg: float = section_field("radius.fit_min_arc_deg", 60.0)
116 radius_fit_residual_frac: float = section_field("radius.fit_residual_frac", 0.35)
117 radius_fit_residual_abs_m: float = section_field("radius.fit_residual_abs_m", 0.03)
118 radius_fit_divergence_factor: float = section_field("radius.fit_divergence_factor", 4.0)
119 # r_max caps: a roadside pole/post is < 0.5 m radius, a tree trunk < 0.8 m.
120 pole_radius_max_m: float = section_field("radius.pole_radius_max_m", 0.5)
121 trunk_radius_max_m: float = section_field("radius.trunk_radius_max_m", 0.8)
122 # Crown circle = trimmed minimum-enclosing circle of the lobe: the radially
123 # farthest (100 - this)% of points are dropped before enclosing the rest.
124 crown_radius_percentile: float = section_field(
125 "radius.crown_radius_percentile", 95.0, ge=0.0, le=100.0
126 )
127 # Multi-lobe crown overlay: the coarse tree DBSCAN can merge several
128 # neighbouring bushes/trees into one detection whose canopy points form
129 # disjoint blobs around an empty centre. The crown points are re-clustered
130 # with a density-based DBSCAN (neighbourhood crown_lobe_gap_m, core count
131 # crown_lobe_min_samples) so the low-density valley between two canopies
132 # breaks the chain. Lobe selection is coverage-driven: every lobe with >=
133 # crown_lobe_min_points (an absolute floor) is eligible, and lobes are
134 # accepted largest-first until the accepted union covers
135 # crown_lobe_coverage_target of the clustered crown points or the
136 # crown_lobe_max_count satellite cap is hit โ€” so most detached blobs get a
137 # circle while tiny fragments/noise do not. Selection stops at the coverage
138 # target, so a sub-(1 - coverage_target) detached lobe can stay uncircled. A
139 # clean single-canopy tree yields one lobe.
140 crown_lobe_gap_m: float = section_field("radius.crown_lobe_gap_m", 0.5)
141 crown_lobe_min_samples: int = section_field("radius.crown_lobe_min_samples", 10)
142 crown_lobe_min_points: int = section_field("radius.crown_lobe_min_points", 30)
143 crown_lobe_coverage_target: float = section_field("radius.crown_lobe_coverage_target", 0.95)
144 crown_lobe_max_count: int = section_field("radius.crown_lobe_max_count", 8)
145 # Opt-in diagnostics sidecar: when true, detect writes cluster_points.npz
146 # (float64 copies of every detection's fitted points, MBs per segment) so
147 # scripts/radius_diagnostics.py can re-fit the estimator's exact points.
148 # Off on production runs; the diagnostics tool falls back to a neighbourhood
149 # gather when the sidecar is absent.
150 radius_debug_cluster_points: bool = section_field("radius.debug_cluster_points", False)
0
Importance #25: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -1,135 +1,26 @@
1"""Detector configuration.1"""Public import path for the detector configuration.
22
3The 379-field :class:`DetectorConfig` and its ``from_mapping`` flattener are3The schema, the loading entry points and the error class live in `_config`;
4split by section across the ``_config_<section>`` modules; this module4this module re-exports them so the documented ``from
5recombines them and re-exports every piece, so ``from .config import X``5iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig`` keeps
6keeps working for every name that used to live here.6working. The field declarations themselves are split across the
77``_model_<topic>`` slices.
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``.
11"""8"""
129
13from pathlib import Path10from ._config import (
14from typing import Any11 DetectorConfig,
1512 VerticalSignsConfigError,
16from ._config import load_verticalsigns_config13 build_verticalsigns_config,
17from ._config_conic import ConicFields, conic_kwargs14 load_default_config,
18from ._config_corridor import CorridorFields, corridor_kwargs15 load_verticalsigns_config,
19from ._config_devices import DeviceFields, device_kwargs16 normalize_verticalsigns_config,
20from ._config_evidence import EvidenceFields, evidence_kwargs17)
21from ._config_grid import GridFields, grid_kwargs
22from ._config_perspective import PerspectiveFields, perspective_kwargs
23from ._config_roadcontext import RoadContextFields, road_context_kwargs
24from ._config_stages import StageFields, stage_kwargs
25from ._config_treedetect import TreeDetectionFields, tree_detection_kwargs
26from ._config_treeinstance import TreeInstanceFields, tree_instance_kwargs
27from ._config_vegetation import VegetationFields, vegetation_kwargs
2818
29__all__ = [19__all__ = [
30 "DetectorConfig",20 "DetectorConfig",
31 "GridFields",21 "VerticalSignsConfigError",
32 "DeviceFields",22 "build_verticalsigns_config",
33 "VegetationFields",23 "load_default_config",
34 "RoadContextFields",24 "load_verticalsigns_config",
35 "CorridorFields",25 "normalize_verticalsigns_config",
36 "EvidenceFields",
37 "StageFields",
38 "TreeDetectionFields",
39 "TreeInstanceFields",
40 "ConicFields",
41 "PerspectiveFields",
42 "grid_kwargs",
43 "device_kwargs",
44 "vegetation_kwargs",
45 "road_context_kwargs",
46 "corridor_kwargs",
47 "evidence_kwargs",
48 "stage_kwargs",
49 "tree_detection_kwargs",
50 "tree_instance_kwargs",
51 "conic_kwargs",
52 "perspective_kwargs",
53]26]
54
55
56class DetectorConfig( # noqa: D101 - docstring below, after the base list
57 # The bases are listed in REVERSE section order ON PURPOSE: both
58 # dataclasses and pydantic collect fields by walking the MRO backwards, so
59 # this ordering reproduces the original single-class field order exactly
60 # (ground first, then perspective, then the slices added since).
61 # Reordering these lines reorders the fields, so a NEW slice goes at the
62 # TOP of this list to have its fields appended at the end.
63 TreeInstanceFields,
64 PerspectiveFields,
65 ConicFields,
66 TreeDetectionFields,
67 StageFields,
68 EvidenceFields,
69 CorridorFields,
70 RoadContextFields,
71 VegetationFields,
72 DeviceFields,
73 GridFields,
74):
75 """Spatial and geometric thresholds, in metres unless stated otherwise."""
76
77 @classmethod
78 def from_mapping(cls, config: dict[str, Any]) -> "DetectorConfig":
79 """Builds a DetectorConfig by flattening the nested config sections.
80
81 Only keys present in a section override the corresponding model
82 default, so a partial (or default) config reproduces the built-in
83 thresholds exactly.
84
85 Args:
86 config: The nested config document (packaged defaults merged with
87 an optional user JSON).
88
89 Returns:
90 The flattened configuration.
91 """
92 defaults = cls()
93 return cls(
94 **grid_kwargs(config, defaults),
95 **device_kwargs(config, defaults),
96 **vegetation_kwargs(config, defaults),
97 **road_context_kwargs(config, defaults),
98 **corridor_kwargs(config, defaults),
99 **evidence_kwargs(config, defaults),
100 **stage_kwargs(config, defaults),
101 **tree_detection_kwargs(config, defaults),
102 **conic_kwargs(config, defaults),
103 **perspective_kwargs(config, defaults),
104 **tree_instance_kwargs(config, defaults),
105 )
106
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 a wrongly typed value would be
112 stored uncoerced. The names are checked here and the values are run
113 through the model, so this validates where ``dataclasses.replace``
114 merely type-checked the call.
115
116 Args:
117 overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``.
118
119 Returns:
120 A new frozen config carrying *overrides*.
121
122 Raises:
123 ValueError: An override names a field this config does not declare,
124 or carries a value the field rejects (a
125 ``pydantic.ValidationError``, itself a ``ValueError``).
126 """
127 unknown = sorted(set(overrides) - set(type(self).model_fields))
128 if unknown:
129 raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}")
130 return type(self).model_validate({**self.model_dump(), **overrides})
131
132 @classmethod
133 def load(cls, config_path: str | Path | None = None) -> "DetectorConfig":
134 """Load config from the packaged defaults merged with an optional user JSON."""
135 return cls.from_mapping(load_verticalsigns_config(config_path))
Importance #26: src/iolabs_point_cloud_detection_verticalsigns/__init__.py @@ -2,13 +2,15 @@
22
3from importlib.metadata import PackageNotFoundError, version3from importlib.metadata import PackageNotFoundError, version
44
5from ._config import (5from ._config import (
6 DetectorConfig,
6 VerticalSignsConfigError,7 VerticalSignsConfigError,
8 build_verticalsigns_config,
7 load_default_config,9 load_default_config,
8 load_verticalsigns_config,10 load_verticalsigns_config,
11 normalize_verticalsigns_config,
9)12)
10from .config import DetectorConfig
1113
12try:14try:
13 __version__ = version("iolabs-point-cloud-detection-verticalsigns")15 __version__ = version("iolabs-point-cloud-detection-verticalsigns")
14except PackageNotFoundError: # pragma: no cover - source tree without an install16except PackageNotFoundError: # pragma: no cover - source tree without an install
Importance #27: src/iolabs_point_cloud_detection_verticalsigns/__init__.py @@ -16,8 +18,10 @@
1618
17__all__ = [19__all__ = [
18 "DetectorConfig",20 "DetectorConfig",
19 "VerticalSignsConfigError",21 "VerticalSignsConfigError",
22 "build_verticalsigns_config",
20 "load_default_config",23 "load_default_config",
21 "load_verticalsigns_config",24 "load_verticalsigns_config",
25 "normalize_verticalsigns_config",
22 "__version__",26 "__version__",
23]27]
Importance #28: tests/conftest.py @@ -4,29 +4,37 @@
4from collections.abc import Callable4from collections.abc import Callable
5from typing import Any5from typing import Any
66
7import pytest7import pytest
8from iolabs.common import config_loader
98
9from iolabs_point_cloud_detection_verticalsigns import _model_base
10from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig
1011
11def _section_values(model: type[config_loader.ConfigModel]) -> dict[str, Any]:12
12 """Return one valid non-default value per field of *model*."""13def _section_values(section: str) -> dict[str, Any]:
14 """Return one valid non-default value per key of config section *section*."""
13 values: dict[str, Any] = {}15 values: dict[str, Any] = {}
14 for name, field in model.model_fields.items():16 for field in DetectorConfig.model_fields.values():
17 field_section, key = _model_base.section_path(field)
18 if field_section != section:
19 continue
15 annotation = field.annotation20 annotation = field.annotation
21 options = typing.get_args(annotation) if typing.get_origin(annotation) is None else ()
16 if annotation is bool:22 if annotation is bool:
17 values[name] = not field.default23 values[key] = not field.default
18 elif annotation is int:24 elif annotation is int:
19 values[name] = int(field.default) + 125 values[key] = int(field.default) + 1
20 elif annotation is str:26 elif annotation is str:
21 values[name] = f"{field.default}_x"27 values[key] = f"{field.default}_x"
22 elif typing.get_origin(annotation) is tuple:28 elif typing.get_origin(annotation) is tuple:
23 values[name] = [f"{item}_x" for item in field.default]29 values[key] = [f"{item}_x" for item in field.default]
30 elif options and all(isinstance(option, str) for option in options):
31 values[key] = next(o for o in options if o != field.default)
24 else:32 else:
25 values[name] = 0.533 values[key] = 0.5
26 return values34 return values
2735
2836
29@pytest.fixture37@pytest.fixture
30def section_values() -> Callable[[type[config_loader.ConfigModel]], dict[str, Any]]:38def section_values() -> Callable[[str], dict[str, Any]]:
31 """Return a builder for a full override of one config section."""39 """Return a builder for a full override of one config section."""
32 return _section_values40 return _section_values
Importance #29: tests/test_chroma_vegetation.py @@ -13,9 +13,9 @@
1313
14import numpy as np14import numpy as np
15import pytest15import pytest
1616
17from iolabs_point_cloud_detection_verticalsigns import _config, _model_tree17from iolabs_point_cloud_detection_verticalsigns import _config
18from iolabs_point_cloud_detection_verticalsigns.classify import (18from iolabs_point_cloud_detection_verticalsigns.classify import (
19 CHROMA_VETOABLE_TYPES,19 CHROMA_VETOABLE_TYPES,
20 apply_tree_emission,20 apply_tree_emission,
21 classify_cluster,21 classify_cluster,
Importance #30: tests/test_chroma_vegetation.py @@ -303,9 +303,9 @@
303303
304304
305def test_config_accepts_every_documented_key(tmp_path, section_values) -> None:305def test_config_accepts_every_documented_key(tmp_path, section_values) -> None:
306 """The other half: no modelled key is rejected."""306 """The other half: no modelled key is rejected."""
307 section = section_values(_model_tree.ChromaVegetationConfig)307 section = section_values("chroma_vegetation")
308 section["enabled"] = True308 section["enabled"] = True
309 path = tmp_path / "override.json"309 path = tmp_path / "override.json"
310 path.write_text(json.dumps({"chroma_vegetation": section}))310 path.write_text(json.dumps({"chroma_vegetation": section}))
311 assert _config.load_verticalsigns_config(path)["chroma_vegetation"]["enabled"]311 assert _config.load_verticalsigns_config(path)["chroma_vegetation"]["enabled"]
Importance #31: tests/test_config.py @@ -0,0 +1,195 @@
1"""Schema guards for the flat :class:`DetectorConfig` and the packaged JSON.
2
3The detector reads a FLAT config while the packaged
4``verticalsigns.default.json`` is grouped into sections, and each flat field
5declares the section and key it is loaded from (``_model_base.section_field``).
6Three things must stay true for that to be invisible to callers:
7
8* every field is reachable from the nested config document, and only from the
9 section/key it declares,
10* the model and the JSON declare exactly the same keys with the same defaults,
11* an absent key still falls back to the model default.
12"""
13
14import json
15from pathlib import Path
16
17import pydantic
18import pytest
19from iolabs.common import config_loader
20
21from iolabs_point_cloud_detection_verticalsigns import _config, _model_base
22from iolabs_point_cloud_detection_verticalsigns.config import (
23 DetectorConfig,
24 VerticalSignsConfigError,
25 build_verticalsigns_config,
26 load_default_config,
27 load_verticalsigns_config,
28 normalize_verticalsigns_config,
29)
30
31PACKAGED_JSON = (
32 Path(__file__).resolve().parents[1]
33 / "src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json"
34)
35
36
37def _packaged() -> dict:
38 return json.loads(PACKAGED_JSON.read_text(encoding="utf-8"))
39
40
41def _bounds(field: pydantic.fields.FieldInfo) -> tuple[float, float]:
42 """Return the ``(low, high)`` a field accepts, as declared by its constraints."""
43 low, high = -1e9, 1e9
44 for constraint in field.metadata:
45 low = max(low, getattr(constraint, "ge", low), getattr(constraint, "gt", low))
46 high = min(high, getattr(constraint, "le", high), getattr(constraint, "lt", high))
47 return low, high
48
49
50def _distinct_value(field: pydantic.fields.FieldInfo, salt: int) -> object:
51 """A value that differs from the field default but keeps its type and bounds."""
52 default = field.default
53 if isinstance(default, bool):
54 return not default
55 low, high = _bounds(field)
56 if isinstance(default, int):
57 return int(min(default + salt, high))
58 if isinstance(default, float):
59 step = default + salt * 0.25
60 return step if low < step < high else round((default + low) / 2 + 1e-3, 6)
61 if isinstance(default, str):
62 return f"{default}_x{salt}"
63 return default
64
65
66def _saturating_document() -> tuple[dict, dict]:
67 """Build a nested document that overrides every single field.
68
69 Returns:
70 ``(config_document, expected_field_values)``.
71 """
72 document: dict[str, dict] = {}
73 expected: dict[str, object] = {}
74 for salt, (name, field) in enumerate(DetectorConfig.model_fields.items(), start=1):
75 if field.annotation is not None and field.annotation not in (bool, int, float, str):
76 continue # Literal / tuple fields have no free-form distinct value.
77 section, key = _model_base.section_path(field)
78 value = _distinct_value(field, salt)
79 assert value != field.default, name
80 document.setdefault(section, {})[key] = value
81 expected[name] = value
82 return document, expected
83
84
85def test_model_defaults_match_packaged_json() -> None:
86 assert DetectorConfig().to_document() == _packaged()
87
88
89def test_load_verticalsigns_config_returns_packaged_defaults() -> None:
90 packaged = _packaged()
91 assert load_verticalsigns_config() == packaged
92 assert load_default_config() == packaged
93 assert build_verticalsigns_config() == packaged
94 assert normalize_verticalsigns_config({}) == packaged
95 assert json.loads(json.dumps(packaged)) == packaged # plain JSON types only
96
97
98def test_error_class_is_config_error() -> None:
99 assert issubclass(VerticalSignsConfigError, config_loader.ConfigError)
100 assert issubclass(VerticalSignsConfigError, ValueError)
101
102
103def test_unknown_top_level_key_is_rejected() -> None:
104 with pytest.raises(VerticalSignsConfigError, match="grund"):
105 DetectorConfig.from_mapping({"grund": {"cell_m": 1.0}})
106
107
108def test_unknown_nested_key_is_rejected() -> None:
109 with pytest.raises(VerticalSignsConfigError, match="cell_metres"):
110 DetectorConfig.from_mapping({"ground": {"cell_metres": 1.0}})
111
112
113def test_overrides_deep_merge_onto_defaults() -> None:
114 built = build_verticalsigns_config(overrides={"ground": {"cell_m": 1.25}})
115 assert built["ground"]["cell_m"] == 1.25
116 assert built["ground"]["percentile"] == _packaged()["ground"]["percentile"]
117 assert built["occupancy"] == _packaged()["occupancy"]
118
119
120def test_set_override_coercion_and_rejection() -> None:
121 overrides = config_loader.parse_set_overrides(
122 ["clustering.min_samples=1e3", "classification.emit_trees=on"],
123 error_cls=VerticalSignsConfigError,
124 nested=True,
125 )
126 built = DetectorConfig.from_mapping(
127 config_loader.deep_merge_dicts(load_default_config(), overrides)
128 )
129 assert built.cluster_min_samples == 1000
130 assert built.emit_trees is True
131 with pytest.raises(VerticalSignsConfigError):
132 DetectorConfig.from_mapping({"classification": {"emit_trees": "flase"}})
133
134
135def test_a_user_config_file_merges_onto_the_defaults(tmp_path) -> None:
136 """A user JSON carries only the keys it changes (``prod2_*.config.json``)."""
137 path = tmp_path / "override.json"
138 path.write_text(json.dumps({"ground": {"cell_m": 1.25}}))
139 loaded = load_verticalsigns_config(path)
140 assert loaded["ground"] == {"cell_m": 1.25, "percentile": _packaged()["ground"]["percentile"]}
141 assert DetectorConfig.load(path).ground_cell_m == 1.25
142
143
144def test_every_field_declares_a_section_path() -> None:
145 """A field without a path is unreachable from the config document."""
146 for field in DetectorConfig.model_fields.values():
147 _model_base.section_path(field)
148
149
150def test_every_field_is_reachable_from_the_nested_document() -> None:
151 document, expected = _saturating_document()
152 built = DetectorConfig.from_mapping(document)
153 wrong = {n: (getattr(built, n), v) for n, v in expected.items() if getattr(built, n) != v}
154 assert not wrong
155
156
157def test_absent_sections_fall_back_to_the_model_defaults() -> None:
158 assert DetectorConfig.from_mapping({}) == DetectorConfig()
159 assert DetectorConfig.from_mapping(load_default_config()) == DetectorConfig()
160
161
162def test_a_partial_section_only_overrides_the_keys_it_carries() -> None:
163 built = DetectorConfig.from_mapping({"ground": {"cell_m": 1.25}})
164 assert built.ground_cell_m == 1.25
165 assert built.ground_percentile == DetectorConfig().ground_percentile
166 assert built.perspective_coverage_tol_m == DetectorConfig().perspective_coverage_tol_m
167
168
169def test_flat_field_names_never_collide_with_section_names() -> None:
170 """The section expansion keys off the section names, so they must be distinct."""
171 sections = {_model_base.section_path(f)[0] for f in DetectorConfig.model_fields.values()}
172 assert not sections & set(DetectorConfig.model_fields)
173
174
175def test_the_document_round_trips_through_the_model() -> None:
176 document, _ = _saturating_document()
177 merged = config_loader.deep_merge_dicts(load_default_config(), document)
178 assert DetectorConfig.from_mapping(merged).to_document() == merged
179
180
181def test_with_overrides_rejects_a_misspelled_field() -> None:
182 """A typo must not become a new attribute while the threshold keeps its default.
183
184 ``model_copy(update=...)`` skips validation, so this is the only thing
185 standing between a misspelled override and a silently ignored threshold.
186 """
187 assert DetectorConfig().with_overrides(cluster_eps_m=0.9).cluster_eps_m == 0.9
188 with pytest.raises(ValueError, match="cluster_eps"):
189 DetectorConfig().with_overrides(cluster_eps=0.9)
190
191
192def test_the_config_module_constants_name_the_package() -> None:
193 assert _config._PACKAGE_NAME == "iolabs_point_cloud_detection_verticalsigns"
194 assert _config._DEFAULT_FILENAME == PACKAGED_JSON.name
195 assert _config._CONTEXT == "verticalsigns config"
0
Importance #32: tests/test_config_split.py @@ -1,143 +0,0 @@
1"""Schema guards for the section-split :class:`DetectorConfig`.
2
3``config.py`` no longer declares the 370 fields itself: they live in the
4``_config_<section>`` slices and are recombined by multiple inheritance, and
5``from_mapping`` is the merge of the slices' ``*_kwargs`` functions. Three
6things must stay true for that split to be invisible to callers:
7
8* every field is still reachable from the nested config document,
9* the slices partition the fields (no field lost, none declared twice),
10* an absent key still falls back to the model default.
11"""
12
13import json
14import re
15from pathlib import Path
16
17from iolabs.common import config_loader
18
19from iolabs_point_cloud_detection_verticalsigns import _config_model
20from iolabs_point_cloud_detection_verticalsigns._config import load_default_config
21from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig
22
23CONFIG_PY = (
24 Path(__file__).resolve().parents[1]
25 / "src/iolabs_point_cloud_detection_verticalsigns/config.py"
26)
27
28
29def _distinct_value(default: object, salt: int) -> object:
30 """A value that differs from *default* but keeps its type."""
31 if isinstance(default, bool):
32 return not default
33 if isinstance(default, int):
34 return default + salt
35 if isinstance(default, float):
36 return default + salt * 0.25
37 if isinstance(default, str):
38 return f"{default}_x{salt}"
39 return default
40
41
42def _saturating_config() -> tuple[dict, dict]:
43 """Builds a nested config that overrides every single field.
44
45 Returns:
46 ``(config_document, expected_field_values)``.
47 """
48 section_locals: dict[str, str] = {}
49 document: dict[str, dict] = {}
50 expected: dict[str, object] = {}
51 fields = DetectorConfig.model_fields
52
53 for path in sorted(CONFIG_PY.parent.glob("_config_*.py")):
54 text = path.read_text()
55 section_locals.update(
56 dict(re.findall(r'^ (\w+) = config\.get\("([^"]+)", \{\}\)$', text, re.M))
57 )
58 for salt, (field_name, local, key) in enumerate(
59 re.findall(r'"(\w+)": (\w+)\.get\(\s*"([^"]+)"', text), start=1
60 ):
61 value = _distinct_value(fields[field_name].default, salt + len(expected))
62 document.setdefault(section_locals[local], {})[key] = value
63 expected[field_name] = value
64
65 return document, expected
66
67
68def test_every_field_is_reachable_from_the_nested_document() -> None:
69 document, expected = _saturating_config()
70 built = DetectorConfig.from_mapping(document)
71 wrong = {n: (getattr(built, n), v) for n, v in expected.items() if getattr(built, n) != v}
72 assert not wrong
73
74
75def test_absent_sections_fall_back_to_the_model_defaults() -> None:
76 assert DetectorConfig.from_mapping({}) == DetectorConfig()
77
78
79def test_a_partial_section_only_overrides_the_keys_it_carries() -> None:
80 built = DetectorConfig.from_mapping({"ground": {"cell_m": 1.25}})
81 assert built.ground_cell_m == 1.25
82 assert built.ground_percentile == DetectorConfig().ground_percentile
83 assert built.perspective_coverage_tol_m == DetectorConfig().perspective_coverage_tol_m
84
85
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
97def test_the_packaged_defaults_round_trip() -> None:
98 packaged = load_default_config()
99 assert json.loads(json.dumps(packaged)) == packaged # plain JSON types only
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")
128
129
130def test_the_packaged_json_declares_exactly_the_model_keys() -> None:
131 """The packaged JSON and the model must not drift apart in SHAPE either.
132
133 ``load_verticalsigns_config`` returns the validated model dump, so a key
134 the model declares but the JSON omits would be injected into the returned
135 document (and a JSON key the model lacks would be rejected outright).
136 """
137 packaged = json.loads(
138 (CONFIG_PY.parent / "verticalsigns.default.json").read_text(encoding="utf-8")
139 )
140 model = _config_model.VerticalSignsConfig().model_dump(mode="json")
141 assert {s: sorted(keys) for s, keys in packaged.items()} == {
142 s: sorted(keys) for s, keys in model.items()
143 }
0
Importance #33: tests/test_tree_instances.py @@ -17,9 +17,9 @@
1717
18import numpy as np18import numpy as np
19import pytest19import pytest
2020
21from iolabs_point_cloud_detection_verticalsigns import _config, _model_tree21from iolabs_point_cloud_detection_verticalsigns import _config
22from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig22from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig
23from iolabs_point_cloud_detection_verticalsigns.tree_instances import (23from iolabs_point_cloud_detection_verticalsigns.tree_instances import (
24 ABSTAIN_ASSIGNED,24 ABSTAIN_ASSIGNED,
25 ABSTAIN_HEDGE,25 ABSTAIN_HEDGE,
Importance #34: tests/test_tree_instances.py @@ -1070,9 +1070,9 @@
1070 _config.load_verticalsigns_config(path)1070 _config.load_verticalsigns_config(path)
10711071
10721072
1073def test_config_accepts_every_documented_key(tmp_path, section_values) -> None:1073def test_config_accepts_every_documented_key(tmp_path, section_values) -> None:
1074 section = section_values(_model_tree.TreeInstanceConfig)1074 section = section_values("tree_instance")
1075 section["enabled"] = True1075 section["enabled"] = True
1076 path = tmp_path / "override.json"1076 path = tmp_path / "override.json"
1077 path.write_text(json.dumps({"tree_instance": section}))1077 path.write_text(json.dumps({"tree_instance": section}))
1078 assert _config.load_verticalsigns_config(path)["tree_instance"]["enabled"]1078 assert _config.load_verticalsigns_config(path)["tree_instance"]["enabled"]
Importance #35: BRIEF.md @@ -95,10 +95,10 @@
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 and validates the result against the98 a user `--config` JSON over the defaults and validates the result against the
99 `VerticalSignsConfig` pydantic model tree (`_config_model.py` +99 `DetectorConfig` pydantic model (`_config.py` + the `_model_<topic>.py`
100 `_model_<slice>.py`), which rejects unknown keys and bad values. Logging100 field slices), which rejects unknown sections/keys and bad values. Logging
101 uses `iolabs.logstash.get_props_logger(__name__, LOG_PROPS)`.101 uses `iolabs.logstash.get_props_logger(__name__, LOG_PROPS)`.
102- 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]`
103 (segment IDs zero-padded to 3); `python -m103 (segment IDs zero-padded to 3); `python -m
104 iolabs_point_cloud_detection_verticalsigns.detect ...` is equivalent.104 iolabs_point_cloud_detection_verticalsigns.detect ...` is equivalent.
Importance #36: README.md @@ -18,32 +18,33 @@
1818
19`python -m iolabs_point_cloud_detection_verticalsigns.detect ...` works too, as19`python -m iolabs_point_cloud_detection_verticalsigns.detect ...` works too, as
20does `uv run verticalsigns-views ...` for the per-detection close-up renders.20does `uv run verticalsigns-views ...` for the per-detection close-up renders.
2121
22Thresholds live in the packaged `verticalsigns.default.json`, one nested22## Configuration
23section per detector stage (`ground`, `occupancy`, `candidates`, `clustering`,23
24`classification`, `radius`, `corridor`, `context`, `delineator`, `sign_post`,24Defaults live in `src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json`,
25`panel`, `gantry`, `repetitive_row`, `field_stake`, `marker_extract`,25one nested section per detector stage (`ground`, `occupancy`, `candidates`,
26`rail_halfpost`, `reject_rescue`, `road_context`, `edge_line`, `tree`,26`clustering`, `classification`, `radius`, `corridor`, `context`, `delineator`,
27`tree_detection`, `tree_instance`, `chroma_vegetation`, `tcs_ground`,27`sign_post`, `panel`, `gantry`, `repetitive_row`, `field_stake`,
28`conic_gate`, `conifer_rule`, `vehicle`, `views`, `perspective`). Pass28`marker_extract`, `rail_halfpost`, `reject_rescue`, `road_context`,
29`--config` to deep-merge a partial JSON over those defaults; unknown keys and29`edge_line`, `tree`, `tree_detection`, `tree_instance`, `chroma_vegetation`,
30bad values are rejected.30`tcs_ground`, `conic_gate`, `conifer_rule`, `vehicle`, `views`, `perspective`).
3131
32The schema of that JSON is the `VerticalSignsConfig` pydantic model tree32The schema is `DetectorConfig` in `_config` (a `config_loader.ConfigModel`),
33(`_config_model.py` plus the `_model_<slice>.py` sections, built on33re-exported from `...verticalsigns.config` and `...verticalsigns`. The model is
34`iolabs.common.config_loader.ConfigModel`): one nested model per JSON section,34FLAT โ€” the detector modules read `config.ground_cell_m` โ€” while the JSON is
35one field per key, and the two sides must agree key for key (guarded by35sectioned, so every field names the section and key it is loaded from right
36`tests/test_config_split.py`). **Adding a config key = add the field to its36where it is declared (`ground_cell_m: float = section_field("ground.cell_m",
37section model and the same default to `verticalsigns.default.json`** โ€” plus,370.75)`, in the `_model_<topic>.py` slices). Unknown sections and keys are
38if a detector module reads it, the flat field and `*_kwargs` line below.38rejected. **To add a config key: add the field (with its type, default and any
3939`Field` range) to the model and the same key with the same default to the JSON
40The 379-field `DetectorConfig` is the FLAT view the detector modules read40โ€” nothing else.**
41(`config.ground_cell_m`): it is declared across the `_config_<section>` modules41
42and recombined in `config.py`, which re-exports every name โ€” import from42`load_verticalsigns_config`, `load_default_config`, `build_verticalsigns_config`
43`...verticalsigns.config` exactly as before. A new key that the detector reads43and `normalize_verticalsigns_config` return a plain nested `dict`;
44needs its flat field here and the `*_kwargs` line that maps the section key44`DetectorConfig.load` / `.from_mapping` return the frozen model. Runtime
45onto it; a key only consumed from the nested document (e.g. `views`) does not.45overrides come from `--config <partial.json>`, deep-merged over the packaged
46defaults; never a repo-local full copy of the JSON.
4647
47## QC rendering is an optional extra48## QC rendering is an optional extra
4849
49`verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need50`verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need
Importance #37: README.md @@ -18,32 +18,33 @@
1818
19`python -m iolabs_point_cloud_detection_verticalsigns.detect ...` works too, as19`python -m iolabs_point_cloud_detection_verticalsigns.detect ...` works too, as
20does `uv run verticalsigns-views ...` for the per-detection close-up renders.20does `uv run verticalsigns-views ...` for the per-detection close-up renders.
2121
22Thresholds live in the packaged `verticalsigns.default.json`, one nested22## Configuration
23section per detector stage (`ground`, `occupancy`, `candidates`, `clustering`,23
24`classification`, `radius`, `corridor`, `context`, `delineator`, `sign_post`,24Defaults live in `src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json`,
25`panel`, `gantry`, `repetitive_row`, `field_stake`, `marker_extract`,25one nested section per detector stage (`ground`, `occupancy`, `candidates`,
26`rail_halfpost`, `reject_rescue`, `road_context`, `edge_line`, `tree`,26`clustering`, `classification`, `radius`, `corridor`, `context`, `delineator`,
27`tree_detection`, `tree_instance`, `chroma_vegetation`, `tcs_ground`,27`sign_post`, `panel`, `gantry`, `repetitive_row`, `field_stake`,
28`conic_gate`, `conifer_rule`, `vehicle`, `views`, `perspective`). Pass28`marker_extract`, `rail_halfpost`, `reject_rescue`, `road_context`,
29`--config` to deep-merge a partial JSON over those defaults; unknown keys and29`edge_line`, `tree`, `tree_detection`, `tree_instance`, `chroma_vegetation`,
30bad values are rejected.30`tcs_ground`, `conic_gate`, `conifer_rule`, `vehicle`, `views`, `perspective`).
3131
32The schema of that JSON is the `VerticalSignsConfig` pydantic model tree32The schema is `DetectorConfig` in `_config` (a `config_loader.ConfigModel`),
33(`_config_model.py` plus the `_model_<slice>.py` sections, built on33re-exported from `...verticalsigns.config` and `...verticalsigns`. The model is
34`iolabs.common.config_loader.ConfigModel`): one nested model per JSON section,34FLAT โ€” the detector modules read `config.ground_cell_m` โ€” while the JSON is
35one field per key, and the two sides must agree key for key (guarded by35sectioned, so every field names the section and key it is loaded from right
36`tests/test_config_split.py`). **Adding a config key = add the field to its36where it is declared (`ground_cell_m: float = section_field("ground.cell_m",
37section model and the same default to `verticalsigns.default.json`** โ€” plus,370.75)`, in the `_model_<topic>.py` slices). Unknown sections and keys are
38if a detector module reads it, the flat field and `*_kwargs` line below.38rejected. **To add a config key: add the field (with its type, default and any
3939`Field` range) to the model and the same key with the same default to the JSON
40The 379-field `DetectorConfig` is the FLAT view the detector modules read40โ€” nothing else.**
41(`config.ground_cell_m`): it is declared across the `_config_<section>` modules41
42and recombined in `config.py`, which re-exports every name โ€” import from42`load_verticalsigns_config`, `load_default_config`, `build_verticalsigns_config`
43`...verticalsigns.config` exactly as before. A new key that the detector reads43and `normalize_verticalsigns_config` return a plain nested `dict`;
44needs its flat field here and the `*_kwargs` line that maps the section key44`DetectorConfig.load` / `.from_mapping` return the frozen model. Runtime
45onto it; a key only consumed from the nested document (e.g. `views`) does not.45overrides come from `--config <partial.json>`, deep-merged over the packaged
46defaults; never a repo-local full copy of the JSON.
4647
47## QC rendering is an optional extra48## QC rendering is an optional extra
4849
49`verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need50`verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need
Importance #38: src/iolabs_point_cloud_detection_verticalsigns/__init__.py @@ -2,13 +2,15 @@
22
3from importlib.metadata import PackageNotFoundError, version3from importlib.metadata import PackageNotFoundError, version
44
5from ._config import (5from ._config import (
6 DetectorConfig,
6 VerticalSignsConfigError,7 VerticalSignsConfigError,
8 build_verticalsigns_config,
7 load_default_config,9 load_default_config,
8 load_verticalsigns_config,10 load_verticalsigns_config,
11 normalize_verticalsigns_config,
9)12)
10from .config import DetectorConfig
1113
12try:14try:
13 __version__ = version("iolabs-point-cloud-detection-verticalsigns")15 __version__ = version("iolabs-point-cloud-detection-verticalsigns")
14except PackageNotFoundError: # pragma: no cover - source tree without an install16except PackageNotFoundError: # pragma: no cover - source tree without an install
Importance #39: src/iolabs_point_cloud_detection_verticalsigns/__init__.py @@ -16,8 +18,10 @@
1618
17__all__ = [19__all__ = [
18 "DetectorConfig",20 "DetectorConfig",
19 "VerticalSignsConfigError",21 "VerticalSignsConfigError",
22 "build_verticalsigns_config",
20 "load_default_config",23 "load_default_config",
21 "load_verticalsigns_config",24 "load_verticalsigns_config",
25 "normalize_verticalsigns_config",
22 "__version__",26 "__version__",
23]27]
Importance #40: src/iolabs_point_cloud_detection_verticalsigns/_config.py @@ -1,43 +1,314 @@
1"""Packaged-default configuration loading and validation for the detector.1"""Configuration of the vertical-sign detector.
22
3The canonical configuration lives in ``verticalsigns.default.json`` packaged3The schema is `DetectorConfig` (a `config_loader.ConfigModel`), mirroring
4next to this module, and its schema is the :class:`VerticalSignsConfig` pydantic4`verticalsigns.default.json` key for key: the JSON is grouped into sections
5model tree in ``_config_model``. ``load_verticalsigns_config`` returns a5while the model is flat (``config.ground_cell_m``), and every field names the
6validated plain dict that deep-merges an optional user JSON over those defaults,6section and key it is loaded from via ``_model_base.section_field``.
7rejecting unknown keys (per section) and bad values with a clear error. The7
8internal :class:`DetectorConfig` model is built from that dict via8Adding a config key means adding the field to the model โ€” one of the
9``DetectorConfig.from_mapping``.9``_model_<topic>`` slices this module recombines โ€” and the same key to
1010`verticalsigns.default.json`; nothing else. Unknown keys are rejected.
11Loading, deep-merge and validation are provided by11
12``iolabs.common.config_loader``; the schema and entrypoints stay here.12``load_verticalsigns_config`` / ``build_verticalsigns_config`` return the
13config as a plain nested ``dict``; ``DetectorConfig.load`` returns the frozen
14model the detector modules read.
13"""15"""
1416
15from __future__ import annotations17from __future__ import annotations
1618
17import json
18import logging19import logging
20from collections.abc import Mapping
19from pathlib import Path21from pathlib import Path
20from typing import Any22from typing import Any
2123
24import pydantic
22from iolabs.common import config_loader25from iolabs.common import config_loader
2326
24from ._config_model import VerticalSignsConfig27from . import _model_base
28from ._model_conic import VerticalSignsConicFields
29from ._model_corridor import VerticalSignsCorridorFields
30from ._model_devices import VerticalSignsDeviceFields
31from ._model_evidence import VerticalSignsEvidenceFields
32from ._model_grid import VerticalSignsGridFields
33from ._model_perspective import VerticalSignsPerspectiveFields, VerticalSignsViewsFields
34from ._model_road import VerticalSignsRoadContextFields
35from ._model_stages import VerticalSignsStageFields
36from ._model_treedetect import VerticalSignsTreeDetectionFields
37from ._model_treeinstance import VerticalSignsTreeInstanceFields
38from ._model_vegetation import VerticalSignsVegetationFields
2539
26logger = logging.getLogger(__name__)40logger = logging.getLogger(__name__)
2741
28_PACKAGE_NAME = "iolabs_point_cloud_detection_verticalsigns"42_PACKAGE_NAME = "iolabs_point_cloud_detection_verticalsigns"
29_DEFAULT_RESOURCE = "verticalsigns.default.json"43_DEFAULT_FILENAME = "verticalsigns.default.json"
30_CONTEXT = "verticalsigns config"44_CONTEXT = "verticalsigns config"
3145
46_SECTION_MAPS: dict[type, dict[str, dict[str, str]]] = {}
47
3248
33class VerticalSignsConfigError(config_loader.ConfigError):49class VerticalSignsConfigError(config_loader.ConfigError):
34 """Raised when the vertical sign detector config contains unsupported keys."""50 """Raised when verticalsigns config contains unsupported keys or values."""
3551
3652
37def load_default_config() -> dict[str, Any]:53class DetectorConfig( # noqa: D101 - docstring below, after the base list
38 """Return a fresh copy of the packaged default configuration."""54 # The bases are listed in REVERSE section order ON PURPOSE: pydantic
39 return load_verticalsigns_config()55 # collects fields by walking the MRO backwards, so this ordering reproduces
56 # the original single-class field order exactly (ground first, then
57 # perspective, then the slices added since). Reordering these lines
58 # reorders the fields, so a NEW slice goes at the TOP of this list to have
59 # its fields appended at the end.
60 VerticalSignsViewsFields,
61 VerticalSignsTreeInstanceFields,
62 VerticalSignsPerspectiveFields,
63 VerticalSignsConicFields,
64 VerticalSignsTreeDetectionFields,
65 VerticalSignsStageFields,
66 VerticalSignsEvidenceFields,
67 VerticalSignsCorridorFields,
68 VerticalSignsRoadContextFields,
69 VerticalSignsVegetationFields,
70 VerticalSignsDeviceFields,
71 VerticalSignsGridFields,
72):
73 """Spatial and geometric thresholds, in metres unless stated otherwise.
74
75 The fields are flat; the config document they are loaded from is grouped
76 into sections. Both shapes validate: ``DetectorConfig(cluster_eps_m=0.9)``
77 for a test or an ad-hoc call, and ``DetectorConfig.from_mapping({...})``
78 for the packaged JSON and user override files.
79 """
80
81 @pydantic.model_validator(mode="before")
82 @classmethod
83 def _flatten_sections(cls, data: Any) -> Any:
84 """Translate the nested config document into flat field values.
85
86 A top-level key naming a section is expanded into the flat fields its
87 keys declare; a mapping carrying no section at all is passed through
88 untouched, so one already keyed by field names (a ``model_dump``, or
89 explicit keyword arguments) validates unchanged.
90
91 Args:
92 data: The raw input handed to pydantic.
93
94 Returns:
95 The input keyed by flat field name.
96
97 Raises:
98 ValueError: The document names a section, or a key inside one, that
99 the model does not declare.
100 """
101 if not isinstance(data, Mapping):
102 return data
103 sections = _section_map(cls)
104 # A mapping VALUE is a section body: no flat field takes a mapping, so
105 # an all-unknown nested document is still reported section-wise.
106 nested = any(
107 name in sections or isinstance(value, Mapping) for name, value in data.items()
108 )
109 if not nested:
110 return data
111 strays = sorted(set(data) - set(sections) - set(cls.model_fields))
112 if strays:
113 raise ValueError(
114 f"Unknown {_CONTEXT} section(s): {', '.join(strays)}. "
115 f"Allowed sections: {', '.join(sorted(sections))}"
116 )
117 flat: dict[str, Any] = {}
118 for name, value in data.items():
119 keys = sections.get(name)
120 if keys is None:
121 flat[name] = value
122 continue
123 if not isinstance(value, Mapping):
124 raise ValueError(
125 f"{_CONTEXT} section {name} must be a mapping, "
126 f"got {type(value).__name__}"
127 )
128 unknown = sorted(set(value) - set(keys))
129 if unknown:
130 raise ValueError(
131 f"Unknown {_CONTEXT}.{name} key(s): {', '.join(unknown)}. "
132 f"Allowed keys: {', '.join(sorted(keys))}"
133 )
134 flat.update({keys[key]: item for key, item in value.items()})
135 return flat
136
137 @classmethod
138 def from_mapping(cls, config: Mapping[str, Any]) -> DetectorConfig:
139 """Build a config from a nested config document.
140
141 Only keys the document carries override the corresponding model
142 default, so a partial (or empty) document reproduces the built-in
143 thresholds exactly.
144
145 Args:
146 config: The nested config document, e.g. the packaged defaults
147 merged with a user JSON.
148
149 Returns:
150 The validated, frozen configuration.
151
152 Raises:
153 VerticalSignsConfigError: The document holds an unknown section or
154 key, or a value that is invalid for its field.
155 """
156 return config_loader.validate_config(
157 cls, config, context=_CONTEXT, error_cls=VerticalSignsConfigError
158 )
159
160 def to_document(self) -> dict[str, dict[str, Any]]:
161 """Return this config as the nested document shape of the packaged JSON.
162
163 Returns:
164 Section name to key to value, as plain JSON types.
165 """
166 dumped = self.model_dump(mode="json")
167 document: dict[str, dict[str, Any]] = {}
168 for name, field in type(self).model_fields.items():
169 section, key = _model_base.section_path(field)
170 document.setdefault(section, {})[key] = dumped[name]
171 return document
172
173 def with_overrides(self, **overrides: Any) -> DetectorConfig:
174 """Return a copy of this config with *overrides* applied.
175
176 ``model_copy(update=...)`` skips validation, so a misspelled name would
177 be attached as a new attribute and a wrongly typed value would be
178 stored uncoerced. The names are checked here and the values are run
179 through the model.
180
181 Args:
182 overrides: Flat field name to new value, e.g. ``cluster_eps_m=0.9``.
183
184 Returns:
185 A new frozen config carrying *overrides*.
186
187 Raises:
188 ValueError: An override names a field this config does not declare,
189 or carries a value the field rejects (a
190 ``pydantic.ValidationError``, itself a ``ValueError``).
191 """
192 unknown = sorted(set(overrides) - set(type(self).model_fields))
193 if unknown:
194 raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}")
195 return type(self).model_validate({**self.model_dump(), **overrides})
196
197 @classmethod
198 def load(cls, config_path: str | Path | None = None) -> DetectorConfig:
199 """Load the packaged defaults, merged with an optional user JSON.
200
201 Args:
202 config_path: Optional user JSON deep-merged over the packaged
203 defaults, as in :func:`load_verticalsigns_config`.
204
205 Returns:
206 The validated, frozen configuration.
207
208 Raises:
209 VerticalSignsConfigError: The JSON is malformed, or the merged
210 config holds an unknown section/key or an invalid value.
211 """
212 return _load_model(config_path=config_path)
213
214
215def _section_map(model_cls: type[DetectorConfig]) -> dict[str, dict[str, str]]:
216 """Return section name to config key to flat field name for *model_cls*.
217
218 Args:
219 model_cls: The flat config model.
220
221 Returns:
222 The nested-to-flat key map, built once and cached on the class.
223 """
224 cached = _SECTION_MAPS.get(model_cls)
225 if cached is None:
226 cached = {}
227 for name, field in model_cls.model_fields.items():
228 section, key = _model_base.section_path(field)
229 cached.setdefault(section, {})[key] = name
230 _SECTION_MAPS[model_cls] = cached
231 return cached
232
233
234def _load_model(
235 *,
236 overrides: Mapping[str, Any] | None = None,
237 config_path: str | Path | None = None,
238) -> DetectorConfig:
239 """Load, merge and validate the packaged defaults into the model.
240
241 Args:
242 overrides: Nested mapping deep-merged over the defaults.
243 config_path: Optional user JSON, itself deep-merged over the defaults.
244
245 Returns:
246 The validated, frozen configuration.
247
248 Raises:
249 VerticalSignsConfigError: The JSON is malformed, or the merged config
250 holds an unknown section/key or an invalid value.
251 """
252 merged: dict[str, Any] = {}
253 if config_path is not None:
254 merged = config_loader.load_json_overrides(config_path, error_cls=VerticalSignsConfigError)
255 logger.info("Config file applied: %s", config_path)
256 if overrides:
257 merged = config_loader.deep_merge_dicts(merged, dict(overrides))
258 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
259 return config_loader.load_config(
260 DetectorConfig,
261 package=_PACKAGE_NAME,
262 filename=_DEFAULT_FILENAME,
263 overrides=merged or None,
264 context=_CONTEXT,
265 error_cls=VerticalSignsConfigError,
266 )
267
268
269def normalize_verticalsigns_config(raw_config: Mapping[str, Any]) -> dict[str, Any]:
270 """Validate a nested config document and fill in the model defaults.
271
272 Args:
273 raw_config: The nested config document to validate.
274
275 Returns:
276 The validated document as plain JSON types: every section and every key
277 the model declares is present, defaults included.
278
279 Raises:
280 VerticalSignsConfigError: The document holds an unknown section/key or
281 an invalid value.
282 """
283 return DetectorConfig.from_mapping(raw_config).to_document()
284
285
286def build_verticalsigns_config(
287 *,
288 overrides: Mapping[str, Any] | None = None,
289 config_path: str | Path | None = None,
290) -> dict[str, Any]:
291 """Load the packaged defaults with overrides and an optional user JSON on top.
292
293 Unlike the shared-layer default, *config_path* MERGES onto the packaged
294 defaults rather than replacing them: a user file carries only the keys it
295 changes (``{"tree_detection": {"enabled": true}}``).
296
297 Args:
298 overrides: Nested mapping deep-merged over the defaults, e.g. the
299 result of ``config_loader.parse_set_overrides``.
300 config_path: Optional user JSON deep-merged over the defaults, below
301 *overrides*.
302
303 Returns:
304 The validated config document as plain JSON types.
305
306 Raises:
307 VerticalSignsConfigError: The JSON is malformed, or the merged config
308 holds an unknown section/key or an invalid value.
309 """
310 return _load_model(overrides=overrides, config_path=config_path).to_document()
40311
41312
42def load_verticalsigns_config(config_path: str | Path | None = None) -> dict[str, Any]:313def load_verticalsigns_config(config_path: str | Path | None = None) -> dict[str, Any]:
43 """Load the detector config, deep-merging an optional user JSON over the defaults.314 """Load the detector config, deep-merging an optional user JSON over the defaults.
Importance #41: src/iolabs_point_cloud_detection_verticalsigns/_config.py @@ -53,30 +324,14 @@
53 Raises:324 Raises:
54 VerticalSignsConfigError: The user JSON is malformed, or the merged325 VerticalSignsConfigError: The user JSON is malformed, or the merged
55 config holds an unknown section/key or an invalid value.326 config holds an unknown section/key or an invalid value.
56 """327 """
57 overrides = _read_user_config(config_path) if config_path is not None else None328 return build_verticalsigns_config(config_path=config_path)
58 config = config_loader.load_config(329
59 VerticalSignsConfig,330
60 package=_PACKAGE_NAME,331def load_default_config() -> dict[str, Any]:
61 filename=_DEFAULT_RESOURCE,332 """Return a fresh copy of the packaged default configuration.
62 overrides=overrides,333
63 context=_CONTEXT,334 Returns:
64 error_cls=VerticalSignsConfigError,335 The packaged defaults as plain JSON types.
65 )336 """
66 return config.model_dump(mode="json")337 return build_verticalsigns_config()
67
68
69def _read_user_config(config_path: str | Path) -> dict[str, Any]:
70 """Read a user config JSON, wrapping decode errors in the package error."""
71 path = Path(config_path)
72 try:
73 with path.open("r", encoding="utf-8") as handle:
74 user_config: Any = json.load(handle)
75 except json.JSONDecodeError as exc:
76 raise VerticalSignsConfigError(f"Invalid JSON in {path}: {exc}") from exc
77 if not isinstance(user_config, dict):
78 raise VerticalSignsConfigError(
79 f"{path} must hold a JSON object, not a {type(user_config).__name__}"
80 )
81 logger.debug("Loaded %s overrides from %s", _CONTEXT, path)
82 return user_config
Importance #42: src/iolabs_point_cloud_detection_verticalsigns/_config_conic.py @@ -1,210 +0,0 @@
1"""The colour-free conic gate and the conifer rule that rides on it.
2
3One slice of the flat ``DetectorConfig``, moved out of
4``config.py`` verbatim. ``config.py`` recombines the slices and
5re-exports both names defined here.
6"""
7
8from typing import Any
9
10from iolabs.common import config_loader
11
12
13class ConicFields(config_loader.ConfigModel):
14 """The colour-free conic gate and the conifer rule that rides on it.
15
16 Metres unless stated otherwise.
17 """
18
19 # Colour-free conic gate (AI3D-339): an OR-bypass around the vegetation RF
20 # for conifers. The RF cannot pass them (its positives contained none, and
21 # crown_isotropy is information-free for cone-vs-pole), so a rule is the
22 # only path that surfaces them. TWO-CUE by design -- shape AND surface
23 # texture -- because a single cue family cannot separate foliage from a
24 # mast. SHIPS OFF; thresholds below are unvalidated seeds pending the
25 # real-distribution dump, and emissions are tagged reason="conic_rule".
26 conic_gate_enabled: bool = False
27 conic_taper_slope_max: float = -0.4
28 # The taper must survive dropping any single decile. Measured on real
29 # A4_5 data, every cluster that faked a cone had its whole slope carried
30 # by one decile -- a ground skirt at the base or one twig at the top.
31 conic_taper_slope_robust_max: float = -0.3
32 conic_apex_deg_min: float = 5.0
33 conic_apex_deg_max: float = 35.0
34 conic_h_over_width_min: float = 1.5
35 conic_h_over_width_max: float = 12.0
36 # Texture conjunct: foliage is scattering-rough, a pole/mast is smooth.
37 # Reads the EXISTING eigenfeature fields. Disable to A/B the shape cue
38 # alone during diagnostics; it is on whenever the gate itself is on.
39 conic_texture_cue_enabled: bool = True
40 conic_change_of_curvature_min: float = 0.06
41 conic_omnivariance_min: float = 0.10
42 conic_max_hi_intensity_fraction: float = 0.2
43 conic_h_max_min_m: float = 2.5
44 conic_max_on_road_fraction: float = 0.6
45 # Abstention guard -- an occlusion-starved radius profile must not be
46 # allowed to fake a conifer's taper.
47 conic_min_decile_fill_fraction: float = 0.8
48 # Minimum crown footprint. A taper says how the radius CHANGES with height
49 # but says nothing about absolute size, so a 0.34 x 0.18 m post 3 m tall
50 # satisfies every shape test while being far too thin to be a crown.
51 # Calibrated on the 143-segment A4_5 sweep: the three thinnest conic
52 # emissions (0.061 / 0.177 / 0.256 m2) were independently judged posts or
53 # bare stems in visual review, while 47 of the 51 clusters the trained
54 # vegetation RF accepted sit above 0.5 m2.
55 conic_min_crown_area_m2: float = 0.3
56
57 # --- conifer rule (AI3D-339) -------------------------------------------
58 # A SECOND, independent bypass. The conic rule above selects for foliage
59 # reaching the ground -- shrub mounds, hedge banks -- because it fits the
60 # taper over the whole cluster. A conifer carrying its crown above a bare
61 # trunk has the opposite profile and is structurally rejected there. This
62 # rule reads the crown-relative fields instead, so it can accept one.
63 #
64 # These thresholds are MORPHOLOGICAL PRIORS, not fitted values: the corpus
65 # contains a single visually-confirmed clean conifer, which is far too few
66 # to calibrate against without overfitting. They are deliberately loose,
67 # to be narrowed once emissions have been reviewed.
68 conifer_rule_enabled: bool = False
69 # THE DISCRIMINATOR, and it is not a shape term. Thirteen candidates were
70 # rendered as 360-degree orbits and labelled by three independent blind
71 # judges; no shape feature separated the five confirmed conifers from the
72 # six confirmed non-conifers (stem_ratio: conifers 0.46-2.08, others
73 # 0.96-1.64 -- fully overlapping). Every judge instead gave the same
74 # reason, "densely filled" versus "see-through twiggy", and a density
75 # BAND separates the labelled set perfectly:
76 #
77 # conifers 154 191 208 278 332
78 # leaf-off 98 116 130 (bare April twigs return little)
79 # hedge/thicket 679 745 853 (a solid mass, not a tree)
80 #
81 # Physically: a conifer is dense foliage on an OPEN branching tree, so it
82 # sits between bare deciduous and a solid hedge. Unlike the shape terms
83 # these bounds ARE fitted -- to 11 labels, which is few -- so they are set
84 # at the midpoints of the observed gaps to maximise margin, and both
85 # contested candidates fall outside the band.
86 conifer_min_volumetric_density: float = 140.0
87 conifer_max_volumetric_density: float = 380.0
88 # Shape sanity only; NOT the discriminator (see above). Kept loose enough
89 # to admit every confirmed conifer, including merged pairs whose base is
90 # widened by the neighbour they were clustered with.
91 conifer_max_stem_ratio: float = 2.2
92 # A point at the top rather than a flat or broadening crown.
93 conifer_max_apex_ratio: float = 0.75
94 # The crown limb must actually taper.
95 conifer_max_crown_taper: float = -0.10
96 # The crown must sit low enough to be a cone, not a mushroom.
97 conifer_max_crown_base_frac: float = 0.55
98 # Slenderness of the whole object: a spire, not a bush and not a mast.
99 conifer_h_over_width_min: float = 2.0
100 conifer_h_over_width_max: float = 15.0
101 conifer_h_max_min_m: float = 2.0
102 # Foliage is scattering-rough; a pole or a fence face is smooth.
103 conifer_min_change_of_curvature: float = 0.04
104 # Not retroreflective, not over the carriageway, not starved of deciles.
105 conifer_max_hi_intensity_fraction: float = 0.2
106 conifer_max_on_road_fraction: float = 0.6
107 conifer_min_decile_fill_fraction: float = 0.8
108 conifer_min_crown_area_m2: float = 0.2
109
110
111def conic_kwargs(config: dict[str, Any], defaults: ConicFields) -> dict[str, Any]:
112 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
113
114 Sections read: ``conic_gate``, ``conifer_rule``.
115
116 Args:
117 config: The nested config document, not a single section.
118 defaults: Instance supplying the fallback for every absent key.
119
120 Returns:
121 The ``ConicFields`` keyword arguments, defaults filled in.
122 """
123 conic_gate = config.get("conic_gate", {})
124 conifer = config.get("conifer_rule", {})
125 return {
126 "conic_gate_enabled": conic_gate.get("enabled", defaults.conic_gate_enabled),
127 "conic_taper_slope_max": conic_gate.get(
128 "taper_slope_max", defaults.conic_taper_slope_max
129 ),
130 "conic_taper_slope_robust_max": conic_gate.get(
131 "taper_slope_robust_max", defaults.conic_taper_slope_robust_max
132 ),
133 "conic_apex_deg_min": conic_gate.get(
134 "apex_deg_min", defaults.conic_apex_deg_min
135 ),
136 "conic_apex_deg_max": conic_gate.get(
137 "apex_deg_max", defaults.conic_apex_deg_max
138 ),
139 "conic_h_over_width_min": conic_gate.get(
140 "h_over_width_min", defaults.conic_h_over_width_min
141 ),
142 "conic_h_over_width_max": conic_gate.get(
143 "h_over_width_max", defaults.conic_h_over_width_max
144 ),
145 "conic_texture_cue_enabled": conic_gate.get(
146 "texture_cue_enabled", defaults.conic_texture_cue_enabled
147 ),
148 "conic_change_of_curvature_min": conic_gate.get(
149 "change_of_curvature_min", defaults.conic_change_of_curvature_min
150 ),
151 "conic_omnivariance_min": conic_gate.get(
152 "omnivariance_min", defaults.conic_omnivariance_min
153 ),
154 "conic_max_hi_intensity_fraction": conic_gate.get(
155 "max_hi_intensity_fraction", defaults.conic_max_hi_intensity_fraction
156 ),
157 "conic_h_max_min_m": conic_gate.get(
158 "h_max_min_m", defaults.conic_h_max_min_m
159 ),
160 "conic_max_on_road_fraction": conic_gate.get(
161 "max_on_road_fraction", defaults.conic_max_on_road_fraction
162 ),
163 "conic_min_decile_fill_fraction": conic_gate.get(
164 "min_decile_fill_fraction", defaults.conic_min_decile_fill_fraction
165 ),
166 "conic_min_crown_area_m2": conic_gate.get(
167 "min_crown_area_m2", defaults.conic_min_crown_area_m2
168 ),
169 "conifer_rule_enabled": conifer.get("enabled", defaults.conifer_rule_enabled),
170 "conifer_max_stem_ratio": conifer.get(
171 "max_stem_ratio", defaults.conifer_max_stem_ratio
172 ),
173 "conifer_min_volumetric_density": conifer.get(
174 "min_volumetric_density", defaults.conifer_min_volumetric_density
175 ),
176 "conifer_max_volumetric_density": conifer.get(
177 "max_volumetric_density", defaults.conifer_max_volumetric_density
178 ),
179 "conifer_max_apex_ratio": conifer.get(
180 "max_apex_ratio", defaults.conifer_max_apex_ratio
181 ),
182 "conifer_max_crown_taper": conifer.get(
183 "max_crown_taper", defaults.conifer_max_crown_taper
184 ),
185 "conifer_max_crown_base_frac": conifer.get(
186 "max_crown_base_frac", defaults.conifer_max_crown_base_frac
187 ),
188 "conifer_h_over_width_min": conifer.get(
189 "h_over_width_min", defaults.conifer_h_over_width_min
190 ),
191 "conifer_h_over_width_max": conifer.get(
192 "h_over_width_max", defaults.conifer_h_over_width_max
193 ),
194 "conifer_h_max_min_m": conifer.get("h_max_min_m", defaults.conifer_h_max_min_m),
195 "conifer_min_change_of_curvature": conifer.get(
196 "min_change_of_curvature", defaults.conifer_min_change_of_curvature
197 ),
198 "conifer_max_hi_intensity_fraction": conifer.get(
199 "max_hi_intensity_fraction", defaults.conifer_max_hi_intensity_fraction
200 ),
201 "conifer_max_on_road_fraction": conifer.get(
202 "max_on_road_fraction", defaults.conifer_max_on_road_fraction
203 ),
204 "conifer_min_decile_fill_fraction": conifer.get(
205 "min_decile_fill_fraction", defaults.conifer_min_decile_fill_fraction
206 ),
207 "conifer_min_crown_area_m2": conifer.get(
208 "min_crown_area_m2", defaults.conifer_min_crown_area_m2
209 ),
210 }
0
Importance #43: src/iolabs_point_cloud_detection_verticalsigns/_config_corridor.py @@ -1,200 +0,0 @@
1"""Road corridor rasterization and on-carriageway rejection.
2
3Also plate planarity, the bright-panel class and the free-space ring.
4
5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.
8"""
9
10from typing import Any
11
12from iolabs.common import config_loader
13
14
15class CorridorFields(config_loader.ConfigModel):
16 """Road corridor rasterization and on-carriageway rejection.
17
18 Also plate planarity, the bright-panel class and the free-space ring.
19
20 Metres unless stated otherwise.
21 """
22
23 # Road corridor (rasterized on the ground-grid geometry).
24 max_dist_to_road_m: float = 10.0
25 on_carriageway_dist_m: float = 0.25
26 on_carriageway_exempt_h_max_m: float = 4.5
27 # Carriageway isolation: run4 over-extends the fitted road plane onto verge /
28 # field-track areas with a sparse point density (segment 000). Keep only
29 # cells whose run4 count clears a segment-adaptive density floor
30 # (max of an absolute floor and a fraction of the p95 cell count), then keep
31 # the connected component(s) covering the main carriageway.
32 corridor_density_min_points: float = 8.0
33 corridor_density_frac_p95: float = 0.06
34 # Cap on the p95-scaled density floor. On heavily-overscanned segments the
35 # main carriageway core is sampled by many overlapping run4 passes, so its
36 # p95 cell count balloons (segment 134: p95~8100 โ†’ floor 487) and the floor
37 # over-drops legitimately-paved but less-densely-scanned branch roads / gore
38 # aprons / ramps (134's apron cells hold ~170-210 returns). The cap keeps the
39 # floor at a road-vs-extrapolation boundary (~150) regardless of how dense the
40 # core is. It only lowers the floor where density_frac_p95*p95 exceeds it, so
41 # genuinely sparse segments (000's vineyard field track, floor 152, field
42 # cells <150) are unchanged and their extrapolated planes stay dropped.
43 corridor_density_max_points: float = 150.0
44 corridor_component_min_area_frac: float = 0.15
45 # A dense run4 component is kept when it is either a decent fraction of the
46 # largest (component_min_area_frac) OR clears an absolute cell-area floor. A
47 # branch road / apron forms its own component disconnected from the main
48 # carriageway across the curb gap; on a long junction tile it is far smaller
49 # than the through-road, so the fractional test alone drops it. run4 holds
50 # road-surface points only, so a dense component of this size is road.
51 corridor_component_min_area_cells: int = 40
52 # On-carriageway rejection: a cluster whose footprint sits (almost) entirely
53 # over genuine road cells is a vehicle / on-road object, rejected for every
54 # class except tall gantry legs (h_max >= on_carriageway_exempt_h_max_m).
55 # Edge delineators keep a mixed footprint and stay below this fraction.
56 on_carriageway_road_fraction: float = 0.7
57 # An on-carriageway cluster is only kept if it is a genuine marker: either
58 # volumetrically dense (a static post/plate packs points) or brightly
59 # retroreflective (a wide guide panel overhanging the edge, segment 006).
60 # A dull, sparse blob on the carriageway is a vehicle / debris smear.
61 min_volumetric_density: float = 8000.0
62 on_carriageway_bright_frac: float = 0.5
63 # Delineator-shape exemption from on-carriageway rejection. The corridor
64 # density cap can extend the kept road mask onto paved shoulders / medians,
65 # so genuine edge delineators end up sitting (almost) entirely over road
66 # cells and get swept up by the on-carriageway rejection (segments 076, 123).
67 # A moving-vehicle smear is never a sub-delineator-height, sub-0.65 m,
68 # near-perfectly-vertical retroreflective column, so a cluster matching that
69 # delineator signature is exempt and allowed to reach the delineator gates.
70 # The len_major cap (0.65 m) sits below the 114/130 vehicle-smear footprints
71 # (1.25 x 0.66 / 1.28 x 0.77), so those FPs stay rejected.
72 on_carriageway_delineator_max_len_major_m: float = 0.65
73 on_carriageway_delineator_min_verticality: float = 0.95
74
75 # Plate planarity: a real sign plate is a thin slab, so the smallest 3D
76 # covariance eigenvalue of its upper-half points (plate_thickness_m) is small.
77 # Vegetation clumps are volumetric and thick. Gate the sign class on it.
78 sign_max_plate_thickness_m: float = 0.15
79
80 # Bright panel (segment 114): a real chevron/warning panel (Richtungstafel)
81 # can sit below the sign_post_h_min_m post-height floor (a low roadside
82 # panel, not a tall post-mounted plate). It is still a thin, bright, planar
83 # slab of plausible plate width, so gate it on brightness, thinness, height,
84 # width and vertical continuity directly rather than routing it through the
85 # post logic.
86 panel_min_hi: float = 0.40
87 panel_max_thickness_m: float = 0.20
88 panel_h_min_m: float = 0.9
89 # A genuine chevron panel is a WIDE board (segment 114's reads 2.95 m).
90 # The 1.5 m floor keeps narrow bright low posts/plates (segment 134's
91 # 1.25 m roadside marker) out of the panel class.
92 panel_len_major_min_m: float = 1.5
93 panel_len_major_max_m: float = 5.0
94
95 # Free-space ring: real plate-less posts (sign_post/pole_other/delineator)
96 # stand clear, so a cylindrical ring around the cluster axis holds few
97 # non-cluster candidate points. Bush interiors, saplings and forest trunks
98 # sit inside filled rings. Also reject a plate-less candidate embedded in a
99 # forest context (several tall neighbouring clusters nearby).
100 ring_r_inner_m: float = 0.5
101 ring_r_outer_m: float = 1.5
102 ring_h_min_m: float = 0.5
103 ring_h_max_m: float = 2.5
104 # Ring fill measured as the ratio of non-cluster ring points to the cluster's
105 # own point count; a sapling/trunk embedded in foliage has a ring several
106 # times denser than itself, a real clear-standing post has a near-empty ring.
107 ring_max_fill_ratio: float = 2.0
108 ring_min_points: int = 40
109 forest_min_neighbors: int = 3
110 forest_radius_m: float = 8.0
111 forest_neighbor_min_h_max_m: float = 2.0
112
113
114def corridor_kwargs(config: dict[str, Any], defaults: CorridorFields) -> dict[str, Any]:
115 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
116
117 Sections read: ``classification``, ``corridor``, ``context``, ``sign_post``, ``panel``.
118
119 Args:
120 config: The nested config document, not a single section.
121 defaults: Instance supplying the fallback for every absent key.
122
123 Returns:
124 The ``CorridorFields`` keyword arguments, defaults filled in.
125 """
126 classification = config.get("classification", {})
127 corridor = config.get("corridor", {})
128 context = config.get("context", {})
129 sign_post = config.get("sign_post", {})
130 panel = config.get("panel", {})
131 return {
132 "max_dist_to_road_m": corridor.get("max_dist_to_road_m", defaults.max_dist_to_road_m),
133 "on_carriageway_dist_m": corridor.get(
134 "on_carriageway_dist_m", defaults.on_carriageway_dist_m
135 ),
136 "on_carriageway_exempt_h_max_m": corridor.get(
137 "on_carriageway_exempt_h_max_m", defaults.on_carriageway_exempt_h_max_m
138 ),
139 "corridor_density_min_points": corridor.get(
140 "density_min_points", defaults.corridor_density_min_points
141 ),
142 "corridor_density_frac_p95": corridor.get(
143 "density_frac_p95", defaults.corridor_density_frac_p95
144 ),
145 "corridor_density_max_points": corridor.get(
146 "density_max_points", defaults.corridor_density_max_points
147 ),
148 "corridor_component_min_area_frac": corridor.get(
149 "component_min_area_frac", defaults.corridor_component_min_area_frac
150 ),
151 "corridor_component_min_area_cells": corridor.get(
152 "component_min_area_cells", defaults.corridor_component_min_area_cells
153 ),
154 "on_carriageway_road_fraction": corridor.get(
155 "on_carriageway_road_fraction", defaults.on_carriageway_road_fraction
156 ),
157 "on_carriageway_bright_frac": corridor.get(
158 "on_carriageway_bright_frac", defaults.on_carriageway_bright_frac
159 ),
160 "on_carriageway_delineator_max_len_major_m": corridor.get(
161 "on_carriageway_delineator_max_len_major_m",
162 defaults.on_carriageway_delineator_max_len_major_m,
163 ),
164 "on_carriageway_delineator_min_verticality": corridor.get(
165 "on_carriageway_delineator_min_verticality",
166 defaults.on_carriageway_delineator_min_verticality,
167 ),
168 "min_volumetric_density": classification.get(
169 "min_volumetric_density", defaults.min_volumetric_density
170 ),
171 "sign_max_plate_thickness_m": sign_post.get(
172 "max_plate_thickness_m", defaults.sign_max_plate_thickness_m
173 ),
174 "panel_min_hi": panel.get("min_hi", defaults.panel_min_hi),
175 "panel_max_thickness_m": panel.get(
176 "max_thickness_m", defaults.panel_max_thickness_m
177 ),
178 "panel_h_min_m": panel.get("h_min_m", defaults.panel_h_min_m),
179 "panel_len_major_min_m": panel.get(
180 "len_major_min_m", defaults.panel_len_major_min_m
181 ),
182 "panel_len_major_max_m": panel.get(
183 "len_major_max_m", defaults.panel_len_major_max_m
184 ),
185 "ring_r_inner_m": context.get("ring_r_inner_m", defaults.ring_r_inner_m),
186 "ring_r_outer_m": context.get("ring_r_outer_m", defaults.ring_r_outer_m),
187 "ring_h_min_m": context.get("ring_h_min_m", defaults.ring_h_min_m),
188 "ring_h_max_m": context.get("ring_h_max_m", defaults.ring_h_max_m),
189 "ring_max_fill_ratio": context.get(
190 "ring_max_fill_ratio", defaults.ring_max_fill_ratio
191 ),
192 "ring_min_points": context.get("ring_min_points", defaults.ring_min_points),
193 "forest_min_neighbors": context.get(
194 "forest_min_neighbors", defaults.forest_min_neighbors
195 ),
196 "forest_radius_m": context.get("forest_radius_m", defaults.forest_radius_m),
197 "forest_neighbor_min_h_max_m": context.get(
198 "forest_neighbor_min_h_max_m", defaults.forest_neighbor_min_h_max_m
199 ),
200 }
0
Importance #44: src/iolabs_point_cloud_detection_verticalsigns/_config_devices.py @@ -1,248 +0,0 @@
1"""Per-device thresholds for delineators, sign posts and gantries.
2
3Also isolated-floating-pole rejection and duplicate suppression.
4
5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.
8"""
9
10from typing import Any
11
12from iolabs.common import config_loader
13
14
15class DeviceFields(config_loader.ConfigModel):
16 """Per-device thresholds for delineators, sign posts and gantries.
17
18 Also isolated-floating-pole rejection and duplicate suppression.
19
20 Metres unless stated otherwise.
21 """
22
23 # Delineator (Leitpfosten). The height ceiling (1.5 m) and footprint cap
24 # (0.45 m) admit taller guide posts and the mild along-track smear that gore
25 # posts pick up in MLS (segment 131's junction posts read 0.42 m major,
26 # h 1.2-1.5); real Leitpfosten cores stay ~0.12 m so the cap change does not
27 # widen the class into vehicles/vegetation.
28 delineator_h_min_m: float = 0.7
29 delineator_h_max_m: float = 1.5
30 delineator_max_footprint_m: float = 0.45
31 # Relaxed footprint band for a delineator whose along-track MLS smear at a
32 # junction/gore pushes its major extent past the tight 0.45 m cap (segment
33 # 134's splitter-island posts read 0.47-0.63 m major). Only admitted when the
34 # cluster is strongly vertical (a genuine post), so a flat bright road-marking
35 # fragment (verticality ~0.1) can never sneak in through the wider cap. Purely
36 # additive: clusters at or under delineator_max_footprint_m keep the original
37 # (verticality-free) path, so no existing detection is affected.
38 # 0.65 -> 0.85 (AI3D-339 pass 3): Abschnitt-1 Leitpfosten merge with verge
39 # grass into 0.67-0.83 m clusters that keep verticality ~0.99; the 0.65 cap
40 # was the single failing conjunct for 8 adversarially judged-real posts.
41 # At 0.85: A4_5 +3 judged-real delineators / 0 lost; A1 +~18 judged-real vs
42 # +5 judged-veg. Real (0.66-0.83) and FP (0.68-0.85) footprints fully
43 # overlap, so no tighter cap separates them โ€” the veg leak is a texture
44 # problem (multi-radius plate regularity, task #14), not a threshold one.
45 delineator_relaxed_footprint_m: float = 0.85
46 delineator_relaxed_min_verticality: float = 0.85
47 # The wider relaxed band admits more smear, so it is guarded harder than the
48 # compact path: the post must stand clear (a near-empty free-space ring, so a
49 # bright speck embedded in roadside vegetation โ€” segment 084 โ€” is rejected)
50 # and be clearly retroreflective (a higher brightness floor than the compact
51 # 0.08, so a modest-brightness on-carriageway edge feature โ€” segment 096 โ€” is
52 # rejected). Genuine gore/island posts pass both (ring ~0, hi 0.28-0.66).
53 delineator_relaxed_max_ring_fill_ratio: float = 1.0
54 delineator_relaxed_min_hi_intensity_fraction: float = 0.15
55 delineator_min_hi_intensity_fraction: float = 0.08
56 # Real Leitpfosten return a few hundred points; sub-~300 bright specks are
57 # reflective vegetation/debris (segment 048 FP had ~100; segment 084's bright
58 # speck embedded in verge scrub, newly reachable once the corridor keeps
59 # branch roads, had 239). Every genuine delineator across the dataset returns
60 # >=371, so the 300 floor drops those specks with margin to spare.
61 delineator_min_points: int = 300
62
63 # Sign post / plate
64 sign_post_max_len_minor_m: float = 0.8
65 sign_post_h_min_m: float = 1.5
66 sign_post_h_max_m: float = 6.0
67 sign_post_min_continuity: float = 0.60
68 # Plate evidence needs strong retroreflectivity: verified real sign plates
69 # (segments 006/030/132/134) return an upper-half high-intensity fraction of
70 # 0.44-0.94, while every dull false-positive "sign" (vegetation mounds,
71 # crash-cushion / truck-rear slabs, forest trunks, vegetation bands) sits at
72 # <=0.35. The gate is set at 0.40 so plate evidence requires a genuine bright
73 # panel; the weak path allows a moderately-bright, upper-piled plate.
74 plate_hi_intensity_fraction: float = 0.40
75 plate_hi_intensity_fraction_weak: float = 0.30
76 # Upper-half point pile-up ratio required as weak-plate evidence and as
77 # plate *shape*. Raised to 2.0 so a mere ~1.7 surplus (roadside bush crowns,
78 # segment 048 FPs) no longer counts as a plate; real plates pile far more
79 # returns up high (good signs sit at 2.8-4.6, or carry a broad bright core).
80 sign_plate_upper_surplus_ratio: float = 2.0
81 # A genuine plate sits high on its post, so the upper half must hold at least
82 # as many returns as ~1/3 of the lower half. Low-lying bright blobs at the
83 # foot of a vehicle/truck (segment 106 FPs at ~0.09) are not plates.
84 sign_min_upper_half_surplus: float = 0.30
85 # A real sign PLATE spreads returns laterally (broad core) or piles them in
86 # the upper half; brightness alone on a tight thin core is a reflective
87 # post/speck, not a plate โ€” route it to the (stricter) bare-post path.
88 plate_min_core_rms_m: float = 0.10
89
90 # Bare posts (no plate evidence) must be tall, tight, vertical, and
91 # well-sampled. 0.065 m tightness rejects tall roadside vegetation (whose
92 # per-bin core reaches ~0.17 m); real marker posts sit near ~0.04 m. The
93 # point-count floor rejects small bright reflective specks (~<450 returns).
94 # Plate-less posts below gantry-leg height are indistinguishable from tree
95 # guards / fence posts by LiDAR geometry alone (confirmed FP in seg 132).
96 bare_post_min_h_max_m: float = 4.5
97 bare_post_max_core_rms_m: float = 0.065
98 bare_post_min_verticality: float = 0.90
99 bare_post_min_points: int = 450
100
101 # Isolated floating-pole rejection (far-range boundary ghost, defect class 1a).
102 # A "floating" pole_other whose base sits well off the ground (h_min high โ€” no
103 # ground-connected shaft, just an upper vertical smear) is a range-smear
104 # artifact at the far edge of dense coverage (segments 005, 015: a lone
105 # ~10 m column floating over the carriageway vanishing point) UNLESS it is one
106 # of several such columns clustered together (a genuine gantry-leg / mast group
107 # โ€” segments 046, 066, 025). Verified across the full sweep: the only isolated
108 # floating poles (no floating-pole neighbour within pole_isolated_radius_m) are
109 # exactly the 005/015 ghosts; every real gantry-leg pole has >=1 neighbour.
110 pole_floating_min_h_min_m: float = 3.5
111 pole_isolated_radius_m: float = 8.0
112
113 # Post-classification duplicate suppression (defect class 4). Two detections
114 # within dedup_radius_m XY of each other describe the same physical marker
115 # (e.g. a striped gore post firing both a delineator and a sign); keep the
116 # higher-priority type (sign > delineator > sign_post > pole_other >
117 # gantry_or_gate), breaking ties by point count, and drop the other.
118 dedup_radius_m: float = 0.8
119
120 # Gantry / gate
121 gantry_h_min_m: float = 4.5
122 gantry_len_major_m: float = 8.0
123 # A road-spanning overhead beam is thin; a tilted reflective truck-trailer
124 # slab (segment 106) is broad (len_minor ~9.8 m). Cap the single-cluster
125 # overhead_span footprint minor extent (real gantry cluster ~4.75 m).
126 gantry_max_len_minor_m: float = 6.0
127 gantry_pair_station_tolerance_m: float = 5.0
128 # Narrow overhead gates (segment 066: two ~10 m retroreflective legs ~3.7 m
129 # apart straddling a ramp) must still pair, so the minimum lateral
130 # separation is 3.0 m; the overhead-return test guards against false pairs.
131 gantry_pair_min_separation_m: float = 3.0
132 gantry_overhead_h_min_m: float = 4.5
133 # A synthesized gantry from a pair of tall posts is only trustworthy when the
134 # pair is ISOLATED โ€” no third tall post nearby. Two ~10 m legs straddling a
135 # ramp with nothing between them is a real gate (segment 066); three-or-more
136 # tall columns clustered at one station are a post row / mast group whose
137 # pairwise "span" crosses empty air (segments 046, 025 โ€” the QC ghosts). If a
138 # third tall post lies within this radius of the pair midpoint, the pairing is
139 # rejected. (The overhead middle-of-span test cannot separate these โ€” verified
140 # from points: 066's real gate also has an empty mid-span, so post COUNT, not
141 # overhead support, is the discriminator.)
142 gantry_pair_isolation_radius_m: float = 8.0
143
144
145def device_kwargs(config: dict[str, Any], defaults: DeviceFields) -> dict[str, Any]:
146 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
147
148 Sections read: ``classification``, ``delineator``, ``sign_post``, ``gantry``.
149
150 Args:
151 config: The nested config document, not a single section.
152 defaults: Instance supplying the fallback for every absent key.
153
154 Returns:
155 The ``DeviceFields`` keyword arguments, defaults filled in.
156 """
157 classification = config.get("classification", {})
158 delineator = config.get("delineator", {})
159 sign_post = config.get("sign_post", {})
160 gantry = config.get("gantry", {})
161 return {
162 "pole_floating_min_h_min_m": classification.get(
163 "pole_floating_min_h_min_m", defaults.pole_floating_min_h_min_m
164 ),
165 "pole_isolated_radius_m": classification.get(
166 "pole_isolated_radius_m", defaults.pole_isolated_radius_m
167 ),
168 "dedup_radius_m": classification.get(
169 "dedup_radius_m", defaults.dedup_radius_m
170 ),
171 "delineator_h_min_m": delineator.get("h_min_m", defaults.delineator_h_min_m),
172 "delineator_h_max_m": delineator.get("h_max_m", defaults.delineator_h_max_m),
173 "delineator_max_footprint_m": delineator.get(
174 "max_footprint_m", defaults.delineator_max_footprint_m
175 ),
176 "delineator_relaxed_footprint_m": delineator.get(
177 "relaxed_footprint_m", defaults.delineator_relaxed_footprint_m
178 ),
179 "delineator_relaxed_min_verticality": delineator.get(
180 "relaxed_min_verticality", defaults.delineator_relaxed_min_verticality
181 ),
182 "delineator_relaxed_max_ring_fill_ratio": delineator.get(
183 "relaxed_max_ring_fill_ratio",
184 defaults.delineator_relaxed_max_ring_fill_ratio,
185 ),
186 "delineator_relaxed_min_hi_intensity_fraction": delineator.get(
187 "relaxed_min_hi_intensity_fraction",
188 defaults.delineator_relaxed_min_hi_intensity_fraction,
189 ),
190 "delineator_min_hi_intensity_fraction": delineator.get(
191 "min_hi_intensity_fraction", defaults.delineator_min_hi_intensity_fraction
192 ),
193 "delineator_min_points": delineator.get(
194 "min_points", defaults.delineator_min_points
195 ),
196 "sign_post_max_len_minor_m": sign_post.get(
197 "max_len_minor_m", defaults.sign_post_max_len_minor_m
198 ),
199 "sign_post_h_min_m": sign_post.get("h_min_m", defaults.sign_post_h_min_m),
200 "sign_post_h_max_m": sign_post.get("h_max_m", defaults.sign_post_h_max_m),
201 "sign_post_min_continuity": sign_post.get(
202 "min_continuity", defaults.sign_post_min_continuity
203 ),
204 "plate_hi_intensity_fraction": sign_post.get(
205 "plate_hi_intensity_fraction", defaults.plate_hi_intensity_fraction
206 ),
207 "plate_hi_intensity_fraction_weak": sign_post.get(
208 "plate_hi_intensity_fraction_weak", defaults.plate_hi_intensity_fraction_weak
209 ),
210 "sign_plate_upper_surplus_ratio": sign_post.get(
211 "plate_upper_surplus_ratio", defaults.sign_plate_upper_surplus_ratio
212 ),
213 "sign_min_upper_half_surplus": sign_post.get(
214 "min_upper_half_surplus", defaults.sign_min_upper_half_surplus
215 ),
216 "plate_min_core_rms_m": sign_post.get(
217 "plate_min_core_rms_m", defaults.plate_min_core_rms_m
218 ),
219 "bare_post_min_h_max_m": sign_post.get(
220 "bare_post_min_h_max_m", defaults.bare_post_min_h_max_m
221 ),
222 "bare_post_max_core_rms_m": sign_post.get(
223 "bare_post_max_core_rms_m", defaults.bare_post_max_core_rms_m
224 ),
225 "bare_post_min_verticality": sign_post.get(
226 "bare_post_min_verticality", defaults.bare_post_min_verticality
227 ),
228 "bare_post_min_points": sign_post.get(
229 "bare_post_min_points", defaults.bare_post_min_points
230 ),
231 "gantry_h_min_m": gantry.get("h_min_m", defaults.gantry_h_min_m),
232 "gantry_len_major_m": gantry.get("len_major_m", defaults.gantry_len_major_m),
233 "gantry_max_len_minor_m": gantry.get(
234 "max_len_minor_m", defaults.gantry_max_len_minor_m
235 ),
236 "gantry_pair_station_tolerance_m": gantry.get(
237 "pair_station_tolerance_m", defaults.gantry_pair_station_tolerance_m
238 ),
239 "gantry_pair_min_separation_m": gantry.get(
240 "pair_min_separation_m", defaults.gantry_pair_min_separation_m
241 ),
242 "gantry_overhead_h_min_m": gantry.get(
243 "overhead_h_min_m", defaults.gantry_overhead_h_min_m
244 ),
245 "gantry_pair_isolation_radius_m": gantry.get(
246 "pair_isolation_radius_m", defaults.gantry_pair_isolation_radius_m
247 ),
248 }
0
Importance #45: src/iolabs_point_cloud_detection_verticalsigns/_config_grid.py @@ -1,131 +0,0 @@
1"""Ground, occupancy grid, candidate band and clustering thresholds.
2
3Also the first classification gates and vehicle rejection.
4
5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.
8"""
9
10from typing import Any
11
12from iolabs.common import config_loader
13
14
15class GridFields(config_loader.ConfigModel):
16 """Ground, occupancy grid, candidate band and clustering thresholds.
17
18 Also the first classification gates and vehicle rejection.
19
20 Metres unless stated otherwise.
21 """
22
23 # Ground model
24 ground_cell_m: float = 0.75
25 ground_percentile: float = 8.0
26
27 # Occupancy grid for candidate cells
28 occupancy_cell_m: float = 0.15
29
30 # Height band for off-ground candidate points
31 min_height_m: float = 0.30
32 max_height_m: float = 10.0
33
34 # Seed-cell gates (vertical span and max height above ground)
35 seed_min_vertical_span_m: float = 0.80
36 seed_min_h_max_m: float = 0.90
37
38 # Delineator recall seed pass. German Leitpfosten are ~1.0 m and, when
39 # sparsely sampled at range, span only ~0.75 m inside a 0.15 m occupancy
40 # cell (base clipped by min_height_m=0.30), so they fall just under the
41 # 0.80 m primary span gate and never seed a cluster โ€” the round-4 recall
42 # gap. A second, relaxed seed pass recovers them, but is restricted to
43 # cells holding >= seed_bright_min_points retroreflective returns
44 # (intensity >= the segment's hi-intensity threshold): a Leitpfosten head
45 # is always retroreflective, so the extra candidate cells stay few and the
46 # existing delineator gates + FP defenses (brightness, footprint, density,
47 # corridor, ring/forest) decide the verdict.
48 seed_bright_min_vertical_span_m: float = 0.45
49 seed_bright_min_h_max_m: float = 0.60
50 seed_bright_min_points: int = 3
51
52 # DBSCAN clustering on seed-cell centres
53 cluster_eps_m: float = 0.45
54 cluster_min_samples: int = 1
55 cluster_hull_margin_m: float = 0.20
56
57 # Per-cluster feature bins
58 continuity_bin_m: float = 0.25
59
60 # Classification thresholds
61 reject_len_major_m: float = 6.0
62 reject_h_max_with_large_footprint_m: float = 4.5
63 min_continuity: float = 0.50
64 min_accept_h_max_m: float = 0.90
65
66 # Vehicle rejection
67 vehicle_h_min_m: float = 1.5
68 vehicle_h_max_m: float = 4.5
69 vehicle_len_major_m: float = 2.5
70 vehicle_len_minor_m: float = 1.5
71 vehicle_max_hi_intensity_fraction: float = 0.10
72
73
74def grid_kwargs(config: dict[str, Any], defaults: GridFields) -> dict[str, Any]:
75 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
76
77 Sections read: ``ground``, ``occupancy``, ``candidates``, ``clustering``,
78 ``classification``, ``vehicle``.
79
80 Args:
81 config: The nested config document, not a single section.
82 defaults: Instance supplying the fallback for every absent key.
83
84 Returns:
85 The ``GridFields`` keyword arguments, defaults filled in.
86 """
87 ground = config.get("ground", {})
88 occupancy = config.get("occupancy", {})
89 candidates = config.get("candidates", {})
90 clustering = config.get("clustering", {})
91 classification = config.get("classification", {})
92 vehicle = config.get("vehicle", {})
93 return {
94 "ground_cell_m": ground.get("cell_m", defaults.ground_cell_m),
95 "ground_percentile": ground.get("percentile", defaults.ground_percentile),
96 "occupancy_cell_m": occupancy.get("cell_m", defaults.occupancy_cell_m),
97 "min_height_m": candidates.get("min_height_m", defaults.min_height_m),
98 "max_height_m": candidates.get("max_height_m", defaults.max_height_m),
99 "seed_min_vertical_span_m": candidates.get(
100 "seed_min_vertical_span_m", defaults.seed_min_vertical_span_m
101 ),
102 "seed_min_h_max_m": candidates.get("seed_min_h_max_m", defaults.seed_min_h_max_m),
103 "seed_bright_min_vertical_span_m": candidates.get(
104 "seed_bright_min_vertical_span_m",
105 defaults.seed_bright_min_vertical_span_m,
106 ),
107 "seed_bright_min_h_max_m": candidates.get(
108 "seed_bright_min_h_max_m", defaults.seed_bright_min_h_max_m
109 ),
110 "seed_bright_min_points": candidates.get(
111 "seed_bright_min_points", defaults.seed_bright_min_points
112 ),
113 "cluster_eps_m": clustering.get("eps_m", defaults.cluster_eps_m),
114 "cluster_min_samples": clustering.get("min_samples", defaults.cluster_min_samples),
115 "cluster_hull_margin_m": clustering.get("hull_margin_m", defaults.cluster_hull_margin_m),
116 "continuity_bin_m": classification.get("continuity_bin_m", defaults.continuity_bin_m),
117 "reject_len_major_m": classification.get("reject_len_major_m", defaults.reject_len_major_m),
118 "reject_h_max_with_large_footprint_m": classification.get(
119 "reject_h_max_with_large_footprint_m",
120 defaults.reject_h_max_with_large_footprint_m,
121 ),
122 "min_continuity": classification.get("min_continuity", defaults.min_continuity),
123 "min_accept_h_max_m": classification.get("min_accept_h_max_m", defaults.min_accept_h_max_m),
124 "vehicle_h_min_m": vehicle.get("h_min_m", defaults.vehicle_h_min_m),
125 "vehicle_h_max_m": vehicle.get("h_max_m", defaults.vehicle_h_max_m),
126 "vehicle_len_major_m": vehicle.get("len_major_m", defaults.vehicle_len_major_m),
127 "vehicle_len_minor_m": vehicle.get("len_minor_m", defaults.vehicle_len_minor_m),
128 "vehicle_max_hi_intensity_fraction": vehicle.get(
129 "max_hi_intensity_fraction", defaults.vehicle_max_hi_intensity_fraction
130 ),
131 }
0
Importance #46: src/iolabs_point_cloud_detection_verticalsigns/_config_model.py @@ -1,48 +0,0 @@
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 the same default to the packaged JSON; the two
7sides must stay in lockstep, and ``tests/test_config_split.py`` fails if they
8drift. A key the detector modules read also needs its flat ``DetectorConfig``
9field and the ``*_kwargs`` line that maps it (see ``config.py``).
10"""
11
12from iolabs.common import config_loader
13
14from . import _model_devices, _model_grid, _model_road, _model_tree
15
16
17class VerticalSignsConfig(config_loader.ConfigModel):
18 """Every configuration section of the vertical-sign detector."""
19
20 ground: _model_grid.GroundConfig = _model_grid.GroundConfig()
21 occupancy: _model_grid.OccupancyConfig = _model_grid.OccupancyConfig()
22 candidates: _model_grid.CandidatesConfig = _model_grid.CandidatesConfig()
23 clustering: _model_grid.ClusteringConfig = _model_grid.ClusteringConfig()
24 classification: _model_grid.ClassificationConfig = _model_grid.ClassificationConfig()
25 corridor: _model_grid.CorridorConfig = _model_grid.CorridorConfig()
26 context: _model_grid.ContextConfig = _model_grid.ContextConfig()
27 delineator: _model_devices.DelineatorConfig = _model_devices.DelineatorConfig()
28 sign_post: _model_devices.SignPostConfig = _model_devices.SignPostConfig()
29 panel: _model_devices.PanelConfig = _model_devices.PanelConfig()
30 gantry: _model_devices.GantryConfig = _model_devices.GantryConfig()
31 repetitive_row: _model_devices.RepetitiveRowConfig = _model_devices.RepetitiveRowConfig()
32 road_context: _model_road.RoadContextConfig = _model_road.RoadContextConfig()
33 edge_line: _model_road.EdgeLineConfig = _model_road.EdgeLineConfig()
34 field_stake: _model_devices.FieldStakeConfig = _model_devices.FieldStakeConfig()
35 marker_extract: _model_devices.MarkerExtractConfig = _model_devices.MarkerExtractConfig()
36 tree: _model_tree.TreeConfig = _model_tree.TreeConfig()
37 tree_detection: _model_tree.TreeDetectionConfig = _model_tree.TreeDetectionConfig()
38 chroma_vegetation: _model_tree.ChromaVegetationConfig = _model_tree.ChromaVegetationConfig()
39 vehicle: _model_grid.VehicleConfig = _model_grid.VehicleConfig()
40 views: _model_road.ViewsConfig = _model_road.ViewsConfig()
41 perspective: _model_road.PerspectiveConfig = _model_road.PerspectiveConfig()
42 tree_instance: _model_tree.TreeInstanceConfig = _model_tree.TreeInstanceConfig()
43 conic_gate: _model_tree.ConicGateConfig = _model_tree.ConicGateConfig()
44 conifer_rule: _model_tree.ConiferRuleConfig = _model_tree.ConiferRuleConfig()
45 radius: _model_grid.RadiusConfig = _model_grid.RadiusConfig()
46 rail_halfpost: _model_devices.RailHalfpostConfig = _model_devices.RailHalfpostConfig()
47 reject_rescue: _model_devices.RejectRescueConfig = _model_devices.RejectRescueConfig()
48 tcs_ground: _model_tree.TcsGroundConfig = _model_tree.TcsGroundConfig()
0
Importance #47: src/iolabs_point_cloud_detection_verticalsigns/_config_perspective.py @@ -1,96 +0,0 @@
1"""Perspective-projection QC overlay cameras and coverage tolerances.
2
3One slice of the flat ``DetectorConfig``, moved out of
4``config.py`` verbatim. ``config.py`` recombines the slices and
5re-exports both names defined here.
6"""
7
8from typing import Any
9
10from iolabs.common import config_loader
11
12
13class PerspectiveFields(config_loader.ConfigModel):
14 """Perspective-projection QC overlay cameras and coverage tolerances.
15
16 Metres unless stated otherwise.
17 """
18
19 # Perspective-projection QC overlay (verticalsigns-perspective). A projected
20 # vertical-line sample is "visible" when its camera-space depth is within
21 # perspective_depth_tol_m of the rendered depth-buffer value; occluded
22 # samples are drawn faint at perspective_occluded_alpha.
23 perspective_depth_tol_m: float = 0.5
24 perspective_line_samples: int = 20
25 perspective_occluded_alpha: int = 90
26 perspective_solid_width_px: int = 3
27 perspective_halo_width_px: int = 6
28 perspective_base_marker_radius_px: int = 6
29 # Synthesized fallback cameras for detections that no Azure metadata camera
30 # covers (outside every frustum, or projecting onto a void/black background).
31 # An 'auto_back' camera sits perspective_back_distance_m behind the detection
32 # along the road axis at perspective_back_height_m above z_ground; an
33 # 'auto_context' camera sits farther back and higher for scene context.
34 # Uncovered detections within perspective_share_radius_m share one camera pair
35 # aimed at their centroid. A detection counts as covered by a camera when its
36 # projected vertical line lands on rendered geometry within
37 # perspective_coverage_tol_m of the depth buffer.
38 perspective_back_distance_m: float = 22.0
39 perspective_back_height_m: float = 4.0
40 perspective_context_distance_m: float = 40.0
41 perspective_context_height_m: float = 6.0
42 perspective_share_radius_m: float = 15.0
43 perspective_coverage_tol_m: float = 0.5
44
45
46def perspective_kwargs(config: dict[str, Any], defaults: PerspectiveFields) -> dict[str, Any]:
47 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
48
49 Sections read: ``perspective``.
50
51 Args:
52 config: The nested config document, not a single section.
53 defaults: Instance supplying the fallback for every absent key.
54
55 Returns:
56 The ``PerspectiveFields`` keyword arguments, defaults filled in.
57 """
58 perspective = config.get("perspective", {})
59 return {
60 "perspective_depth_tol_m": perspective.get(
61 "depth_tol_m", defaults.perspective_depth_tol_m
62 ),
63 "perspective_line_samples": perspective.get(
64 "line_samples", defaults.perspective_line_samples
65 ),
66 "perspective_occluded_alpha": perspective.get(
67 "occluded_alpha", defaults.perspective_occluded_alpha
68 ),
69 "perspective_solid_width_px": perspective.get(
70 "solid_width_px", defaults.perspective_solid_width_px
71 ),
72 "perspective_halo_width_px": perspective.get(
73 "halo_width_px", defaults.perspective_halo_width_px
74 ),
75 "perspective_base_marker_radius_px": perspective.get(
76 "base_marker_radius_px", defaults.perspective_base_marker_radius_px
77 ),
78 "perspective_back_distance_m": perspective.get(
79 "back_distance_m", defaults.perspective_back_distance_m
80 ),
81 "perspective_back_height_m": perspective.get(
82 "back_height_m", defaults.perspective_back_height_m
83 ),
84 "perspective_context_distance_m": perspective.get(
85 "context_distance_m", defaults.perspective_context_distance_m
86 ),
87 "perspective_context_height_m": perspective.get(
88 "context_height_m", defaults.perspective_context_height_m
89 ),
90 "perspective_share_radius_m": perspective.get(
91 "share_radius_m", defaults.perspective_share_radius_m
92 ),
93 "perspective_coverage_tol_m": perspective.get(
94 "coverage_tol_m", defaults.perspective_coverage_tol_m
95 ),
96 }
0
Importance #48: src/iolabs_point_cloud_detection_verticalsigns/_config_roadcontext.py @@ -1,374 +0,0 @@
1"""Road-context gate, driven-lane band and repetitive-row rejection.
2
3Also field-stake rows and embedded-marker extraction.
4
5One slice of the flat ``DetectorConfig``, moved out of
6``config.py`` verbatim. ``config.py`` recombines the slices and
7re-exports both names defined here.
8"""
9
10from typing import Any
11
12from iolabs.common import config_loader
13
14
15class RoadContextFields(config_loader.ConfigModel):
16 """Road-context gate, driven-lane band and repetitive-row rejection.
17
18 Also field-stake rows and embedded-marker extraction.
19
20 Metres unless stated otherwise.
21 """
22
23 # Repetitive-row rejection: a noise-barrier (Lรคrmschutzwand) support row
24 # (segment 116) is >=4 slender clusters of similar height on a line at
25 # regular <=5 m spacing. Delineators repeat at 25-50 m so they never form
26 # such a chain and stay safe.
27 row_min_members: int = 4
28 row_max_spacing_m: float = 5.0
29 row_max_perp_spread_m: float = 1.5
30 row_max_h_max_range_m: float = 0.7
31 row_member_max_len_major_m: float = 2.0
32 row_member_max_len_minor_m: float = 0.8
33
34 # Road-context gate (AI3D-339 pass 7): a delineator with ZERO saturated
35 # returns within roadctx_radius_m is not beside a carriageway and cannot be
36 # road furniture. Presence only โ€” absolute counts run ~100x lower on the
37 # A1 branch-1 ramp than on the mainline, so no count threshold transfers.
38 # See roadctx.py.
39 roadctx_gate_enabled: bool = True
40 roadctx_saturation_intensity: float = 55000.0
41 roadctx_radius_m: float = 15.0
42 # Segments either side to pool: a candidate near a tile boundary otherwise
43 # sees a truncated disc and can read zero purely from tiling.
44 roadctx_neighbour_span: int = 1
45 # Domain guard: below this many saturated returns in the pooled
46 # neighbourhood the measurement is coverage noise, not evidence of "no
47 # road", and the gate disarms. See RoadContext.armed.
48 roadctx_min_neighbourhood_saturated: int = 1000
49 # Local-ext4 cache for the per-segment saturated-return arrays; empty falls
50 # back to a road_context/ directory beside the per-segment output dirs.
51 roadctx_cache_dir: str = ""
52
53 # Driven-lane band gate (AI3D-339 pass 8, Miro directive). A short
54 # candidate standing in the lane the survey vehicle drove is a vehicle, not
55 # road furniture. The pass-8 census killed the wider "between the two edge
56 # lines of the carriageway" form โ€” run4 is absent on A1 and a featureless
57 # full-tile rectangle on A4_5, and paint runs at uniform lane spacing right
58 # across the median. See edgeline.py and p8_edgeline_census_result.md.
59 edgeline_gate_enabled: bool = True
60 # run7 lane XML is the PRIMARY road model (Miro: "use the lines from
61 # run7" / "from the XML. Much more reliable"). See run7_xml.py.
62 edgeline_xml_enabled: bool = True
63 # Cross-file consensus: with many per-drive XMLs a point is on the road
64 # only if this fraction of the files covering it agree. One bad variant
65 # must not be able to put a median device on the carriageway.
66 edgeline_xml_min_agreement: float = 0.6
67 # A file whose band is further than this from the point abstains rather
68 # than voting "outside" โ€” it is describing a different stretch of road.
69 edgeline_xml_vote_slack_m: float = 3.0
70 edgeline_xml_max_distance_m: float = 60.0
71 edgeline_xml_station_tolerance_m: float = 2.0
72 edgeline_xml_station_step_m: float = 10.0
73 # A full carriageway, not a lane: the XML edges bound the whole thing.
74 edgeline_min_carriageway_width_m: float = 3.0
75 edgeline_max_carriageway_width_m: float = 20.0
76 # Paint extraction is demoted to a fallback for corridors with no lane
77 # XML, and is OFF by default per the run7 directive.
78 edgeline_paint_fallback_enabled: bool = False
79 # Paint band: height above the local DEM within which a return is road
80 # marking rather than a device face (a delineator's band sits at 0.7-0.9 m).
81 edgeline_paint_max_height_m: float = 0.35
82 edgeline_paint_min_height_m: float = -0.25
83 # Paint cut as a PERCENTILE of near-ground intensity, never a DN: measured
84 # p95 = 39.3k/39.5k/41.3k on three A4_5 segments, while the roadctx
85 # saturation cut (55000) shows only the single line nearest the drive line.
86 edgeline_paint_intensity_percentile: float = 95.0
87 edgeline_paint_subsample: int = 20
88 # Along-road window.
89 edgeline_station_len_m: float = 10.0
90 edgeline_min_window_returns: int = 2000
91 # Painted-line detection in the lateral histogram.
92 edgeline_lateral_bin_m: float = 0.10
93 edgeline_min_line_points: int = 40
94 edgeline_max_line_width_m: float = 1.5
95 edgeline_min_line_along_fill: float = 0.4
96 # Drive line = densest lateral bin of all near-ground returns.
97 edgeline_drive_line_bin_m: float = 0.5
98 # Band sanity: one or two lanes. Wider means a line was missed.
99 edgeline_min_band_width_m: float = 2.0
100 edgeline_max_band_width_m: float = 9.0
101 # INWARD margin. Delineators stand ON the paint line, so the margin must
102 # shrink the rejection zone, never grow it.
103 edgeline_inward_margin_m: float = 0.3
104 edgeline_min_coverage_frac: float = 0.6
105 # Axis sanity, replacing the tile-elongation guard that misfired on real
106 # 51x34 m tiles: the paint must be sharper ACROSS the chosen axis than
107 # along it (measured ~19x on A4_5).
108 edgeline_min_axis_contrast: float = 3.0
109 # Central-axis prior (cross_sections_run7_lanes_*.npz).
110 edgeline_axis_search_radius_m: float = 40.0
111 edgeline_axis_max_angle_cos: float = 0.8
112 edgeline_axis_max_distance_m: float = 150.0
113 # Overhead exemption; type-based exemption in classify.py covers the rest.
114 edgeline_exempt_h_max_m: float = 4.5
115 # Corroboration: a transient exists in one driving pass only. Rejection
116 # requires this AND on-road position; position alone is a flag.
117 edgeline_reject_requires_transient: bool = True
118 edgeline_transient_max_records: int = 1
119 # Far-from-edge-line filter. Delineators stand 0.5-2 m off the carriageway
120 # edge; a "delineator" tens of metres away is a plantation or field stake
121 # (the class reject_rescue readmits). Default 30.0 m sits between real
122 # ramp posts at junctions with fragmentary XML coverage (p50 3.3 m / max
123 # 26.9 m with roleless edges included; A4_5 segs 131-135) and the
124 # false-positive stake rows (35-50 m on A4_5 038/049 and A1 branch-1
125 # 007/008). Measures against ALL XML edge features including roleless
126 # ramp edges. See edgedist.py.
127 edgeline_far_filter_enabled: bool = True
128 edgeline_far_max_distance_m: float = 30.0
129 # Also measure against painted lane-line families (Center Lines, Central
130 # Axis, Single-Side Central Axis). A delineator beside a painted line is
131 # near a road even where no Axis-of-the-Edge was extracted; this can only
132 # reduce false removals. Does not leak into the carriageway band model.
133 edgeline_far_include_lane_lines: bool = True
134 # Second, tighter far-from-edge cut for the 15-30 m band. Real ramp posts
135 # whose XML ramps are missing sit in that band with roadctx_n_sat 200-57k;
136 # reject-rescue stake rows in fields sit there with sat 2-130. Kill when
137 # screen distance exceeds the tighter cut AND measured saturation is
138 # below the paved-surface floor. See edgedist.py.
139 edgeline_far_tier2_enabled: bool = True
140 edgeline_far_tier2_distance_m: float = 15.0
141 edgeline_far_tier2_max_saturation: int = 150
142
143 # Field-stake rows: road-context failures that are phase-locked at stake
144 # spacing (A1 072/073 agricultural row at 5.8 m; A4_5 plantation rows at
145 # 4-5 m) are emitted as the experimental "field_stake_row" class instead of
146 # being dropped. min_members counts the whole row, so >=3 neighbours.
147 field_stake_row_emit: bool = True
148 field_stake_min_members: int = 4
149 field_stake_min_spacing_m: float = 2.0
150 field_stake_max_spacing_m: float = 10.0
151 field_stake_max_spacing_cv: float = 0.35
152
153 # Embedded-marker extraction: a bright vertical sign/delineator that DBSCAN
154 # glued onto an adjacent guardrail/barrier gets rejected as a large
155 # footprint. Scan the along-axis brightness profile of such rejected
156 # clusters for a compact, salient, retroreflective panel (segment 006).
157 marker_extract_min_len_major_m: float = 6.0
158 marker_extract_bright_h_min_m: float = 1.5
159 marker_extract_min_bright_points: int = 400
160 marker_extract_window_m: float = 2.5
161 marker_extract_min_bright_fraction: float = 0.45
162 marker_extract_min_h_max_m: float = 1.6
163 # Embedded-marker validation (defect class 3). The extracted window must be a
164 # genuine off-ground marker, not a flat bright road-surface artifact glued to a
165 # barrier. Require real vertical extent (points spanning at least this many
166 # metres) AND, for a window emitted as a "sign", genuine plate geometry โ€” a
167 # thin, slender slab (plate_thickness_m <= sign_max_plate_thickness_m and
168 # len_minor <= sign_post_max_len_minor_m). Segment 079's on-road paint blob
169 # (len_minor 2.06 m, plate_thickness 0.16 m) fails both; segment 006's real
170 # guide board (0.45 m, 0.005 m) passes. NB: an on-road-fraction guard is NOT
171 # used here because 006's window also reads on_road_fraction 1.0 โ€” plate
172 # geometry, not road overlap, is the true separator.
173 marker_extract_min_vertical_span_m: float = 0.5
174
175
176def road_context_kwargs(config: dict[str, Any], defaults: RoadContextFields) -> dict[str, Any]:
177 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
178
179 Sections read: ``repetitive_row``, ``road_context``, ``edge_line``, ``field_stake``,
180 ``marker_extract``.
181
182 Args:
183 config: The nested config document, not a single section.
184 defaults: Instance supplying the fallback for every absent key.
185
186 Returns:
187 The ``RoadContextFields`` keyword arguments, defaults filled in.
188 """
189 row = config.get("repetitive_row", {})
190 roadctx = config.get("road_context", {})
191 edgeline = config.get("edge_line", {})
192 stake = config.get("field_stake", {})
193 marker = config.get("marker_extract", {})
194 return {
195 "row_min_members": row.get("min_members", defaults.row_min_members),
196 "row_max_spacing_m": row.get("max_spacing_m", defaults.row_max_spacing_m),
197 "row_max_perp_spread_m": row.get(
198 "max_perp_spread_m", defaults.row_max_perp_spread_m
199 ),
200 "row_max_h_max_range_m": row.get(
201 "max_h_max_range_m", defaults.row_max_h_max_range_m
202 ),
203 "row_member_max_len_major_m": row.get(
204 "member_max_len_major_m", defaults.row_member_max_len_major_m
205 ),
206 "row_member_max_len_minor_m": row.get(
207 "member_max_len_minor_m", defaults.row_member_max_len_minor_m
208 ),
209 "roadctx_gate_enabled": roadctx.get(
210 "gate_enabled", defaults.roadctx_gate_enabled
211 ),
212 "roadctx_saturation_intensity": roadctx.get(
213 "saturation_intensity", defaults.roadctx_saturation_intensity
214 ),
215 "roadctx_radius_m": roadctx.get("radius_m", defaults.roadctx_radius_m),
216 "roadctx_neighbour_span": roadctx.get(
217 "neighbour_span", defaults.roadctx_neighbour_span
218 ),
219 "roadctx_cache_dir": roadctx.get("cache_dir", defaults.roadctx_cache_dir),
220 "roadctx_min_neighbourhood_saturated": roadctx.get(
221 "min_neighbourhood_saturated",
222 defaults.roadctx_min_neighbourhood_saturated,
223 ),
224 "edgeline_gate_enabled": edgeline.get(
225 "gate_enabled", defaults.edgeline_gate_enabled
226 ),
227 "edgeline_xml_enabled": edgeline.get(
228 "xml_enabled", defaults.edgeline_xml_enabled
229 ),
230 "edgeline_xml_min_agreement": edgeline.get(
231 "xml_min_agreement", defaults.edgeline_xml_min_agreement
232 ),
233 "edgeline_xml_vote_slack_m": edgeline.get(
234 "xml_vote_slack_m", defaults.edgeline_xml_vote_slack_m
235 ),
236 "edgeline_xml_max_distance_m": edgeline.get(
237 "xml_max_distance_m", defaults.edgeline_xml_max_distance_m
238 ),
239 "edgeline_xml_station_tolerance_m": edgeline.get(
240 "xml_station_tolerance_m", defaults.edgeline_xml_station_tolerance_m
241 ),
242 "edgeline_xml_station_step_m": edgeline.get(
243 "xml_station_step_m", defaults.edgeline_xml_station_step_m
244 ),
245 "edgeline_min_carriageway_width_m": edgeline.get(
246 "min_carriageway_width_m", defaults.edgeline_min_carriageway_width_m
247 ),
248 "edgeline_max_carriageway_width_m": edgeline.get(
249 "max_carriageway_width_m", defaults.edgeline_max_carriageway_width_m
250 ),
251 "edgeline_paint_fallback_enabled": edgeline.get(
252 "paint_fallback_enabled", defaults.edgeline_paint_fallback_enabled
253 ),
254 "edgeline_paint_max_height_m": edgeline.get(
255 "paint_max_height_m", defaults.edgeline_paint_max_height_m
256 ),
257 "edgeline_paint_min_height_m": edgeline.get(
258 "paint_min_height_m", defaults.edgeline_paint_min_height_m
259 ),
260 "edgeline_paint_intensity_percentile": edgeline.get(
261 "paint_intensity_percentile", defaults.edgeline_paint_intensity_percentile
262 ),
263 "edgeline_paint_subsample": edgeline.get(
264 "paint_subsample", defaults.edgeline_paint_subsample
265 ),
266 "edgeline_station_len_m": edgeline.get(
267 "station_len_m", defaults.edgeline_station_len_m
268 ),
269 "edgeline_min_window_returns": edgeline.get(
270 "min_window_returns", defaults.edgeline_min_window_returns
271 ),
272 "edgeline_lateral_bin_m": edgeline.get(
273 "lateral_bin_m", defaults.edgeline_lateral_bin_m
274 ),
275 "edgeline_min_line_points": edgeline.get(
276 "min_line_points", defaults.edgeline_min_line_points
277 ),
278 "edgeline_max_line_width_m": edgeline.get(
279 "max_line_width_m", defaults.edgeline_max_line_width_m
280 ),
281 "edgeline_min_line_along_fill": edgeline.get(
282 "min_line_along_fill", defaults.edgeline_min_line_along_fill
283 ),
284 "edgeline_drive_line_bin_m": edgeline.get(
285 "drive_line_bin_m", defaults.edgeline_drive_line_bin_m
286 ),
287 "edgeline_min_band_width_m": edgeline.get(
288 "min_band_width_m", defaults.edgeline_min_band_width_m
289 ),
290 "edgeline_max_band_width_m": edgeline.get(
291 "max_band_width_m", defaults.edgeline_max_band_width_m
292 ),
293 "edgeline_inward_margin_m": edgeline.get(
294 "inward_margin_m", defaults.edgeline_inward_margin_m
295 ),
296 "edgeline_min_coverage_frac": edgeline.get(
297 "min_coverage_frac", defaults.edgeline_min_coverage_frac
298 ),
299 "edgeline_min_axis_contrast": edgeline.get(
300 "min_axis_contrast", defaults.edgeline_min_axis_contrast
301 ),
302 "edgeline_axis_search_radius_m": edgeline.get(
303 "axis_search_radius_m", defaults.edgeline_axis_search_radius_m
304 ),
305 "edgeline_axis_max_angle_cos": edgeline.get(
306 "axis_max_angle_cos", defaults.edgeline_axis_max_angle_cos
307 ),
308 "edgeline_axis_max_distance_m": edgeline.get(
309 "axis_max_distance_m", defaults.edgeline_axis_max_distance_m
310 ),
311 "edgeline_exempt_h_max_m": edgeline.get(
312 "exempt_h_max_m", defaults.edgeline_exempt_h_max_m
313 ),
314 "edgeline_reject_requires_transient": edgeline.get(
315 "reject_requires_transient", defaults.edgeline_reject_requires_transient
316 ),
317 "edgeline_transient_max_records": edgeline.get(
318 "transient_max_records", defaults.edgeline_transient_max_records
319 ),
320 "edgeline_far_filter_enabled": edgeline.get(
321 "far_filter_enabled", defaults.edgeline_far_filter_enabled
322 ),
323 "edgeline_far_max_distance_m": edgeline.get(
324 "far_max_distance_m", defaults.edgeline_far_max_distance_m
325 ),
326 "edgeline_far_include_lane_lines": edgeline.get(
327 "far_include_lane_lines", defaults.edgeline_far_include_lane_lines
328 ),
329 "edgeline_far_tier2_enabled": edgeline.get(
330 "far_tier2_enabled", defaults.edgeline_far_tier2_enabled
331 ),
332 "edgeline_far_tier2_distance_m": edgeline.get(
333 "far_tier2_distance_m", defaults.edgeline_far_tier2_distance_m
334 ),
335 "edgeline_far_tier2_max_saturation": edgeline.get(
336 "far_tier2_max_saturation", defaults.edgeline_far_tier2_max_saturation
337 ),
338 "field_stake_row_emit": stake.get(
339 "row_emit", defaults.field_stake_row_emit
340 ),
341 "field_stake_min_members": stake.get(
342 "min_members", defaults.field_stake_min_members
343 ),
344 "field_stake_min_spacing_m": stake.get(
345 "min_spacing_m", defaults.field_stake_min_spacing_m
346 ),
347 "field_stake_max_spacing_m": stake.get(
348 "max_spacing_m", defaults.field_stake_max_spacing_m
349 ),
350 "field_stake_max_spacing_cv": stake.get(
351 "max_spacing_cv", defaults.field_stake_max_spacing_cv
352 ),
353 "marker_extract_min_len_major_m": marker.get(
354 "min_len_major_m", defaults.marker_extract_min_len_major_m
355 ),
356 "marker_extract_bright_h_min_m": marker.get(
357 "bright_h_min_m", defaults.marker_extract_bright_h_min_m
358 ),
359 "marker_extract_min_bright_points": marker.get(
360 "min_bright_points", defaults.marker_extract_min_bright_points
361 ),
362 "marker_extract_window_m": marker.get(
363 "window_m", defaults.marker_extract_window_m
364 ),
365 "marker_extract_min_bright_fraction": marker.get(
366 "min_bright_fraction", defaults.marker_extract_min_bright_fraction
367 ),
368 "marker_extract_min_h_max_m": marker.get(
369 "min_h_max_m", defaults.marker_extract_min_h_max_m
370 ),
371 "marker_extract_min_vertical_span_m": marker.get(
372 "min_vertical_span_m", defaults.marker_extract_min_vertical_span_m
373 ),
374 }
0
Importance #49: src/iolabs_point_cloud_detection_verticalsigns/_config_stages.py @@ -1,241 +0,0 @@
1"""Opt-in post-classification stages.
2
3The rail-relative half-post pass, the reject-rescue second look and
4the ML verifier.
5
6One slice of the flat ``DetectorConfig``, moved out of
7``config.py`` verbatim. ``config.py`` recombines the slices and
8re-exports both names defined here.
9"""
10
11from typing import Any
12
13from iolabs.common import config_loader
14
15
16class StageFields(config_loader.ConfigModel):
17 """Opt-in post-classification stages.
18
19 The rail-relative half-post pass, the reject-rescue second look and
20 the ML verifier.
21
22 Metres unless stated otherwise.
23 """
24
25 # Rail-relative half-post stage (see railpost.py; AI3D-339 pass 10). A
26 # guardrail-mounted delineator body is invisible to the main path: it fuses
27 # with the W-beam into one 45 m blob at seeding. This stage searches the
28 # band above each rail's measured beam crest, given guardrail models from
29 # the guardrails repo. ~91% of A4_5 is railed, so the class is the dominant
30 # delineator morphology there, not an edge case.
31 #
32 # Every constant is FROZEN from the pass-8 A4_5 probe and its pass-9 A1
33 # re-run, which applied the gate unchanged โ€” the panel's "twice-transferred"
34 # requirement. They are config keys so the reserve burn can toggle them,
35 # not because they are open for tuning.
36 #
37 # prime (n_sat >= 1 AND nrec >= 2) is a CONFIDENCE MARKER, NEVER A GATE:
38 # the pass-9 control arm measured the non-prime tail at 43% real, which
39 # makes prime a ~2.2x precision-ranking device. Gating on it would throw
40 # away a near-coin-flip tail.
41 #
42 # OFF by default: validation needs the ratified truth set.
43 rail_halfpost_stage: bool = False
44 # Root searched for **/segment_<id>/guardrails.json (the guardrails repo
45 # writes one output root per worker: out_w0/, out_w1/, ...). Empty disables
46 # the stage even when the flag is on.
47 rail_halfpost_models_dir: str = ""
48 # Band geometry (probe constants). The 0.15 m floor is calibrated: the
49 # W-beam's own returns reach ~0.20 m above the fitted top, and below that
50 # floor every cluster in the band fuses into one blob per rail.
51 rail_halfpost_band_lat_m: float = 0.80
52 rail_halfpost_band_z_lo_m: float = 0.15
53 rail_halfpost_band_z_hi_m: float = 1.50
54 rail_halfpost_sample_step_m: float = 0.10
55 rail_halfpost_cluster_cell_m: float = 0.15
56 rail_halfpost_min_emit_points: int = 8
57 rail_halfpost_ground_cell_m: float = 2.0
58 rail_halfpost_ground_percentile: float = 10.0
59 rail_halfpost_saturation_intensity: float = 55000.0
60 # Acceptance gate (pass-8, transferred to A1 unchanged in pass 9).
61 rail_halfpost_h_min_m: float = 0.20
62 rail_halfpost_h_max_m: float = 0.80
63 rail_halfpost_max_lateral_m: float = 0.50
64 rail_halfpost_max_width_m: float = 0.20
65 rail_halfpost_min_points: int = 15
66 rail_halfpost_min_z_extent_m: float = 0.10
67 rail_halfpost_dedupe_m: float = 1.5
68 # Confidence marker only โ€” see above.
69 rail_halfpost_prime_min_sat: int = 1
70 rail_halfpost_prime_min_records: int = 2
71
72 # Reject-rescue second-look stage (see rescue.py; AI3D-339 pass 10). The
73 # pass-9 sieve's stratum A, ported as a detector stage: a label-free
74 # physical screen over clusters the detector rejected with a reason that
75 # named no positive counter-indication. Seven clusters called vegetation
76 # over the lifetime of the loop were later overturned to real devices, and
77 # the criteria below are the profile those seven share, with each threshold
78 # anchored to a percentile of the detector's OWN accepted delineators on the
79 # same run โ€” never to a judged label (out_eval/pass9/p9_sieve.py).
80 #
81 # Brightness is deliberately NOT a gate: three of the seven overturns were
82 # explicitly unsaturated. It is a rank bonus in the sieve and nothing here.
83 #
84 # OFF by default: validation needs the ratified truth set.
85 reject_rescue_stage: bool = False
86 rescue_h_min_m: float = 0.85
87 rescue_h_max_m: float = 1.60
88 rescue_min_verticality: float = 0.90
89 rescue_max_core_rms_m: float = 0.20
90 rescue_min_h_over_width: float = 1.40
91 rescue_min_records: int = 2
92 rescue_min_roadctx_sat: int = 17
93 rescue_min_continuity: float = 0.80
94 rescue_min_decile_fill: float = 0.60
95 rescue_min_points: int = 30
96 # Two rescues this close describe one physical object; keep the better one.
97 rescue_merge_radius_m: float = 1.0
98 # A rescue within this distance of something already accepted is not a
99 # rescue, it is a duplicate.
100 rescue_accepted_exclusion_m: float = 2.0
101 # Sieve's PER_SEGMENT_CAP was a crop-budget device for a judge pool, not a
102 # physical criterion, so it does not ship as one: 0 means no cap.
103 rescue_per_segment_cap: int = 0
104
105 # ML verifier stage (see ml.py). When enabled and a model file resolves,
106 # every accepted detection gets an "ml_confidence" = P(real) in the JSON and
107 # detections scoring below ml_veto_threshold are dropped with reason
108 # ml_vetoed (logged in clusters.csv). Enabled by default but a pure no-op
109 # when no model is present, so a fresh checkout behaves exactly as before.
110 # A negative ml_veto_threshold means "use the threshold in the model
111 # bundle"; ml_model_path empty means "resolve models/latest.json".
112 ml_verifier_enabled: bool = True
113 ml_veto_threshold: float = -1.0
114 ml_model_path: str = ""
115 # The verifier was trained on corridor-bearing A4_5 data with its veto
116 # threshold anchored to the minimum P(real) among training reals (0.62).
117 # On a run4-less dataset the model runs out-of-domain: measured on
118 # Abschnitt 1, all five adversarially judged-real signs of the segment-048
119 # family scored P 0.51-0.59 and were vetoed. When True (default), segments
120 # without run4 road-surface files score-and-annotate but do not veto;
121 # corridor-bearing segments (all of A4_5) are byte-identical either way.
122 ml_veto_requires_corridor: bool = True
123
124
125def stage_kwargs(config: dict[str, Any], defaults: StageFields) -> dict[str, Any]:
126 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
127
128 Sections read: ``classification``, ``rail_halfpost``, ``reject_rescue``.
129
130 Args:
131 config: The nested config document, not a single section.
132 defaults: Instance supplying the fallback for every absent key.
133
134 Returns:
135 The ``StageFields`` keyword arguments, defaults filled in.
136 """
137 classification = config.get("classification", {})
138 railpost = config.get("rail_halfpost", {})
139 rescue = config.get("reject_rescue", {})
140 return {
141 "rail_halfpost_stage": railpost.get("enabled", defaults.rail_halfpost_stage),
142 "rail_halfpost_models_dir": railpost.get(
143 "models_dir", defaults.rail_halfpost_models_dir
144 ),
145 "rail_halfpost_band_lat_m": railpost.get(
146 "band_lat_m", defaults.rail_halfpost_band_lat_m
147 ),
148 "rail_halfpost_band_z_lo_m": railpost.get(
149 "band_z_lo_m", defaults.rail_halfpost_band_z_lo_m
150 ),
151 "rail_halfpost_band_z_hi_m": railpost.get(
152 "band_z_hi_m", defaults.rail_halfpost_band_z_hi_m
153 ),
154 "rail_halfpost_sample_step_m": railpost.get(
155 "sample_step_m", defaults.rail_halfpost_sample_step_m
156 ),
157 "rail_halfpost_cluster_cell_m": railpost.get(
158 "cluster_cell_m", defaults.rail_halfpost_cluster_cell_m
159 ),
160 "rail_halfpost_min_emit_points": railpost.get(
161 "min_emit_points", defaults.rail_halfpost_min_emit_points
162 ),
163 "rail_halfpost_ground_cell_m": railpost.get(
164 "ground_cell_m", defaults.rail_halfpost_ground_cell_m
165 ),
166 "rail_halfpost_ground_percentile": railpost.get(
167 "ground_percentile", defaults.rail_halfpost_ground_percentile
168 ),
169 "rail_halfpost_saturation_intensity": railpost.get(
170 "saturation_intensity", defaults.rail_halfpost_saturation_intensity
171 ),
172 "rail_halfpost_h_min_m": railpost.get(
173 "h_min_m", defaults.rail_halfpost_h_min_m
174 ),
175 "rail_halfpost_h_max_m": railpost.get(
176 "h_max_m", defaults.rail_halfpost_h_max_m
177 ),
178 "rail_halfpost_max_lateral_m": railpost.get(
179 "max_lateral_m", defaults.rail_halfpost_max_lateral_m
180 ),
181 "rail_halfpost_max_width_m": railpost.get(
182 "max_width_m", defaults.rail_halfpost_max_width_m
183 ),
184 "rail_halfpost_min_points": railpost.get(
185 "min_points", defaults.rail_halfpost_min_points
186 ),
187 "rail_halfpost_min_z_extent_m": railpost.get(
188 "min_z_extent_m", defaults.rail_halfpost_min_z_extent_m
189 ),
190 "rail_halfpost_dedupe_m": railpost.get(
191 "dedupe_m", defaults.rail_halfpost_dedupe_m
192 ),
193 "rail_halfpost_prime_min_sat": railpost.get(
194 "prime_min_sat", defaults.rail_halfpost_prime_min_sat
195 ),
196 "rail_halfpost_prime_min_records": railpost.get(
197 "prime_min_records", defaults.rail_halfpost_prime_min_records
198 ),
199 "reject_rescue_stage": rescue.get("enabled", defaults.reject_rescue_stage),
200 "rescue_h_min_m": rescue.get("h_min_m", defaults.rescue_h_min_m),
201 "rescue_h_max_m": rescue.get("h_max_m", defaults.rescue_h_max_m),
202 "rescue_min_verticality": rescue.get(
203 "min_verticality", defaults.rescue_min_verticality
204 ),
205 "rescue_max_core_rms_m": rescue.get(
206 "max_core_rms_m", defaults.rescue_max_core_rms_m
207 ),
208 "rescue_min_h_over_width": rescue.get(
209 "min_h_over_width", defaults.rescue_min_h_over_width
210 ),
211 "rescue_min_records": rescue.get("min_records", defaults.rescue_min_records),
212 "rescue_min_roadctx_sat": rescue.get(
213 "min_roadctx_sat", defaults.rescue_min_roadctx_sat
214 ),
215 "rescue_min_continuity": rescue.get(
216 "min_continuity", defaults.rescue_min_continuity
217 ),
218 "rescue_min_decile_fill": rescue.get(
219 "min_decile_fill", defaults.rescue_min_decile_fill
220 ),
221 "rescue_min_points": rescue.get("min_points", defaults.rescue_min_points),
222 "rescue_merge_radius_m": rescue.get(
223 "merge_radius_m", defaults.rescue_merge_radius_m
224 ),
225 "rescue_accepted_exclusion_m": rescue.get(
226 "accepted_exclusion_m", defaults.rescue_accepted_exclusion_m
227 ),
228 "rescue_per_segment_cap": rescue.get(
229 "per_segment_cap", defaults.rescue_per_segment_cap
230 ),
231 "ml_verifier_enabled": classification.get(
232 "ml_verifier_enabled", defaults.ml_verifier_enabled
233 ),
234 "ml_veto_threshold": classification.get(
235 "ml_veto_threshold", defaults.ml_veto_threshold
236 ),
237 "ml_model_path": classification.get("ml_model_path", defaults.ml_model_path),
238 "ml_veto_requires_corridor": classification.get(
239 "ml_veto_requires_corridor", defaults.ml_veto_requires_corridor
240 ),
241 }
0
Importance #50: src/iolabs_point_cloud_detection_verticalsigns/_config_treedetect.py @@ -1,146 +0,0 @@
1"""Experimental tree detection and TCS ground filtering of the DEM input.
2
3One slice of the flat ``DetectorConfig``, moved out of
4``config.py`` verbatim. ``config.py`` recombines the slices and
5re-exports both names defined here.
6"""
7
8from typing import Any
9
10from iolabs.common import config_loader
11
12
13class TreeDetectionFields(config_loader.ConfigModel):
14 """Experimental tree detection and TCS ground filtering of the DEM input.
15
16 Metres unless stated otherwise.
17 """
18
19 # Experimental vegetation (tree) detection path (Part B). Master flag off by
20 # default; enabled via a config override for the tree run. A coarser DBSCAN
21 # and a wider (20 m) corridor run SEPARATELY from the sign path, and a
22 # dedicated vegetation RF (models/latest_vegetation.json) decides tree-vs-not.
23 # Candidates sitting directly above road-surface cells (a bridge/elevated
24 # deck, segment 033) are rejected by the on-road-fraction bridge guard.
25 tree_detection_enabled: bool = False
26 tree_max_dist_to_road_m: float = 20.0
27 tree_seed_min_vertical_span_m: float = 1.5
28 tree_seed_points_above_m: float = 2.0
29 tree_eps_m: float = 1.5
30 tree_min_samples: int = 3
31 tree_hull_margin_m: float = 0.5
32 tree_min_points: int = 60
33 tree_bridge_max_on_road_fraction: float = 0.6
34 tree_dedup_radius_m: float = 2.0
35 tree_min_confidence: float = -1.0
36 tree_model_path: str = ""
37 # Hedge split: every accepted tree cluster is put through the instance
38 # splitter's band (hedge) rule, and a grounded, low, long, stemless,
39 # flat-topped one is emitted as "medium_vegetation" (LAS 4) instead of
40 # "tree" (LAS 5). OFF by default (Miro, AI3D-373): whatever the tree
41 # stage accepts IS a tree -- a 3 m flat-topped band of greenery is high
42 # vegetation to the annotators, and the ground is often cut off so the
43 # trunks that would tell a tree from a hedge are not in the cloud. The
44 # rule stays available for datasets where hedges must go to LAS 4.
45 #
46 # This is the ONLY hedge knob under "tree_detection": it is on/off and
47 # nothing else. Every threshold the rule reads lives in the tree_instance
48 # slice, because the rule itself belongs to the instance splitter and the
49 # two callers must not be able to drift apart -- see _config_treeinstance:
50 # ``ti_hedge_*`` (ground gap, height, length, area, continuity, top relief,
51 # stems per 10 m, stem score bar), ``ti_min_cluster_points`` (the point
52 # floor below which the verdict abstains as "too_few_points"), and the stem
53 # band ``ti_stem_band_*`` / ``ti_stem_exg_bonus`` that produce the seeds the
54 # stemless conjunct counts. JSON: {"tree_instance": {"hedge_max_height_m":
55 # ...}}, not {"tree_detection": {...}}.
56 tree_hedge_split_enabled: bool = False
57
58 # TCS (tablecloth) ground filtering, Option C (AI3D-339). When enabled the
59 # p8 DEM is built from TCS-ground-classified points only, so height-above-
60 # ground stops being biased upward by parked vehicles and low canopy. This
61 # repoints the DEM INPUT ONLY -- the candidate accumulation keeps reading
62 # the original run3 files, because TCS drops vegetation as non-ground and
63 # feeding cleaned clouds to the candidate path would erase every tree.
64 # Profile is FORKED from tablecloth's defaults, which are tuned lip-first
65 # for pavement-edge retention (max_window 3.0 m lets vehicles survive into
66 # the surface); these are the wider road-corridor values.
67 tcs_ground_enabled: bool = False
68 tcs_mechanism: str = "smrf_numpy"
69 tcs_cell_m: float = 0.20
70 tcs_slope_threshold: float = 0.30
71 tcs_max_elev_diff_m: float = 0.15
72 tcs_smrf_max_window_m: float = 6.0
73 tcs_elev_scalar: float = 0.0
74 tcs_pit_fill_enabled: bool = True
75 # Where the ground-only *_run3_ground_points.npz intermediates are written.
76 # Empty means "beside the output segment dir". Point this at local ext4 --
77 # the 9p /mnt/d share is far too slow for rewriting whole clouds.
78 tcs_cache_dir: str = ""
79
80
81def tree_detection_kwargs(config: dict[str, Any], defaults: TreeDetectionFields) -> dict[str, Any]:
82 """Reads this slice's config sections into ``DetectorConfig`` kwargs.
83
84 Sections read: ``tree_detection``, ``tcs_ground``.
85
86 Args:
87 config: The nested config document, not a single section.
88 defaults: Instance supplying the fallback for every absent key.
89
90 Returns:
91 The ``TreeDetectionFields`` keyword arguments, defaults filled in.
92 """
93 tree_detection = config.get("tree_detection", {})
94 tcs_ground = config.get("tcs_ground", {})
95 return {
96 "tree_detection_enabled": tree_detection.get(
97 "enabled", defaults.tree_detection_enabled
98 ),
99 "tree_max_dist_to_road_m": tree_detection.get(
100 "max_dist_to_road_m", defaults.tree_max_dist_to_road_m
101 ),
102 "tree_seed_min_vertical_span_m": tree_detection.get(
103 "seed_min_vertical_span_m", defaults.tree_seed_min_vertical_span_m
104 ),
105 "tree_seed_points_above_m": tree_detection.get(
106 "seed_points_above_m", defaults.tree_seed_points_above_m
107 ),
108 "tree_eps_m": tree_detection.get("eps_m", defaults.tree_eps_m),
109 "tree_min_samples": tree_detection.get(
110 "min_samples", defaults.tree_min_samples
111 ),
112 "tree_hull_margin_m": tree_detection.get(
113 "hull_margin_m", defaults.tree_hull_margin_m
114 ),
115 "tree_min_points": tree_detection.get("min_points", defaults.tree_min_points),
116 "tree_bridge_max_on_road_fraction": tree_detection.get(
117 "bridge_max_on_road_fraction", defaults.tree_bridge_max_on_road_fraction
118 ),
119 "tree_dedup_radius_m": tree_detection.get(
120 "dedup_radius_m", defaults.tree_dedup_radius_m
121 ),
122 "tree_min_confidence": tree_detection.get(
123 "min_confidence", defaults.tree_min_confidence
124 ),
125 "tree_model_path": tree_detection.get("model_path", defaults.tree_model_path),
126 "tree_hedge_split_enabled": tree_detection.get(
127 "hedge_split_enabled", defaults.tree_hedge_split_enabled
128 ),
129 "tcs_ground_enabled": tcs_ground.get("enabled", defaults.tcs_ground_enabled),
130 "tcs_mechanism": tcs_ground.get("mechanism", defaults.tcs_mechanism),
131 "tcs_cell_m": tcs_ground.get("cell_m", defaults.tcs_cell_m),
132 "tcs_slope_threshold": tcs_ground.get(
133 "slope_threshold", defaults.tcs_slope_threshold
134 ),
135 "tcs_max_elev_diff_m": tcs_ground.get(
136 "max_elev_diff_m", defaults.tcs_max_elev_diff_m
137 ),
138 "tcs_smrf_max_window_m": tcs_ground.get(
139 "smrf_max_window_m", defaults.tcs_smrf_max_window_m
140 ),
141 "tcs_elev_scalar": tcs_ground.get("elev_scalar", defaults.tcs_elev_scalar),
142 "tcs_pit_fill_enabled": tcs_ground.get(
143 "pit_fill_enabled", defaults.tcs_pit_fill_enabled
144 ),
145 "tcs_cache_dir": tcs_ground.get("cache_dir", defaults.tcs_cache_dir),
146 }
0
Importance #51: src/iolabs_point_cloud_detection_verticalsigns/_model_base.py @@ -0,0 +1,67 @@
1"""Field-declaration helper shared by the ``_model_<topic>`` config slices.
2
3The detector reads a FLAT config (``config.ground_cell_m``) while the packaged
4``verticalsigns.default.json`` โ€” and every user override file โ€” is grouped into
5sections (``{"ground": {"cell_m": 0.75}}``). :func:`section_field` is what joins
6the two: each flat field declares the JSON section and key it comes from right
7where it declares its type and default, so a new config key costs exactly two
8edits (the field here, the same key in the JSON) and no separate mapping table.
9
10:class:`iolabs_point_cloud_detection_verticalsigns._config.DetectorConfig`
11walks that metadata to translate a nested document into flat keyword arguments
12(``DetectorConfig.from_mapping``) and back (``DetectorConfig.to_document``).
13"""
14
15from __future__ import annotations
16
17from typing import Any
18
19import pydantic
20
21_SECTION_METADATA_KEY = "config_section_path"
22
23
24def section_field(path: str, default: Any, **constraints: Any) -> Any:
25 """Declare a flat field carrying the ``"<section>.<key>"`` it is loaded from.
26
27 Args:
28 path: Dotted location in the nested config document, e.g.
29 ``"ground.cell_m"``. The section must exist in
30 ``verticalsigns.default.json`` and the key must be spelled exactly
31 as the JSON spells it.
32 default: The field default, which must equal the packaged JSON value.
33 constraints: Extra ``pydantic.Field`` arguments, e.g. ``ge=0.0``.
34
35 Returns:
36 The ``pydantic.Field`` descriptor for the field.
37
38 Raises:
39 ValueError: *path* is not a ``section.key`` pair.
40 """
41 section, _, key = path.partition(".")
42 if not section or not key or "." in key:
43 raise ValueError(f"section_field path must be 'section.key', got {path!r}")
44 return pydantic.Field(
45 default,
46 json_schema_extra={_SECTION_METADATA_KEY: [section, key]},
47 **constraints,
48 )
49
50
51def section_path(field: pydantic.fields.FieldInfo) -> tuple[str, str]:
52 """Return the ``(section, key)`` a :func:`section_field` field was declared with.
53
54 Args:
55 field: The ``pydantic.fields.FieldInfo`` of a flat config field.
56
57 Returns:
58 The section name and the key inside it.
59
60 Raises:
61 ValueError: The field was not declared with :func:`section_field`.
62 """
63 extra = field.json_schema_extra
64 path = extra.get(_SECTION_METADATA_KEY) if isinstance(extra, dict) else None
65 if not isinstance(path, list) or len(path) != 2:
66 raise ValueError("config field was not declared with section_field()")
67 return str(path[0]), str(path[1])
0
Importance #52: src/iolabs_point_cloud_detection_verticalsigns/_model_conic.py @@ -0,0 +1,122 @@
1"""The colour-free conic gate and the conifer rule that rides on it.
2
3One slice of the flat ``DetectorConfig``. Every field declares, via
4``section_field``, the ``verticalsigns.default.json`` section and key it is
5loaded from; ``_config`` recombines the slices into the model.
6"""
7
8from iolabs.common import config_loader
9
10from ._model_base import section_field
11
12
13class VerticalSignsConicFields(config_loader.ConfigModel):
14 """The colour-free conic gate and the conifer rule that rides on it.
15
16 Metres unless stated otherwise.
17 """
18
19 # Colour-free conic gate (AI3D-339): an OR-bypass around the vegetation RF
20 # for conifers. The RF cannot pass them (its positives contained none, and
21 # crown_isotropy is information-free for cone-vs-pole), so a rule is the
22 # only path that surfaces them. TWO-CUE by design -- shape AND surface
23 # texture -- because a single cue family cannot separate foliage from a
24 # mast. SHIPS OFF; thresholds below are unvalidated seeds pending the
25 # real-distribution dump, and emissions are tagged reason="conic_rule".
26 conic_gate_enabled: bool = section_field("conic_gate.enabled", False)
27 conic_taper_slope_max: float = section_field("conic_gate.taper_slope_max", -0.4)
28 # The taper must survive dropping any single decile. Measured on real
29 # A4_5 data, every cluster that faked a cone had its whole slope carried
30 # by one decile -- a ground skirt at the base or one twig at the top.
31 conic_taper_slope_robust_max: float = section_field("conic_gate.taper_slope_robust_max", -0.3)
32 conic_apex_deg_min: float = section_field("conic_gate.apex_deg_min", 5.0)
33 conic_apex_deg_max: float = section_field("conic_gate.apex_deg_max", 35.0)
34 conic_h_over_width_min: float = section_field("conic_gate.h_over_width_min", 1.5)
35 conic_h_over_width_max: float = section_field("conic_gate.h_over_width_max", 12.0)
36 # Texture conjunct: foliage is scattering-rough, a pole/mast is smooth.
37 # Reads the EXISTING eigenfeature fields. Disable to A/B the shape cue
38 # alone during diagnostics; it is on whenever the gate itself is on.
39 conic_texture_cue_enabled: bool = section_field("conic_gate.texture_cue_enabled", True)
40 conic_change_of_curvature_min: float = section_field("conic_gate.change_of_curvature_min", 0.06)
41 conic_omnivariance_min: float = section_field("conic_gate.omnivariance_min", 0.10)
42 conic_max_hi_intensity_fraction: float = section_field(
43 "conic_gate.max_hi_intensity_fraction", 0.2
44 )
45 conic_h_max_min_m: float = section_field("conic_gate.h_max_min_m", 2.5)
46 conic_max_on_road_fraction: float = section_field("conic_gate.max_on_road_fraction", 0.6)
47 # Abstention guard -- an occlusion-starved radius profile must not be
48 # allowed to fake a conifer's taper.
49 conic_min_decile_fill_fraction: float = section_field(
50 "conic_gate.min_decile_fill_fraction", 0.8
51 )
52 # Minimum crown footprint. A taper says how the radius CHANGES with height
53 # but says nothing about absolute size, so a 0.34 x 0.18 m post 3 m tall
54 # satisfies every shape test while being far too thin to be a crown.
55 # Calibrated on the 143-segment A4_5 sweep: the three thinnest conic
56 # emissions (0.061 / 0.177 / 0.256 m2) were independently judged posts or
57 # bare stems in visual review, while 47 of the 51 clusters the trained
58 # vegetation RF accepted sit above 0.5 m2.
59 conic_min_crown_area_m2: float = section_field("conic_gate.min_crown_area_m2", 0.3)
60
61 # --- conifer rule (AI3D-339) -------------------------------------------
62 # A SECOND, independent bypass. The conic rule above selects for foliage
63 # reaching the ground -- shrub mounds, hedge banks -- because it fits the
64 # taper over the whole cluster. A conifer carrying its crown above a bare
65 # trunk has the opposite profile and is structurally rejected there. This
66 # rule reads the crown-relative fields instead, so it can accept one.
67 #
68 # These thresholds are MORPHOLOGICAL PRIORS, not fitted values: the corpus
69 # contains a single visually-confirmed clean conifer, which is far too few
70 # to calibrate against without overfitting. They are deliberately loose,
71 # to be narrowed once emissions have been reviewed.
72 conifer_rule_enabled: bool = section_field("conifer_rule.enabled", False)
73 # THE DISCRIMINATOR, and it is not a shape term. Thirteen candidates were
74 # rendered as 360-degree orbits and labelled by three independent blind
75 # judges; no shape feature separated the five confirmed conifers from the
76 # six confirmed non-conifers (stem_ratio: conifers 0.46-2.08, others
77 # 0.96-1.64 -- fully overlapping). Every judge instead gave the same
78 # reason, "densely filled" versus "see-through twiggy", and a density
79 # BAND separates the labelled set perfectly:
80 #
81 # conifers 154 191 208 278 332
82 # leaf-off 98 116 130 (bare April twigs return little)
83 # hedge/thicket 679 745 853 (a solid mass, not a tree)
84 #
85 # Physically: a conifer is dense foliage on an OPEN branching tree, so it
86 # sits between bare deciduous and a solid hedge. Unlike the shape terms
87 # these bounds ARE fitted -- to 11 labels, which is few -- so they are set
88 # at the midpoints of the observed gaps to maximise margin, and both
89 # contested candidates fall outside the band.
90 conifer_min_volumetric_density: float = section_field(
91 "conifer_rule.min_volumetric_density", 140.0
92 )
93 conifer_max_volumetric_density: float = section_field(
94 "conifer_rule.max_volumetric_density", 380.0
95 )
96 # Shape sanity only; NOT the discriminator (see above). Kept loose enough
97 # to admit every confirmed conifer, including merged pairs whose base is
98 # widened by the neighbour they were clustered with.
99 conifer_max_stem_ratio: float = section_field("conifer_rule.max_stem_ratio", 2.2)
100 # A point at the top rather than a flat or broadening crown.
101 conifer_max_apex_ratio: float = section_field("conifer_rule.max_apex_ratio", 0.75)
102 # The crown limb must actually taper.
103 conifer_max_crown_taper: float = section_field("conifer_rule.max_crown_taper", -0.10)
104 # The crown must sit low enough to be a cone, not a mushroom.
105 conifer_max_crown_base_frac: float = section_field("conifer_rule.max_crown_base_frac", 0.55)
106 # Slenderness of the whole object: a spire, not a bush and not a mast.
107 conifer_h_over_width_min: float = section_field("conifer_rule.h_over_width_min", 2.0)
108 conifer_h_over_width_max: float = section_field("conifer_rule.h_over_width_max", 15.0)
109 conifer_h_max_min_m: float = section_field("conifer_rule.h_max_min_m", 2.0)
110 # Foliage is scattering-rough; a pole or a fence face is smooth.
111 conifer_min_change_of_curvature: float = section_field(
112 "conifer_rule.min_change_of_curvature", 0.04
113 )
114 # Not retroreflective, not over the carriageway, not starved of deciles.
115 conifer_max_hi_intensity_fraction: float = section_field(
116 "conifer_rule.max_hi_intensity_fraction", 0.2
117 )
118 conifer_max_on_road_fraction: float = section_field("conifer_rule.max_on_road_fraction", 0.6)
119 conifer_min_decile_fill_fraction: float = section_field(
120 "conifer_rule.min_decile_fill_fraction", 0.8
121 )
122 conifer_min_crown_area_m2: float = section_field("conifer_rule.min_crown_area_m2", 0.2)
0
Importance #53: src/iolabs_point_cloud_detection_verticalsigns/_model_corridor.py @@ -0,0 +1,121 @@
1"""Road corridor rasterization and on-carriageway rejection.
2
3Also plate planarity, the bright-panel class and the free-space ring.
4
5One slice of the flat ``DetectorConfig``. Every field declares, via
6``section_field``, the ``verticalsigns.default.json`` section and key it is
7loaded from; ``_config`` recombines the slices into the model.
8"""
9
10from iolabs.common import config_loader
11
12from ._model_base import section_field
13
14
15class VerticalSignsCorridorFields(config_loader.ConfigModel):
16 """Road corridor rasterization and on-carriageway rejection.
17
18 Also plate planarity, the bright-panel class and the free-space ring.
19
20 Metres unless stated otherwise.
21 """
22
23 # Road corridor (rasterized on the ground-grid geometry).
24 max_dist_to_road_m: float = section_field("corridor.max_dist_to_road_m", 10.0)
25 on_carriageway_dist_m: float = section_field("corridor.on_carriageway_dist_m", 0.25)
26 on_carriageway_exempt_h_max_m: float = section_field(
27 "corridor.on_carriageway_exempt_h_max_m", 4.5
28 )
29 # Carriageway isolation: run4 over-extends the fitted road plane onto verge /
30 # field-track areas with a sparse point density (segment 000). Keep only
31 # cells whose run4 count clears a segment-adaptive density floor
32 # (max of an absolute floor and a fraction of the p95 cell count), then keep
33 # the connected component(s) covering the main carriageway.
34 corridor_density_min_points: float = section_field("corridor.density_min_points", 8.0)
35 corridor_density_frac_p95: float = section_field("corridor.density_frac_p95", 0.06)
36 # Cap on the p95-scaled density floor. On heavily-overscanned segments the
37 # main carriageway core is sampled by many overlapping run4 passes, so its
38 # p95 cell count balloons (segment 134: p95~8100 โ†’ floor 487) and the floor
39 # over-drops legitimately-paved but less-densely-scanned branch roads / gore
40 # aprons / ramps (134's apron cells hold ~170-210 returns). The cap keeps the
41 # floor at a road-vs-extrapolation boundary (~150) regardless of how dense the
42 # core is. It only lowers the floor where density_frac_p95*p95 exceeds it, so
43 # genuinely sparse segments (000's vineyard field track, floor 152, field
44 # cells <150) are unchanged and their extrapolated planes stay dropped.
45 corridor_density_max_points: float = section_field("corridor.density_max_points", 150.0)
46 corridor_component_min_area_frac: float = section_field(
47 "corridor.component_min_area_frac", 0.15
48 )
49 # A dense run4 component is kept when it is either a decent fraction of the
50 # largest (component_min_area_frac) OR clears an absolute cell-area floor. A
51 # branch road / apron forms its own component disconnected from the main
52 # carriageway across the curb gap; on a long junction tile it is far smaller
53 # than the through-road, so the fractional test alone drops it. run4 holds
54 # road-surface points only, so a dense component of this size is road.
55 corridor_component_min_area_cells: int = section_field("corridor.component_min_area_cells", 40)
56 # On-carriageway rejection: a cluster whose footprint sits (almost) entirely
57 # over genuine road cells is a vehicle / on-road object, rejected for every
58 # class except tall gantry legs (h_max >= on_carriageway_exempt_h_max_m).
59 # Edge delineators keep a mixed footprint and stay below this fraction.
60 on_carriageway_road_fraction: float = section_field(
61 "corridor.on_carriageway_road_fraction", 0.7
62 )
63 # An on-carriageway cluster is only kept if it is a genuine marker: either
64 # volumetrically dense (a static post/plate packs points) or brightly
65 # retroreflective (a wide guide panel overhanging the edge, segment 006).
66 # A dull, sparse blob on the carriageway is a vehicle / debris smear.
67 min_volumetric_density: float = section_field("classification.min_volumetric_density", 8000.0)
68 on_carriageway_bright_frac: float = section_field("corridor.on_carriageway_bright_frac", 0.5)
69 # Delineator-shape exemption from on-carriageway rejection. The corridor
70 # density cap can extend the kept road mask onto paved shoulders / medians,
71 # so genuine edge delineators end up sitting (almost) entirely over road
72 # cells and get swept up by the on-carriageway rejection (segments 076, 123).
73 # A moving-vehicle smear is never a sub-delineator-height, sub-0.65 m,
74 # near-perfectly-vertical retroreflective column, so a cluster matching that
75 # delineator signature is exempt and allowed to reach the delineator gates.
76 # The len_major cap (0.65 m) sits below the 114/130 vehicle-smear footprints
77 # (1.25 x 0.66 / 1.28 x 0.77), so those FPs stay rejected.
78 on_carriageway_delineator_max_len_major_m: float = section_field(
79 "corridor.on_carriageway_delineator_max_len_major_m", 0.65
80 )
81 on_carriageway_delineator_min_verticality: float = section_field(
82 "corridor.on_carriageway_delineator_min_verticality", 0.95
83 )
84
85 # Plate planarity: a real sign plate is a thin slab, so the smallest 3D
86 # covariance eigenvalue of its upper-half points (plate_thickness_m) is small.
87 # Vegetation clumps are volumetric and thick. Gate the sign class on it.
88 sign_max_plate_thickness_m: float = section_field("sign_post.max_plate_thickness_m", 0.15)
89
90 # Bright panel (segment 114): a real chevron/warning panel (Richtungstafel)
91 # can sit below the sign_post_h_min_m post-height floor (a low roadside
92 # panel, not a tall post-mounted plate). It is still a thin, bright, planar
93 # slab of plausible plate width, so gate it on brightness, thinness, height,
94 # width and vertical continuity directly rather than routing it through the
95 # post logic.
96 panel_min_hi: float = section_field("panel.min_hi", 0.40)
97 panel_max_thickness_m: float = section_field("panel.max_thickness_m", 0.20)
98 panel_h_min_m: float = section_field("panel.h_min_m", 0.9)
99 # A genuine chevron panel is a WIDE board (segment 114's reads 2.95 m).
100 # The 1.5 m floor keeps narrow bright low posts/plates (segment 134's
101 # 1.25 m roadside marker) out of the panel class.
102 panel_len_major_min_m: float = section_field("panel.len_major_min_m", 1.5)
103 panel_len_major_max_m: float = section_field("panel.len_major_max_m", 5.0)
104
105 # Free-space ring: real plate-less posts (sign_post/pole_other/delineator)
106 # stand clear, so a cylindrical ring around the cluster axis holds few
107 # non-cluster candidate points. Bush interiors, saplings and forest trunks
108 # sit inside filled rings. Also reject a plate-less candidate embedded in a
109 # forest context (several tall neighbouring clusters nearby).
110 ring_r_inner_m: float = section_field("context.ring_r_inner_m", 0.5)
111 ring_r_outer_m: float = section_field("context.ring_r_outer_m", 1.5)
112 ring_h_min_m: float = section_field("context.ring_h_min_m", 0.5)
113 ring_h_max_m: float = section_field("context.ring_h_max_m", 2.5)
114 # Ring fill measured as the ratio of non-cluster ring points to the cluster's
115 # own point count; a sapling/trunk embedded in foliage has a ring several
116 # times denser than itself, a real clear-standing post has a near-empty ring.
117 ring_max_fill_ratio: float = section_field("context.ring_max_fill_ratio", 2.0)
118 ring_min_points: int = section_field("context.ring_min_points", 40)
119 forest_min_neighbors: int = section_field("context.forest_min_neighbors", 3)
120 forest_radius_m: float = section_field("context.forest_radius_m", 8.0)
121 forest_neighbor_min_h_max_m: float = section_field("context.forest_neighbor_min_h_max_m", 2.0)
0
Importance #54: src/iolabs_point_cloud_detection_verticalsigns/_model_devices.py @@ -1,140 +1,158 @@
1"""Per-device acceptance gates and the two probe stages.1"""Per-device thresholds for delineators, sign posts and gantries.
22
3One slice of the nested :class:`VerticalSignsConfig` model tree; the sections3Also isolated-floating-pole rejection and duplicate suppression.
4mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines4
5the slices.5One slice of the flat ``DetectorConfig``. Every field declares, via
6``section_field``, the ``verticalsigns.default.json`` section and key it is
7loaded from; ``_config`` recombines the slices into the model.
6"""8"""
79
8from iolabs.common import config_loader10from iolabs.common import config_loader
911
1012from ._model_base import section_field
11class DelineatorConfig(config_loader.ConfigModel):13
12 """Delineator (Leitpfosten) acceptance gates."""14
1315class VerticalSignsDeviceFields(config_loader.ConfigModel):
14 h_min_m: float = 0.716 """Per-device thresholds for delineators, sign posts and gantries.
15 h_max_m: float = 1.517
16 max_footprint_m: float = 0.4518 Also isolated-floating-pole rejection and duplicate suppression.
17 relaxed_footprint_m: float = 0.8519
18 relaxed_min_verticality: float = 0.8520 Metres unless stated otherwise.
19 relaxed_max_ring_fill_ratio: float = 1.021 """
20 relaxed_min_hi_intensity_fraction: float = 0.1522
21 min_hi_intensity_fraction: float = 0.0823 # Delineator (Leitpfosten). The height ceiling (1.5 m) and footprint cap
22 min_points: int = 30024 # (0.45 m) admit taller guide posts and the mild along-track smear that gore
2325 # posts pick up in MLS (segment 131's junction posts read 0.42 m major,
2426 # h 1.2-1.5); real Leitpfosten cores stay ~0.12 m so the cap change does not
25class SignPostConfig(config_loader.ConfigModel):27 # widen the class into vehicles/vegetation.
26 """Sign-post and plate acceptance gates."""28 delineator_h_min_m: float = section_field("delineator.h_min_m", 0.7)
2729 delineator_h_max_m: float = section_field("delineator.h_max_m", 1.5)
28 max_len_minor_m: float = 0.830 delineator_max_footprint_m: float = section_field("delineator.max_footprint_m", 0.45)
29 h_min_m: float = 1.531 # Relaxed footprint band for a delineator whose along-track MLS smear at a
30 h_max_m: float = 6.032 # junction/gore pushes its major extent past the tight 0.45 m cap (segment
31 min_continuity: float = 0.633 # 134's splitter-island posts read 0.47-0.63 m major). Only admitted when the
32 plate_hi_intensity_fraction: float = 0.434 # cluster is strongly vertical (a genuine post), so a flat bright road-marking
33 plate_hi_intensity_fraction_weak: float = 0.335 # fragment (verticality ~0.1) can never sneak in through the wider cap. Purely
34 plate_upper_surplus_ratio: float = 2.036 # additive: clusters at or under delineator_max_footprint_m keep the original
35 min_upper_half_surplus: float = 0.337 # (verticality-free) path, so no existing detection is affected.
36 plate_min_core_rms_m: float = 0.138 # 0.65 -> 0.85 (AI3D-339 pass 3): Abschnitt-1 Leitpfosten merge with verge
37 max_plate_thickness_m: float = 0.1539 # grass into 0.67-0.83 m clusters that keep verticality ~0.99; the 0.65 cap
38 bare_post_min_h_max_m: float = 4.540 # was the single failing conjunct for 8 adversarially judged-real posts.
39 bare_post_max_core_rms_m: float = 0.06541 # At 0.85: A4_5 +3 judged-real delineators / 0 lost; A1 +~18 judged-real vs
40 bare_post_min_verticality: float = 0.942 # +5 judged-veg. Real (0.66-0.83) and FP (0.68-0.85) footprints fully
41 bare_post_min_points: int = 45043 # overlap, so no tighter cap separates them โ€” the veg leak is a texture
4244 # problem (multi-radius plate regularity, task #14), not a threshold one.
4345 delineator_relaxed_footprint_m: float = section_field("delineator.relaxed_footprint_m", 0.85)
44class PanelConfig(config_loader.ConfigModel):46 delineator_relaxed_min_verticality: float = section_field(
45 """Large panel acceptance gates."""47 "delineator.relaxed_min_verticality", 0.85
4648 )
47 min_hi: float = 0.449 # The wider relaxed band admits more smear, so it is guarded harder than the
48 max_thickness_m: float = 0.250 # compact path: the post must stand clear (a near-empty free-space ring, so a
49 h_min_m: float = 0.951 # bright speck embedded in roadside vegetation โ€” segment 084 โ€” is rejected)
50 len_major_min_m: float = 1.552 # and be clearly retroreflective (a higher brightness floor than the compact
51 len_major_max_m: float = 5.053 # 0.08, so a modest-brightness on-carriageway edge feature โ€” segment 096 โ€” is
5254 # rejected). Genuine gore/island posts pass both (ring ~0, hi 0.28-0.66).
5355 delineator_relaxed_max_ring_fill_ratio: float = section_field(
54class GantryConfig(config_loader.ConfigModel):56 "delineator.relaxed_max_ring_fill_ratio", 1.0
55 """Gantry leg and pairing gates."""57 )
5658 delineator_relaxed_min_hi_intensity_fraction: float = section_field(
57 h_min_m: float = 4.559 "delineator.relaxed_min_hi_intensity_fraction", 0.15
58 len_major_m: float = 8.060 )
59 max_len_minor_m: float = 6.061 delineator_min_hi_intensity_fraction: float = section_field(
60 pair_station_tolerance_m: float = 5.062 "delineator.min_hi_intensity_fraction", 0.08
61 pair_min_separation_m: float = 3.063 )
62 overhead_h_min_m: float = 4.564 # Real Leitpfosten return a few hundred points; sub-~300 bright specks are
63 pair_isolation_radius_m: float = 8.065 # reflective vegetation/debris (segment 048 FP had ~100; segment 084's bright
6466 # speck embedded in verge scrub, newly reachable once the corridor keeps
6567 # branch roads, had 239). Every genuine delineator across the dataset returns
66class RepetitiveRowConfig(config_loader.ConfigModel):68 # >=371, so the 300 floor drops those specks with margin to spare.
67 """Repetitive-row (guardrail post series) grouping."""69 delineator_min_points: int = section_field("delineator.min_points", 300)
6870
69 min_members: int = 471 # Sign post / plate
70 max_spacing_m: float = 5.072 sign_post_max_len_minor_m: float = section_field("sign_post.max_len_minor_m", 0.8)
71 max_perp_spread_m: float = 1.573 sign_post_h_min_m: float = section_field("sign_post.h_min_m", 1.5)
72 max_h_max_range_m: float = 0.774 sign_post_h_max_m: float = section_field("sign_post.h_max_m", 6.0)
73 member_max_len_major_m: float = 2.075 sign_post_min_continuity: float = section_field("sign_post.min_continuity", 0.60)
74 member_max_len_minor_m: float = 0.876 # Plate evidence needs strong retroreflectivity: verified real sign plates
7577 # (segments 006/030/132/134) return an upper-half high-intensity fraction of
7678 # 0.44-0.94, while every dull false-positive "sign" (vegetation mounds,
77class FieldStakeConfig(config_loader.ConfigModel):79 # crash-cushion / truck-rear slabs, forest trunks, vegetation bands) sits at
78 """Field-stake row emission gates."""80 # <=0.35. The gate is set at 0.40 so plate evidence requires a genuine bright
7981 # panel; the weak path allows a moderately-bright, upper-piled plate.
80 row_emit: bool = True82 plate_hi_intensity_fraction: float = section_field(
81 min_members: int = 483 "sign_post.plate_hi_intensity_fraction", 0.40
82 min_spacing_m: float = 2.084 )
83 max_spacing_m: float = 10.085 plate_hi_intensity_fraction_weak: float = section_field(
84 max_spacing_cv: float = 0.3586 "sign_post.plate_hi_intensity_fraction_weak", 0.30
8587 )
8688 # Upper-half point pile-up ratio required as weak-plate evidence and as
87class MarkerExtractConfig(config_loader.ConfigModel):89 # plate *shape*. Raised to 2.0 so a mere ~1.7 surplus (roadside bush crowns,
88 """Bright marker extraction from rejected clusters."""90 # segment 048 FPs) no longer counts as a plate; real plates pile far more
8991 # returns up high (good signs sit at 2.8-4.6, or carry a broad bright core).
90 min_len_major_m: float = 6.092 sign_plate_upper_surplus_ratio: float = section_field(
91 bright_h_min_m: float = 1.593 "sign_post.plate_upper_surplus_ratio", 2.0
92 min_bright_points: int = 40094 )
93 window_m: float = 2.595 # A genuine plate sits high on its post, so the upper half must hold at least
94 min_bright_fraction: float = 0.4596 # as many returns as ~1/3 of the lower half. Low-lying bright blobs at the
95 min_h_max_m: float = 1.697 # foot of a vehicle/truck (segment 106 FPs at ~0.09) are not plates.
96 min_vertical_span_m: float = 0.598 sign_min_upper_half_surplus: float = section_field("sign_post.min_upper_half_surplus", 0.30)
9799 # A real sign PLATE spreads returns laterally (broad core) or piles them in
98100 # the upper half; brightness alone on a tight thin core is a reflective
99class RailHalfpostConfig(config_loader.ConfigModel):101 # post/speck, not a plate โ€” route it to the (stricter) bare-post path.
100 """Guardrail half-post probe stage."""102 plate_min_core_rms_m: float = section_field("sign_post.plate_min_core_rms_m", 0.10)
101103
102 band_lat_m: float = 0.8104 # Bare posts (no plate evidence) must be tall, tight, vertical, and
103 band_z_hi_m: float = 1.5105 # well-sampled. 0.065 m tightness rejects tall roadside vegetation (whose
104 band_z_lo_m: float = 0.15106 # per-bin core reaches ~0.17 m); real marker posts sit near ~0.04 m. The
105 cluster_cell_m: float = 0.15107 # point-count floor rejects small bright reflective specks (~<450 returns).
106 dedupe_m: float = 1.5108 # Plate-less posts below gantry-leg height are indistinguishable from tree
107 enabled: bool = False109 # guards / fence posts by LiDAR geometry alone (confirmed FP in seg 132).
108 ground_cell_m: float = 2.0110 bare_post_min_h_max_m: float = section_field("sign_post.bare_post_min_h_max_m", 4.5)
109 ground_percentile: float = 10.0111 bare_post_max_core_rms_m: float = section_field("sign_post.bare_post_max_core_rms_m", 0.065)
110 h_max_m: float = 0.8112 bare_post_min_verticality: float = section_field("sign_post.bare_post_min_verticality", 0.90)
111 h_min_m: float = 0.2113 bare_post_min_points: int = section_field("sign_post.bare_post_min_points", 450)
112 max_lateral_m: float = 0.5114
113 max_width_m: float = 0.2115 # Isolated floating-pole rejection (far-range boundary ghost, defect class 1a).
114 min_emit_points: int = 8116 # A "floating" pole_other whose base sits well off the ground (h_min high โ€” no
115 min_points: int = 15117 # ground-connected shaft, just an upper vertical smear) is a range-smear
116 min_z_extent_m: float = 0.1118 # artifact at the far edge of dense coverage (segments 005, 015: a lone
117 models_dir: str = ""119 # ~10 m column floating over the carriageway vanishing point) UNLESS it is one
118 prime_min_records: int = 2120 # of several such columns clustered together (a genuine gantry-leg / mast group
119 prime_min_sat: int = 1121 # โ€” segments 046, 066, 025). Verified across the full sweep: the only isolated
120 sample_step_m: float = 0.1122 # floating poles (no floating-pole neighbour within pole_isolated_radius_m) are
121 saturation_intensity: float = 55000.0123 # exactly the 005/015 ghosts; every real gantry-leg pole has >=1 neighbour.
122124 pole_floating_min_h_min_m: float = section_field(
123125 "classification.pole_floating_min_h_min_m", 3.5
124class RejectRescueConfig(config_loader.ConfigModel):126 )
125 """Reject-rescue stage gates."""127 pole_isolated_radius_m: float = section_field("classification.pole_isolated_radius_m", 8.0)
126128
127 accepted_exclusion_m: float = 2.0129 # Post-classification duplicate suppression (defect class 4). Two detections
128 enabled: bool = False130 # within dedup_radius_m XY of each other describe the same physical marker
129 h_max_m: float = 1.6131 # (e.g. a striped gore post firing both a delineator and a sign); keep the
130 h_min_m: float = 0.85132 # higher-priority type (sign > delineator > sign_post > pole_other >
131 max_core_rms_m: float = 0.2133 # gantry_or_gate), breaking ties by point count, and drop the other.
132 merge_radius_m: float = 1.0134 dedup_radius_m: float = section_field("classification.dedup_radius_m", 0.8)
133 min_continuity: float = 0.8135
134 min_decile_fill: float = 0.6136 # Gantry / gate
135 min_h_over_width: float = 1.4137 gantry_h_min_m: float = section_field("gantry.h_min_m", 4.5)
136 min_points: int = 30138 gantry_len_major_m: float = section_field("gantry.len_major_m", 8.0)
137 min_records: int = 2139 # A road-spanning overhead beam is thin; a tilted reflective truck-trailer
138 min_roadctx_sat: int = 17140 # slab (segment 106) is broad (len_minor ~9.8 m). Cap the single-cluster
139 min_verticality: float = 0.9141 # overhead_span footprint minor extent (real gantry cluster ~4.75 m).
140 per_segment_cap: int = 0142 gantry_max_len_minor_m: float = section_field("gantry.max_len_minor_m", 6.0)
143 gantry_pair_station_tolerance_m: float = section_field("gantry.pair_station_tolerance_m", 5.0)
144 # Narrow overhead gates (segment 066: two ~10 m retroreflective legs ~3.7 m
145 # apart straddling a ramp) must still pair, so the minimum lateral
146 # separation is 3.0 m; the overhead-return test guards against false pairs.
147 gantry_pair_min_separation_m: float = section_field("gantry.pair_min_separation_m", 3.0)
148 gantry_overhead_h_min_m: float = section_field("gantry.overhead_h_min_m", 4.5)
149 # A synthesized gantry from a pair of tall posts is only trustworthy when the
150 # pair is ISOLATED โ€” no third tall post nearby. Two ~10 m legs straddling a
151 # ramp with nothing between them is a real gate (segment 066); three-or-more
152 # tall columns clustered at one station are a post row / mast group whose
153 # pairwise "span" crosses empty air (segments 046, 025 โ€” the QC ghosts). If a
154 # third tall post lies within this radius of the pair midpoint, the pairing is
155 # rejected. (The overhead middle-of-span test cannot separate these โ€” verified
156 # from points: 066's real gate also has an empty mid-span, so post COUNT, not
157 # overhead support, is the discriminator.)
158 gantry_pair_isolation_radius_m: float = section_field("gantry.pair_isolation_radius_m", 8.0)
Importance #55: src/iolabs_point_cloud_detection_verticalsigns/_model_evidence.py @@ -0,0 +1,186 @@
1"""Evidence-level thresholds: sentinels, vetoes and reference percentiles.
2
3Covers the verticality sentinel, tier-2 robust extent statistics,
4retroreflectivity references, the single-record transient and
5vegetation-texture vetoes, the delineator lattice and tree emission.
6
7One slice of the flat ``DetectorConfig``. Every field declares, via
8``section_field``, the ``verticalsigns.default.json`` section and key it is
9loaded from; ``_config`` recombines the slices into the model.
10"""
11
12from iolabs.common import config_loader
13
14from ._model_base import section_field
15
16
17class VerticalSignsEvidenceFields(config_loader.ConfigModel):
18 """Evidence-level thresholds: sentinels, vetoes and reference percentiles.
19
20 Covers the verticality sentinel, tier-2 robust extent statistics,
21 retroreflectivity references, the single-record transient and
22 vegetation-texture vetoes, the delineator lattice and tree emission.
23
24 Metres unless stated otherwise.
25 """
26
27 # Verticality sentinel fix (F1, AI3D-339 pass 10). features.py::_verticality
28 # used to return a hard 0.0 for any cluster with len_minor > 0.8 m, which
29 # every verticality-reading acceptance gate then read as "measured
30 # horizontal". 64.5% of A1 fused clusters were hit and 81% of the
31 # unclassified rejects were caused by it; see p10_veto_rootcause.md ยง2 (H2)
32 # and p10_f2_disposition.md (F1: SHIP, 2/2 judge-confirmed recoveries,
33 # measured FP exposure 1 cluster in 21 623). ON by default โ€” the panel
34 # pre-cleared this one. False is the kill-switch: byte-identical to the
35 # pre-fix detector.
36 verticality_sentinel_fix: bool = section_field("classification.verticality_sentinel_fix", True)
37
38 # Tier-2 robust extent statistics (AI3D-339 pass 10). h_max, len_major and
39 # len_minor are sample EXTREMA, monotone non-decreasing in the number of
40 # points, and every acceptance window bounds them from above โ€” so fusing
41 # more records into a cluster can only push a device out of its window.
42 # That is the FUSED-RUN VETO (p10_veto_rootcause.md ยง0). Turning this on
43 # makes the delineator height band, the two delineator footprint windows
44 # and the sign_post slender test read density-invariant twins (an upper
45 # height quantile, p1-p99 projection ranges) instead. It WIDENS NO WINDOW:
46 # the constants were calibrated on typical fused clusters and a robust
47 # statistic pulls the outlier-driven cases back toward typical, so the FP
48 # surface cannot grow. Measured on the reserve burn: 4 real / 0 FP as the
49 # sole attributed component (p10_burn_report.md), so it ships ON per the
50 # pass-10 terminal panel directive (p10_panel_verdict.md closing item 1).
51 # The twin columns are computed and written to clusters.csv either way.
52 robust_extent_stats: bool = section_field("classification.robust_extent_stats", True)
53 robust_h_max_percentile: float = section_field(
54 "classification.robust_h_max_percentile", 98.0, ge=0.0, le=100.0
55 )
56 robust_extent_lo_percentile: float = section_field(
57 "classification.robust_extent_lo_percentile", 1.0, ge=0.0, le=100.0
58 )
59 robust_extent_hi_percentile: float = section_field(
60 "classification.robust_extent_hi_percentile", 99.0, ge=0.0, le=100.0
61 )
62
63 # Absolute retroreflectivity reference: high percentile of the ALL-points
64 # intensity histogram (a stable, non-degenerate reference โ€” unlike the old
65 # p98-of-candidates, which collapsed when a segment had no bright object).
66 # p99.5 lands at near-saturated lane paint, above the delineator reflectors
67 # (~p95-p98 on this sensor), so it is set at p98 to keep retroreflective
68 # markers separable from diffuse vegetation (bush fraction stays ~0.00).
69 hi_intensity_all_points_percentile: float = section_field(
70 "classification.hi_intensity_all_points_percentile", 98.0, ge=0.0, le=100.0
71 )
72
73 # Bright-SEED percentile split (AI3D-339 pass 2). The p98 reference above
74 # is self-referential for seeding: one bright guide panel can push p98
75 # above a weakly sampled Leitpfosten head, so whole 50 m post lattices
76 # never seed (adversarially judged: 37 real objects recovered at p95 on
77 # A4_5). This percentile feeds ONLY the seed pass's bright_counts;
78 # hi_intensity_fraction (a frozen RF-verifier input) and every
79 # classification brightness floor stay on the p98 reference above.
80 # None inherits hi_intensity_all_points_percentile (byte-identical to the
81 # pre-split detector). Default 95 after the A4_5 census + adversarial
82 # judging: +29 judged-real delineators, +3 sub-noise FPs, and the 4 sign
83 # losses were each visually confirmed FPs (ghost, vegetation, smear,
84 # gore paint).
85 seed_bright_percentile: float | None = section_field(
86 "classification.seed_bright_percentile", 95.0
87 )
88
89 # Single-record transient veto (AI3D-339 pass 3). A moving vehicle exists in
90 # exactly one driving pass, so its cluster has n_records_present == 1 โ€”
91 # while 95% of accepted delineators (static roadside inventory) are seen by
92 # 2+ records. Visual audit of all 12 accepted A4_5 signs found 6 moving
93 # vehicles (trucks/cars caught by the bright_panel / embedded_bright_marker
94 # rules): every one single-record, panel-like (verticality <= 0.07), 2.9 m+
95 # long and under 2.0 m tall. Every judged-real sign was either multi-record
96 # or post-vertical (the s134 gore beacon: nrec=1 but verticality 0.9999), so
97 # the conjunction below has wide margins on both sides. h_max cap protects
98 # large genuine panels; verticality cap protects post-mounted plates.
99 # False restores the byte-identical pre-veto detector.
100 single_record_transient_veto: bool = section_field(
101 "classification.single_record_transient_veto", True
102 )
103 transient_max_verticality: float = section_field(
104 "classification.transient_max_verticality", 0.3
105 )
106 transient_min_len_major_m: float = section_field(
107 "classification.transient_min_len_major_m", 2.0
108 )
109 transient_max_h_max_m: float = section_field("classification.transient_max_h_max_m", 2.5)
110
111 # Vegetation-texture veto (AI3D-339 pass 4). The pass-3 footprint
112 # relaxation and A1 veto-off admitted 9 adversarially judged vegetation
113 # FPs (scrub bands, retroreflective tree shelters). Signature: a thick
114 # upper half (plate_thickness_m โ€” a bush or plastic tube is a blob, not a
115 # sheet) AND near-total upper-half brightness at the seed threshold
116 # (hi_intensity_fraction_seed โ€” shelters/bright scrub are uniformly
117 # reflective, while a real marker is bright-head-dark-post or a thin
118 # plate protected by the thickness conjunct). Calibrated on
119 # pipeline-computed values of the 118 judged pass-3 clusters โ€” an earlier
120 # zbin_count_cv conjunct measured on an offline instrument did NOT
121 # transfer to exact cluster points (its separation came from
122 # neighbourhood context) and cost 3 judged reals in the validation
123 # re-run; this pair is derived from the production feature values
124 # themselves. Kills 6/9 accepted veg FPs (both segment-038 shelter
125 # cones, both veg-leaning disputeds, one newly judged shelter trunk in
126 # segment 049) with 0/57 judged reals lost; binding real ag12 (plates on
127 # mast) sits at seed fraction 0.650 vs the 0.668 cut. False restores the
128 # pre-veto detector byte-identically.
129 veg_texture_veto: bool = section_field("classification.veg_texture_veto", True)
130 veg_texture_min_plate_thickness_m: float = section_field(
131 "classification.veg_texture_min_plate_thickness_m", 0.05
132 )
133 veg_texture_min_hi_seed_fraction: float = section_field(
134 "classification.veg_texture_min_hi_seed_fraction", 0.668
135 )
136
137 # Corridor-level delineator-lattice admission (see lattice.py). After all
138 # segments of an invocation are written, accepted delineators seed chain
139 # growth (StVO/HLB row prior: regular spacing, 3-50 m by curvature) over a
140 # strictly gated pool of rejected clusters; pool members phase-locking
141 # into a chain with >= lattice_min_anchors accepted anchors are admitted
142 # as reason "delineator_lattice". Gates were derived on the pass-5 A1
143 # instrument and validated against a position-randomised null: 1-2-anchor
144 # chains are chance at the observed candidate density (their admissions
145 # judged 6/6 vegetation) while >= 4-anchor chains admitted 8 judged-real
146 # posts of 9 candidates; the one vegetation admission had no bright
147 # returns at all, which the hi_seed >= 0.15 + plate <= 0.05 pool gates
148 # remove (every judged-real admission: hi_seed >= 0.18, plate <= 0.04).
149 # h_max window brackets the HLB 1.00 m post. False = no post-pass,
150 # byte-identical outputs.
151 lattice_admission: bool = section_field("classification.lattice_admission", True)
152 lattice_min_anchors: int = section_field("classification.lattice_min_anchors", 4)
153 lattice_snap_m: float = section_field("classification.lattice_snap_m", 3.0)
154 lattice_max_skip: int = section_field("classification.lattice_max_skip", 6)
155 lattice_min_seed_spacing_m: float = section_field(
156 "classification.lattice_min_seed_spacing_m", 15.0
157 )
158 lattice_max_seed_spacing_m: float = section_field(
159 "classification.lattice_max_seed_spacing_m", 60.0
160 )
161 lattice_max_spacing_resid: float = section_field(
162 "classification.lattice_max_spacing_resid", 0.15
163 )
164 lattice_pool_h_max_min_m: float = section_field("classification.lattice_pool_h_max_min_m", 0.8)
165 lattice_pool_h_max_max_m: float = section_field("classification.lattice_pool_h_max_max_m", 1.4)
166 lattice_pool_max_len_major_m: float = section_field(
167 "classification.lattice_pool_max_len_major_m", 1.2
168 )
169 lattice_pool_min_verticality: float = section_field(
170 "classification.lattice_pool_min_verticality", 0.85
171 )
172 lattice_pool_min_points: int = section_field("classification.lattice_pool_min_points", 20)
173 lattice_pool_max_plate_thickness_m: float = section_field(
174 "classification.lattice_pool_max_plate_thickness_m", 0.05
175 )
176 lattice_pool_min_hi_seed_fraction: float = section_field(
177 "classification.lattice_pool_min_hi_seed_fraction", 0.15
178 )
179
180 # Experimental: surface the existing tree-rejection logic as opt-in "tree"
181 # detections instead of silently discarding those clusters. When true,
182 # clusters rejected with reason tree_crown_isotropic, tree_crown_green, or
183 # forest_context are emitted as type "tree" detections (see classify.py's
184 # TREE_REJECT_REASONS) rather than dropped. Off by default so normal runs
185 # are unaffected.
186 emit_trees: bool = section_field("classification.emit_trees", False)
0
Importance #56: src/iolabs_point_cloud_detection_verticalsigns/_model_grid.py @@ -1,152 +1,77 @@
1"""Grid, candidate, classification, radius and corridor config sections.1"""Ground, occupancy grid, candidate band and clustering thresholds.
22
3One slice of the nested :class:`VerticalSignsConfig` model tree; the sections3Also the first classification gates and vehicle rejection.
4mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines4
5the slices.5One slice of the flat ``DetectorConfig``. Every field declares, via
6``section_field``, the ``verticalsigns.default.json`` section and key it is
7loaded from; ``_config`` recombines the slices into the model.
6"""8"""
79
8from iolabs.common import config_loader10from iolabs.common import config_loader
911
1012from ._model_base import section_field
11class GroundConfig(config_loader.ConfigModel):13
12 """Ground-model raster cell size and percentile."""14
1315class VerticalSignsGridFields(config_loader.ConfigModel):
14 cell_m: float = 0.7516 """Ground, occupancy grid, candidate band and clustering thresholds.
15 percentile: float = 8.017
1618 Also the first classification gates and vehicle rejection.
1719
18class OccupancyConfig(config_loader.ConfigModel):20 Metres unless stated otherwise.
19 """Occupancy grid used to find candidate cells."""21 """
2022
21 cell_m: float = 0.1523 # Ground model
2224 ground_cell_m: float = section_field("ground.cell_m", 0.75, gt=0.0)
2325 ground_percentile: float = section_field("ground.percentile", 8.0, ge=0.0, le=100.0)
24class CandidatesConfig(config_loader.ConfigModel):26
25 """Height band and seed-cell gates for candidate points."""27 # Occupancy grid for candidate cells
2628 occupancy_cell_m: float = section_field("occupancy.cell_m", 0.15, gt=0.0)
27 min_height_m: float = 0.329
28 max_height_m: float = 10.030 # Height band for off-ground candidate points
29 seed_min_vertical_span_m: float = 0.831 min_height_m: float = section_field("candidates.min_height_m", 0.30)
30 seed_min_h_max_m: float = 0.932 max_height_m: float = section_field("candidates.max_height_m", 10.0)
31 seed_bright_min_vertical_span_m: float = 0.4533
32 seed_bright_min_h_max_m: float = 0.634 # Seed-cell gates (vertical span and max height above ground)
33 seed_bright_min_points: int = 335 seed_min_vertical_span_m: float = section_field("candidates.seed_min_vertical_span_m", 0.80)
3436 seed_min_h_max_m: float = section_field("candidates.seed_min_h_max_m", 0.90)
3537
36class ClusteringConfig(config_loader.ConfigModel):38 # Delineator recall seed pass. German Leitpfosten are ~1.0 m and, when
37 """DBSCAN clustering of seed-cell centres."""39 # sparsely sampled at range, span only ~0.75 m inside a 0.15 m occupancy
3840 # cell (base clipped by min_height_m=0.30), so they fall just under the
39 eps_m: float = 0.4541 # 0.80 m primary span gate and never seed a cluster โ€” the round-4 recall
40 min_samples: int = 142 # gap. A second, relaxed seed pass recovers them, but is restricted to
41 hull_margin_m: float = 0.243 # cells holding >= seed_bright_min_points retroreflective returns
4244 # (intensity >= the segment's hi-intensity threshold): a Leitpfosten head
4345 # is always retroreflective, so the extra candidate cells stay few and the
44class ClassificationConfig(config_loader.ConfigModel):46 # existing delineator gates + FP defenses (brightness, footprint, density,
45 """Cluster-level accept/reject gates and ML verifier wiring."""47 # corridor, ring/forest) decide the verdict.
4648 seed_bright_min_vertical_span_m: float = section_field(
47 continuity_bin_m: float = 0.2549 "candidates.seed_bright_min_vertical_span_m", 0.45
48 reject_len_major_m: float = 6.050 )
49 reject_h_max_with_large_footprint_m: float = 4.551 seed_bright_min_h_max_m: float = section_field("candidates.seed_bright_min_h_max_m", 0.60)
50 min_continuity: float = 0.552 seed_bright_min_points: int = section_field("candidates.seed_bright_min_points", 3)
51 min_accept_h_max_m: float = 0.953
52 core_rms_bin_m: float = 0.2554 # DBSCAN clustering on seed-cell centres
53 core_rms_h_min_m: float = 0.355 cluster_eps_m: float = section_field("clustering.eps_m", 0.45)
54 core_rms_h_cap_m: float = 3.056 cluster_min_samples: int = section_field("clustering.min_samples", 1)
55 hi_intensity_all_points_percentile: float = 98.057 cluster_hull_margin_m: float = section_field("clustering.hull_margin_m", 0.20)
56 min_volumetric_density: float = 8000.058
57 pole_floating_min_h_min_m: float = 3.559 # Per-cluster feature bins
58 pole_isolated_radius_m: float = 8.060 continuity_bin_m: float = section_field("classification.continuity_bin_m", 0.25)
59 dedup_radius_m: float = 0.861
60 emit_trees: bool = False62 # Classification thresholds
61 ml_verifier_enabled: bool = True63 reject_len_major_m: float = section_field("classification.reject_len_major_m", 6.0)
62 ml_veto_threshold: float = -1.064 reject_h_max_with_large_footprint_m: float = section_field(
63 ml_model_path: str = ""65 "classification.reject_h_max_with_large_footprint_m", 4.5
64 lattice_admission: bool = True66 )
65 lattice_max_seed_spacing_m: float = 60.067 min_continuity: float = section_field("classification.min_continuity", 0.50)
66 lattice_max_skip: int = 668 min_accept_h_max_m: float = section_field("classification.min_accept_h_max_m", 0.90)
67 lattice_max_spacing_resid: float = 0.1569
68 lattice_min_anchors: int = 470 # Vehicle rejection
69 lattice_min_seed_spacing_m: float = 15.071 vehicle_h_min_m: float = section_field("vehicle.h_min_m", 1.5)
70 lattice_pool_h_max_max_m: float = 1.472 vehicle_h_max_m: float = section_field("vehicle.h_max_m", 4.5)
71 lattice_pool_h_max_min_m: float = 0.873 vehicle_len_major_m: float = section_field("vehicle.len_major_m", 2.5)
72 lattice_pool_max_len_major_m: float = 1.274 vehicle_len_minor_m: float = section_field("vehicle.len_minor_m", 1.5)
73 lattice_pool_max_plate_thickness_m: float = 0.0575 vehicle_max_hi_intensity_fraction: float = section_field(
74 lattice_pool_min_hi_seed_fraction: float = 0.1576 "vehicle.max_hi_intensity_fraction", 0.10
75 lattice_pool_min_points: int = 2077 )
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
Importance #57: src/iolabs_point_cloud_detection_verticalsigns/_model_perspective.py @@ -0,0 +1,59 @@
1"""Perspective-projection QC overlay cameras and per-detection QC views.
2
3One slice of the flat ``DetectorConfig``. Every field declares, via
4``section_field``, the ``verticalsigns.default.json`` section and key it is
5loaded from; ``_config`` recombines the slices into the model.
6"""
7
8from iolabs.common import config_loader
9
10from ._model_base import section_field
11
12
13class VerticalSignsPerspectiveFields(config_loader.ConfigModel):
14 """Perspective-projection QC overlay cameras and coverage tolerances.
15
16 Metres unless stated otherwise.
17 """
18
19 # Perspective-projection QC overlay (verticalsigns-perspective). A projected
20 # vertical-line sample is "visible" when its camera-space depth is within
21 # perspective_depth_tol_m of the rendered depth-buffer value; occluded
22 # samples are drawn faint at perspective_occluded_alpha.
23 perspective_depth_tol_m: float = section_field("perspective.depth_tol_m", 0.5)
24 perspective_line_samples: int = section_field("perspective.line_samples", 20)
25 perspective_occluded_alpha: int = section_field("perspective.occluded_alpha", 90)
26 perspective_solid_width_px: int = section_field("perspective.solid_width_px", 3)
27 perspective_halo_width_px: int = section_field("perspective.halo_width_px", 6)
28 perspective_base_marker_radius_px: int = section_field("perspective.base_marker_radius_px", 6)
29 # Synthesized fallback cameras for detections that no Azure metadata camera
30 # covers (outside every frustum, or projecting onto a void/black background).
31 # An 'auto_back' camera sits perspective_back_distance_m behind the detection
32 # along the road axis at perspective_back_height_m above z_ground; an
33 # 'auto_context' camera sits farther back and higher for scene context.
34 # Uncovered detections within perspective_share_radius_m share one camera pair
35 # aimed at their centroid. A detection counts as covered by a camera when its
36 # projected vertical line lands on rendered geometry within
37 # perspective_coverage_tol_m of the depth buffer.
38 perspective_back_distance_m: float = section_field("perspective.back_distance_m", 22.0)
39 perspective_back_height_m: float = section_field("perspective.back_height_m", 4.0)
40 perspective_context_distance_m: float = section_field("perspective.context_distance_m", 40.0)
41 perspective_context_height_m: float = section_field("perspective.context_height_m", 6.0)
42 perspective_share_radius_m: float = section_field("perspective.share_radius_m", 15.0)
43 perspective_coverage_tol_m: float = section_field("perspective.coverage_tol_m", 0.5)
44
45
46class VerticalSignsViewsFields(config_loader.ConfigModel):
47 """Per-detection QC view rendering (``verticalsigns-views``).
48
49 Metres unless stated otherwise. The view renderer reads these from the
50 nested document rather than off the flat config, so they are declared here
51 only to keep the packaged JSON and the model in lockstep.
52 """
53
54 views_near_radius_m: float = section_field("views.near_radius_m", 45.0)
55 views_fov_deg: float = section_field("views.fov_deg", 55.0)
56 views_splat: int = section_field("views.splat", 2)
57 views_image_width: int = section_field("views.image_width", 1100)
58 views_image_height: int = section_field("views.image_height", 750)
59 views_view_names: tuple[str, ...] = section_field("views.view_names", ("back", "side"))
0
Importance #58: src/iolabs_point_cloud_detection_verticalsigns/_model_road.py @@ -1,99 +1,215 @@
1"""Road-context, edge-line and QC rendering config sections.1"""Road-context gate, driven-lane band and repetitive-row rejection.
22
3One slice of the nested :class:`VerticalSignsConfig` model tree; the sections3Also field-stake rows and embedded-marker extraction.
4mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines4
5the slices.5One slice of the flat ``DetectorConfig``. Every field declares, via
6``section_field``, the ``verticalsigns.default.json`` section and key it is
7loaded from; ``_config`` recombines the slices into the model.
6"""8"""
79
8from iolabs.common import config_loader10from iolabs.common import config_loader
911
12from ._model_base import section_field
1013
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
2914
15class VerticalSignsRoadContextFields(config_loader.ConfigModel):
16 """Road-context gate, driven-lane band and repetitive-row rejection.
3017
31class EdgeLineConfig(config_loader.ConfigModel):18 Also field-stake rows and embedded-marker extraction.
32 """Edge-line paint detection and far-distance filtering."""
3319
34 gate_enabled: bool = True20 Metres unless stated otherwise.
35 paint_max_height_m: float = 0.3521 """
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
7222
23 # Repetitive-row rejection: a noise-barrier (Lรคrmschutzwand) support row
24 # (segment 116) is >=4 slender clusters of similar height on a line at
25 # regular <=5 m spacing. Delineators repeat at 25-50 m so they never form
26 # such a chain and stay safe.
27 row_min_members: int = section_field("repetitive_row.min_members", 4)
28 row_max_spacing_m: float = section_field("repetitive_row.max_spacing_m", 5.0)
29 row_max_perp_spread_m: float = section_field("repetitive_row.max_perp_spread_m", 1.5)
30 row_max_h_max_range_m: float = section_field("repetitive_row.max_h_max_range_m", 0.7)
31 row_member_max_len_major_m: float = section_field("repetitive_row.member_max_len_major_m", 2.0)
32 row_member_max_len_minor_m: float = section_field("repetitive_row.member_max_len_minor_m", 0.8)
7333
74class ViewsConfig(config_loader.ConfigModel):34 # Road-context gate (AI3D-339 pass 7): a delineator with ZERO saturated
75 """Rendered QC view cameras and image size."""35 # returns within roadctx_radius_m is not beside a carriageway and cannot be
36 # road furniture. Presence only โ€” absolute counts run ~100x lower on the
37 # A1 branch-1 ramp than on the mainline, so no count threshold transfers.
38 # See roadctx.py.
39 roadctx_gate_enabled: bool = section_field("road_context.gate_enabled", True)
40 roadctx_saturation_intensity: float = section_field(
41 "road_context.saturation_intensity", 55000.0
42 )
43 roadctx_radius_m: float = section_field("road_context.radius_m", 15.0)
44 # Segments either side to pool: a candidate near a tile boundary otherwise
45 # sees a truncated disc and can read zero purely from tiling.
46 roadctx_neighbour_span: int = section_field("road_context.neighbour_span", 1)
47 # Domain guard: below this many saturated returns in the pooled
48 # neighbourhood the measurement is coverage noise, not evidence of "no
49 # road", and the gate disarms. See RoadContext.armed.
50 roadctx_min_neighbourhood_saturated: int = section_field(
51 "road_context.min_neighbourhood_saturated", 1000
52 )
53 # Local-ext4 cache for the per-segment saturated-return arrays; empty falls
54 # back to a road_context/ directory beside the per-segment output dirs.
55 roadctx_cache_dir: str = section_field("road_context.cache_dir", "")
7656
77 near_radius_m: float = 45.057 # Driven-lane band gate (AI3D-339 pass 8, Miro directive). A short
78 fov_deg: float = 55.058 # candidate standing in the lane the survey vehicle drove is a vehicle, not
79 splat: int = 259 # road furniture. The pass-8 census killed the wider "between the two edge
80 image_width: int = 110060 # lines of the carriageway" form โ€” run4 is absent on A1 and a featureless
81 image_height: int = 75061 # full-tile rectangle on A4_5, and paint runs at uniform lane spacing right
82 view_names: tuple[str, ...] = ("back", "side")62 # across the median. See edgeline.py and p8_edgeline_census_result.md.
63 edgeline_gate_enabled: bool = section_field("edge_line.gate_enabled", True)
64 # run7 lane XML is the PRIMARY road model (Miro: "use the lines from
65 # run7" / "from the XML. Much more reliable"). See run7_xml.py.
66 edgeline_xml_enabled: bool = section_field("edge_line.xml_enabled", True)
67 # Cross-file consensus: with many per-drive XMLs a point is on the road
68 # only if this fraction of the files covering it agree. One bad variant
69 # must not be able to put a median device on the carriageway.
70 edgeline_xml_min_agreement: float = section_field("edge_line.xml_min_agreement", 0.6)
71 # A file whose band is further than this from the point abstains rather
72 # than voting "outside" โ€” it is describing a different stretch of road.
73 edgeline_xml_vote_slack_m: float = section_field("edge_line.xml_vote_slack_m", 3.0)
74 edgeline_xml_max_distance_m: float = section_field("edge_line.xml_max_distance_m", 60.0)
75 edgeline_xml_station_tolerance_m: float = section_field(
76 "edge_line.xml_station_tolerance_m", 2.0
77 )
78 edgeline_xml_station_step_m: float = section_field("edge_line.xml_station_step_m", 10.0)
79 # A full carriageway, not a lane: the XML edges bound the whole thing.
80 edgeline_min_carriageway_width_m: float = section_field(
81 "edge_line.min_carriageway_width_m", 3.0
82 )
83 edgeline_max_carriageway_width_m: float = section_field(
84 "edge_line.max_carriageway_width_m", 20.0
85 )
86 # Paint extraction is demoted to a fallback for corridors with no lane
87 # XML, and is OFF by default per the run7 directive.
88 edgeline_paint_fallback_enabled: bool = section_field("edge_line.paint_fallback_enabled", False)
89 # Paint band: height above the local DEM within which a return is road
90 # marking rather than a device face (a delineator's band sits at 0.7-0.9 m).
91 edgeline_paint_max_height_m: float = section_field("edge_line.paint_max_height_m", 0.35)
92 edgeline_paint_min_height_m: float = section_field("edge_line.paint_min_height_m", -0.25)
93 # Paint cut as a PERCENTILE of near-ground intensity, never a DN: measured
94 # p95 = 39.3k/39.5k/41.3k on three A4_5 segments, while the roadctx
95 # saturation cut (55000) shows only the single line nearest the drive line.
96 edgeline_paint_intensity_percentile: float = section_field(
97 "edge_line.paint_intensity_percentile", 95.0, ge=0.0, le=100.0
98 )
99 edgeline_paint_subsample: int = section_field("edge_line.paint_subsample", 20)
100 # Along-road window.
101 edgeline_station_len_m: float = section_field("edge_line.station_len_m", 10.0)
102 edgeline_min_window_returns: int = section_field("edge_line.min_window_returns", 2000)
103 # Painted-line detection in the lateral histogram.
104 edgeline_lateral_bin_m: float = section_field("edge_line.lateral_bin_m", 0.10)
105 edgeline_min_line_points: int = section_field("edge_line.min_line_points", 40)
106 edgeline_max_line_width_m: float = section_field("edge_line.max_line_width_m", 1.5)
107 edgeline_min_line_along_fill: float = section_field("edge_line.min_line_along_fill", 0.4)
108 # Drive line = densest lateral bin of all near-ground returns.
109 edgeline_drive_line_bin_m: float = section_field("edge_line.drive_line_bin_m", 0.5)
110 # Band sanity: one or two lanes. Wider means a line was missed.
111 edgeline_min_band_width_m: float = section_field("edge_line.min_band_width_m", 2.0)
112 edgeline_max_band_width_m: float = section_field("edge_line.max_band_width_m", 9.0)
113 # INWARD margin. Delineators stand ON the paint line, so the margin must
114 # shrink the rejection zone, never grow it.
115 edgeline_inward_margin_m: float = section_field("edge_line.inward_margin_m", 0.3)
116 edgeline_min_coverage_frac: float = section_field("edge_line.min_coverage_frac", 0.6)
117 # Axis sanity, replacing the tile-elongation guard that misfired on real
118 # 51x34 m tiles: the paint must be sharper ACROSS the chosen axis than
119 # along it (measured ~19x on A4_5).
120 edgeline_min_axis_contrast: float = section_field("edge_line.min_axis_contrast", 3.0)
121 # Central-axis prior (cross_sections_run7_lanes_*.npz).
122 edgeline_axis_search_radius_m: float = section_field("edge_line.axis_search_radius_m", 40.0)
123 edgeline_axis_max_angle_cos: float = section_field("edge_line.axis_max_angle_cos", 0.8)
124 edgeline_axis_max_distance_m: float = section_field("edge_line.axis_max_distance_m", 150.0)
125 # Overhead exemption; type-based exemption in classify.py covers the rest.
126 edgeline_exempt_h_max_m: float = section_field("edge_line.exempt_h_max_m", 4.5)
127 # Corroboration: a transient exists in one driving pass only. Rejection
128 # requires this AND on-road position; position alone is a flag.
129 edgeline_reject_requires_transient: bool = section_field(
130 "edge_line.reject_requires_transient", True
131 )
132 edgeline_transient_max_records: int = section_field("edge_line.transient_max_records", 1)
133 # Far-from-edge-line filter. Delineators stand 0.5-2 m off the carriageway
134 # edge; a "delineator" tens of metres away is a plantation or field stake
135 # (the class reject_rescue readmits). Default 30.0 m sits between real
136 # ramp posts at junctions with fragmentary XML coverage (p50 3.3 m / max
137 # 26.9 m with roleless edges included; A4_5 segs 131-135) and the
138 # false-positive stake rows (35-50 m on A4_5 038/049 and A1 branch-1
139 # 007/008). Measures against ALL XML edge features including roleless
140 # ramp edges. See edgedist.py.
141 edgeline_far_filter_enabled: bool = section_field("edge_line.far_filter_enabled", True)
142 edgeline_far_max_distance_m: float = section_field("edge_line.far_max_distance_m", 30.0)
143 # Also measure against painted lane-line families (Center Lines, Central
144 # Axis, Single-Side Central Axis). A delineator beside a painted line is
145 # near a road even where no Axis-of-the-Edge was extracted; this can only
146 # reduce false removals. Does not leak into the carriageway band model.
147 edgeline_far_include_lane_lines: bool = section_field("edge_line.far_include_lane_lines", True)
148 # Second, tighter far-from-edge cut for the 15-30 m band. Real ramp posts
149 # whose XML ramps are missing sit in that band with roadctx_n_sat 200-57k;
150 # reject-rescue stake rows in fields sit there with sat 2-130. Kill when
151 # screen distance exceeds the tighter cut AND measured saturation is
152 # below the paved-surface floor. See edgedist.py.
153 edgeline_far_tier2_enabled: bool = section_field("edge_line.far_tier2_enabled", True)
154 edgeline_far_tier2_distance_m: float = section_field("edge_line.far_tier2_distance_m", 15.0)
155 edgeline_far_tier2_max_saturation: int = section_field(
156 "edge_line.far_tier2_max_saturation", 150
157 )
83158
159 # Field-stake rows: road-context failures that are phase-locked at stake
160 # spacing (A1 072/073 agricultural row at 5.8 m; A4_5 plantation rows at
161 # 4-5 m) are emitted as the experimental "field_stake_row" class instead of
162 # being dropped. min_members counts the whole row, so >=3 neighbours.
163 field_stake_row_emit: bool = section_field("field_stake.row_emit", True)
164 field_stake_min_members: int = section_field("field_stake.min_members", 4)
165 field_stake_min_spacing_m: float = section_field("field_stake.min_spacing_m", 2.0)
166 field_stake_max_spacing_m: float = section_field("field_stake.max_spacing_m", 10.0)
167 field_stake_max_spacing_cv: float = section_field("field_stake.max_spacing_cv", 0.35)
84168
85class PerspectiveConfig(config_loader.ConfigModel):169 # Embedded-marker extraction: a bright vertical sign/delineator that DBSCAN
86 """Perspective-projection QC overlay cameras and tolerances."""170 # glued onto an adjacent guardrail/barrier gets rejected as a large
171 # footprint. Scan the along-axis brightness profile of such rejected
172 # clusters for a compact, salient, retroreflective panel (segment 006).
173 marker_extract_min_len_major_m: float = section_field("marker_extract.min_len_major_m", 6.0)
174 marker_extract_bright_h_min_m: float = section_field("marker_extract.bright_h_min_m", 1.5)
175 marker_extract_min_bright_points: int = section_field("marker_extract.min_bright_points", 400)
176 marker_extract_window_m: float = section_field("marker_extract.window_m", 2.5)
177 marker_extract_min_bright_fraction: float = section_field(
178 "marker_extract.min_bright_fraction", 0.45
179 )
180 marker_extract_min_h_max_m: float = section_field("marker_extract.min_h_max_m", 1.6)
181 # Embedded-marker validation (defect class 3). The extracted window must be a
182 # genuine off-ground marker, not a flat bright road-surface artifact glued to a
183 # barrier. Require real vertical extent (points spanning at least this many
184 # metres) AND, for a window emitted as a "sign", genuine plate geometry โ€” a
185 # thin, slender slab (plate_thickness_m <= sign_max_plate_thickness_m and
186 # len_minor <= sign_post_max_len_minor_m). Segment 079's on-road paint blob
187 # (len_minor 2.06 m, plate_thickness 0.16 m) fails both; segment 006's real
188 # guide board (0.45 m, 0.005 m) passes. NB: an on-road-fraction guard is NOT
189 # used here because 006's window also reads on_road_fraction 1.0 โ€” plate
190 # geometry, not road overlap, is the true separator.
191 marker_extract_min_vertical_span_m: float = section_field(
192 "marker_extract.min_vertical_span_m", 0.5
193 )
87194
88 depth_tol_m: float = 0.5195 # Legacy ``road_context`` copies of the ``edge_line`` keys of the same name.
89 line_samples: int = 20196 # The detector reads the ``edge_line`` fields above; these are declared so
90 occluded_alpha: int = 90197 # the packaged JSON keeps validating, and so an override file written
91 solid_width_px: int = 3198 # against the old section spelling is still accepted rather than rejected.
92 halo_width_px: int = 6199 roadctx_xml_enabled: bool = section_field("road_context.xml_enabled", True)
93 base_marker_radius_px: int = 6200 roadctx_xml_min_agreement: float = section_field("road_context.xml_min_agreement", 0.6)
94 back_distance_m: float = 22.0201 roadctx_xml_vote_slack_m: float = section_field("road_context.xml_vote_slack_m", 3.0)
95 back_height_m: float = 4.0202 roadctx_xml_max_distance_m: float = section_field("road_context.xml_max_distance_m", 60.0)
96 context_distance_m: float = 40.0203 roadctx_xml_station_tolerance_m: float = section_field(
97 context_height_m: float = 6.0204 "road_context.xml_station_tolerance_m", 2.0
98 share_radius_m: float = 15.0205 )
99 coverage_tol_m: float = 0.5206 roadctx_xml_station_step_m: float = section_field("road_context.xml_station_step_m", 10.0)
207 roadctx_min_carriageway_width_m: float = section_field(
208 "road_context.min_carriageway_width_m", 3.0
209 )
210 roadctx_max_carriageway_width_m: float = section_field(
211 "road_context.max_carriageway_width_m", 20.0
212 )
213 roadctx_paint_fallback_enabled: bool = section_field(
214 "road_context.paint_fallback_enabled", False
215 )
Importance #59: src/iolabs_point_cloud_detection_verticalsigns/_model_stages.py @@ -0,0 +1,130 @@
1"""Opt-in post-classification stages.
2
3The rail-relative half-post pass, the reject-rescue second look and
4the ML verifier.
5
6One slice of the flat ``DetectorConfig``. Every field declares, via
7``section_field``, the ``verticalsigns.default.json`` section and key it is
8loaded from; ``_config`` recombines the slices into the model.
9"""
10
11from iolabs.common import config_loader
12
13from ._model_base import section_field
14
15
16class VerticalSignsStageFields(config_loader.ConfigModel):
17 """Opt-in post-classification stages.
18
19 The rail-relative half-post pass, the reject-rescue second look and
20 the ML verifier.
21
22 Metres unless stated otherwise.
23 """
24
25 # Rail-relative half-post stage (see railpost.py; AI3D-339 pass 10). A
26 # guardrail-mounted delineator body is invisible to the main path: it fuses
27 # with the W-beam into one 45 m blob at seeding. This stage searches the
28 # band above each rail's measured beam crest, given guardrail models from
29 # the guardrails repo. ~91% of A4_5 is railed, so the class is the dominant
30 # delineator morphology there, not an edge case.
31 #
32 # Every constant is FROZEN from the pass-8 A4_5 probe and its pass-9 A1
33 # re-run, which applied the gate unchanged โ€” the panel's "twice-transferred"
34 # requirement. They are config keys so the reserve burn can toggle them,
35 # not because they are open for tuning.
36 #
37 # prime (n_sat >= 1 AND nrec >= 2) is a CONFIDENCE MARKER, NEVER A GATE:
38 # the pass-9 control arm measured the non-prime tail at 43% real, which
39 # makes prime a ~2.2x precision-ranking device. Gating on it would throw
40 # away a near-coin-flip tail.
41 #
42 # OFF by default: validation needs the ratified truth set.
43 rail_halfpost_stage: bool = section_field("rail_halfpost.enabled", False)
44 # Root searched for **/segment_<id>/guardrails.json (the guardrails repo
45 # writes one output root per worker: out_w0/, out_w1/, ...). Empty disables
46 # the stage even when the flag is on.
47 rail_halfpost_models_dir: str = section_field("rail_halfpost.models_dir", "")
48 # Band geometry (probe constants). The 0.15 m floor is calibrated: the
49 # W-beam's own returns reach ~0.20 m above the fitted top, and below that
50 # floor every cluster in the band fuses into one blob per rail.
51 rail_halfpost_band_lat_m: float = section_field("rail_halfpost.band_lat_m", 0.80)
52 rail_halfpost_band_z_lo_m: float = section_field("rail_halfpost.band_z_lo_m", 0.15)
53 rail_halfpost_band_z_hi_m: float = section_field("rail_halfpost.band_z_hi_m", 1.50)
54 rail_halfpost_sample_step_m: float = section_field("rail_halfpost.sample_step_m", 0.10)
55 rail_halfpost_cluster_cell_m: float = section_field(
56 "rail_halfpost.cluster_cell_m", 0.15, gt=0.0
57 )
58 rail_halfpost_min_emit_points: int = section_field("rail_halfpost.min_emit_points", 8)
59 rail_halfpost_ground_cell_m: float = section_field("rail_halfpost.ground_cell_m", 2.0, gt=0.0)
60 rail_halfpost_ground_percentile: float = section_field(
61 "rail_halfpost.ground_percentile", 10.0, ge=0.0, le=100.0
62 )
63 rail_halfpost_saturation_intensity: float = section_field(
64 "rail_halfpost.saturation_intensity", 55000.0
65 )
66 # Acceptance gate (pass-8, transferred to A1 unchanged in pass 9).
67 rail_halfpost_h_min_m: float = section_field("rail_halfpost.h_min_m", 0.20)
68 rail_halfpost_h_max_m: float = section_field("rail_halfpost.h_max_m", 0.80)
69 rail_halfpost_max_lateral_m: float = section_field("rail_halfpost.max_lateral_m", 0.50)
70 rail_halfpost_max_width_m: float = section_field("rail_halfpost.max_width_m", 0.20)
71 rail_halfpost_min_points: int = section_field("rail_halfpost.min_points", 15)
72 rail_halfpost_min_z_extent_m: float = section_field("rail_halfpost.min_z_extent_m", 0.10)
73 rail_halfpost_dedupe_m: float = section_field("rail_halfpost.dedupe_m", 1.5)
74 # Confidence marker only โ€” see above.
75 rail_halfpost_prime_min_sat: int = section_field("rail_halfpost.prime_min_sat", 1)
76 rail_halfpost_prime_min_records: int = section_field("rail_halfpost.prime_min_records", 2)
77
78 # Reject-rescue second-look stage (see rescue.py; AI3D-339 pass 10). The
79 # pass-9 sieve's stratum A, ported as a detector stage: a label-free
80 # physical screen over clusters the detector rejected with a reason that
81 # named no positive counter-indication. Seven clusters called vegetation
82 # over the lifetime of the loop were later overturned to real devices, and
83 # the criteria below are the profile those seven share, with each threshold
84 # anchored to a percentile of the detector's OWN accepted delineators on the
85 # same run โ€” never to a judged label (out_eval/pass9/p9_sieve.py).
86 #
87 # Brightness is deliberately NOT a gate: three of the seven overturns were
88 # explicitly unsaturated. It is a rank bonus in the sieve and nothing here.
89 #
90 # OFF by default: validation needs the ratified truth set.
91 reject_rescue_stage: bool = section_field("reject_rescue.enabled", False)
92 rescue_h_min_m: float = section_field("reject_rescue.h_min_m", 0.85)
93 rescue_h_max_m: float = section_field("reject_rescue.h_max_m", 1.60)
94 rescue_min_verticality: float = section_field("reject_rescue.min_verticality", 0.90)
95 rescue_max_core_rms_m: float = section_field("reject_rescue.max_core_rms_m", 0.20)
96 rescue_min_h_over_width: float = section_field("reject_rescue.min_h_over_width", 1.40)
97 rescue_min_records: int = section_field("reject_rescue.min_records", 2)
98 rescue_min_roadctx_sat: int = section_field("reject_rescue.min_roadctx_sat", 17)
99 rescue_min_continuity: float = section_field("reject_rescue.min_continuity", 0.80)
100 rescue_min_decile_fill: float = section_field("reject_rescue.min_decile_fill", 0.60)
101 rescue_min_points: int = section_field("reject_rescue.min_points", 30)
102 # Two rescues this close describe one physical object; keep the better one.
103 rescue_merge_radius_m: float = section_field("reject_rescue.merge_radius_m", 1.0)
104 # A rescue within this distance of something already accepted is not a
105 # rescue, it is a duplicate.
106 rescue_accepted_exclusion_m: float = section_field("reject_rescue.accepted_exclusion_m", 2.0)
107 # Sieve's PER_SEGMENT_CAP was a crop-budget device for a judge pool, not a
108 # physical criterion, so it does not ship as one: 0 means no cap.
109 rescue_per_segment_cap: int = section_field("reject_rescue.per_segment_cap", 0)
110
111 # ML verifier stage (see ml.py). When enabled and a model file resolves,
112 # every accepted detection gets an "ml_confidence" = P(real) in the JSON and
113 # detections scoring below ml_veto_threshold are dropped with reason
114 # ml_vetoed (logged in clusters.csv). Enabled by default but a pure no-op
115 # when no model is present, so a fresh checkout behaves exactly as before.
116 # A negative ml_veto_threshold means "use the threshold in the model
117 # bundle"; ml_model_path empty means "resolve models/latest.json".
118 ml_verifier_enabled: bool = section_field("classification.ml_verifier_enabled", True)
119 ml_veto_threshold: float = section_field("classification.ml_veto_threshold", -1.0)
120 ml_model_path: str = section_field("classification.ml_model_path", "")
121 # The verifier was trained on corridor-bearing A4_5 data with its veto
122 # threshold anchored to the minimum P(real) among training reals (0.62).
123 # On a run4-less dataset the model runs out-of-domain: measured on
124 # Abschnitt 1, all five adversarially judged-real signs of the segment-048
125 # family scored P 0.51-0.59 and were vetoed. When True (default), segments
126 # without run4 road-surface files score-and-annotate but do not veto;
127 # corridor-bearing segments (all of A4_5) are byte-identical either way.
128 ml_veto_requires_corridor: bool = section_field(
129 "classification.ml_veto_requires_corridor", True
130 )
0
Importance #60: src/iolabs_point_cloud_detection_verticalsigns/_model_tree.py @@ -1,180 +0,0 @@
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 #61: src/iolabs_point_cloud_detection_verticalsigns/_model_treedetect.py @@ -0,0 +1,87 @@
1"""Experimental tree detection and TCS ground filtering of the DEM input.
2
3One slice of the flat ``DetectorConfig``. Every field declares, via
4``section_field``, the ``verticalsigns.default.json`` section and key it is
5loaded from; ``_config`` recombines the slices into the model.
6"""
7
8from typing import Literal, TypeAlias
9
10from iolabs.common import config_loader
11
12from ._model_base import section_field
13
14#: Ground-filter mechanism, spelled exactly as ``iolabs_point_cloud_tablecloth`` types it.
15TcsMechanism: TypeAlias = Literal["none", "smrf_numpy", "csf_cloth"]
16
17
18class VerticalSignsTreeDetectionFields(config_loader.ConfigModel):
19 """Experimental tree detection and TCS ground filtering of the DEM input.
20
21 Metres unless stated otherwise.
22 """
23
24 # Experimental vegetation (tree) detection path (Part B). Master flag off by
25 # default; enabled via a config override for the tree run. A coarser DBSCAN
26 # and a wider (20 m) corridor run SEPARATELY from the sign path, and a
27 # dedicated vegetation RF (models/latest_vegetation.json) decides tree-vs-not.
28 # Candidates sitting directly above road-surface cells (a bridge/elevated
29 # deck, segment 033) are rejected by the on-road-fraction bridge guard.
30 tree_detection_enabled: bool = section_field("tree_detection.enabled", False)
31 tree_max_dist_to_road_m: float = section_field("tree_detection.max_dist_to_road_m", 20.0)
32 tree_seed_min_vertical_span_m: float = section_field(
33 "tree_detection.seed_min_vertical_span_m", 1.5
34 )
35 tree_seed_points_above_m: float = section_field("tree_detection.seed_points_above_m", 2.0)
36 tree_eps_m: float = section_field("tree_detection.eps_m", 1.5)
37 tree_min_samples: int = section_field("tree_detection.min_samples", 3)
38 tree_hull_margin_m: float = section_field("tree_detection.hull_margin_m", 0.5)
39 tree_min_points: int = section_field("tree_detection.min_points", 60)
40 tree_bridge_max_on_road_fraction: float = section_field(
41 "tree_detection.bridge_max_on_road_fraction", 0.6
42 )
43 tree_dedup_radius_m: float = section_field("tree_detection.dedup_radius_m", 2.0)
44 tree_min_confidence: float = section_field("tree_detection.min_confidence", -1.0)
45 tree_model_path: str = section_field("tree_detection.model_path", "")
46 # Hedge split: every accepted tree cluster is put through the instance
47 # splitter's band (hedge) rule, and a grounded, low, long, stemless,
48 # flat-topped one is emitted as "medium_vegetation" (LAS 4) instead of
49 # "tree" (LAS 5). OFF by default (Miro, AI3D-373): whatever the tree
50 # stage accepts IS a tree -- a 3 m flat-topped band of greenery is high
51 # vegetation to the annotators, and the ground is often cut off so the
52 # trunks that would tell a tree from a hedge are not in the cloud. The
53 # rule stays available for datasets where hedges must go to LAS 4.
54 #
55 # This is the ONLY hedge knob under "tree_detection": it is on/off and
56 # nothing else. Every threshold the rule reads lives in the tree_instance
57 # slice, because the rule itself belongs to the instance splitter and the
58 # two callers must not be able to drift apart -- see _model_treeinstance:
59 # ``ti_hedge_*`` (ground gap, height, length, area, continuity, top relief,
60 # stems per 10 m, stem score bar), ``ti_min_cluster_points`` (the point
61 # floor below which the verdict abstains as "too_few_points"), and the stem
62 # band ``ti_stem_band_*`` / ``ti_stem_exg_bonus`` that produce the seeds the
63 # stemless conjunct counts. JSON: {"tree_instance": {"hedge_max_height_m":
64 # ...}}, not {"tree_detection": {...}}.
65 tree_hedge_split_enabled: bool = section_field("tree_detection.hedge_split_enabled", False)
66
67 # TCS (tablecloth) ground filtering, Option C (AI3D-339). When enabled the
68 # p8 DEM is built from TCS-ground-classified points only, so height-above-
69 # ground stops being biased upward by parked vehicles and low canopy. This
70 # repoints the DEM INPUT ONLY -- the candidate accumulation keeps reading
71 # the original run3 files, because TCS drops vegetation as non-ground and
72 # feeding cleaned clouds to the candidate path would erase every tree.
73 # Profile is FORKED from tablecloth's defaults, which are tuned lip-first
74 # for pavement-edge retention (max_window 3.0 m lets vehicles survive into
75 # the surface); these are the wider road-corridor values.
76 tcs_ground_enabled: bool = section_field("tcs_ground.enabled", False)
77 tcs_mechanism: TcsMechanism = section_field("tcs_ground.mechanism", "smrf_numpy")
78 tcs_cell_m: float = section_field("tcs_ground.cell_m", 0.20, gt=0.0)
79 tcs_slope_threshold: float = section_field("tcs_ground.slope_threshold", 0.30)
80 tcs_max_elev_diff_m: float = section_field("tcs_ground.max_elev_diff_m", 0.15)
81 tcs_smrf_max_window_m: float = section_field("tcs_ground.smrf_max_window_m", 6.0)
82 tcs_elev_scalar: float = section_field("tcs_ground.elev_scalar", 0.0)
83 tcs_pit_fill_enabled: bool = section_field("tcs_ground.pit_fill_enabled", True)
84 # Where the ground-only *_run3_ground_points.npz intermediates are written.
85 # Empty means "beside the output segment dir". Point this at local ext4 --
86 # the 9p /mnt/d share is far too slow for rewriting whole clouds.
87 tcs_cache_dir: str = section_field("tcs_ground.cache_dir", "")
0
Importance #62: src/iolabs_point_cloud_detection_verticalsigns/_model_treeinstance.py @@ -0,0 +1,345 @@
1"""Per-point tree instance splitting of merged canopy blobs.
2
3One slice of the flat ``DetectorConfig``. Every field declares, via
4``section_field``, the ``verticalsigns.default.json`` section and key it is
5loaded from; ``_config`` recombines the slices into the model.
6"""
7
8from iolabs.common import config_loader
9
10from ._model_base import section_field
11
12
13class VerticalSignsTreeInstanceFields(config_loader.ConfigModel):
14 """Stem-seeded instance splitting of a single ``type: "tree"`` detection.
15
16 Metres unless stated otherwise.
17 """
18
19 # Master flag for future in-run wiring (detect.py emitting per-instance
20 # ids). The offline splitter script drives tree_instances.py directly and
21 # ignores this, exactly as tree_detection_enabled gates only the in-run
22 # vegetation path.
23 tree_instance_enabled: bool = section_field("tree_instance.enabled", False)
24
25 # Local ground. One z_ground per detection is fine for a 5 m crown and
26 # wrong for a 40 m blob on an embankment: a 10% slope moves true ground by
27 # 3 m over 30 m, which alone pushes the far end's trunks entirely out of
28 # the stem band. Above the footprint threshold the ground is re-estimated
29 # per XY cell as a low percentile of z, then replaced by the MINIMUM of
30 # that percentile over a window_m neighbourhood. The minimum is what makes
31 # it robust: a cell under a dense crown has no ground return and a cell
32 # holding a trunk has that trunk mixed into its percentile, so cell errors
33 # are one-signed (always too high) and the neighbourhood's best-observed
34 # ground is the right pick. window_m trades a constant downhill bias on a
35 # slope (harmless - the crown base is measured on the same normalized
36 # heights) against reaching a real ground cell from under a crown.
37 # Cell size is deliberately coarse: 2 m cells keep enough returns per cell
38 # for a percentile to mean anything on 200-500 pt/m2 MLS.
39 ti_local_ground_footprint_m: float = section_field(
40 "tree_instance.local_ground_footprint_m", 15.0
41 )
42 ti_local_ground_cell_m: float = section_field("tree_instance.local_ground_cell_m", 2.0, gt=0.0)
43 ti_local_ground_percentile: float = section_field(
44 "tree_instance.local_ground_percentile", 5.0, ge=0.0, le=100.0
45 )
46 ti_local_ground_window_m: float = section_field("tree_instance.local_ground_window_m", 6.0)
47
48 # Crown base and the stem band. The treeX/Point2Tree literature slices a
49 # FIXED 1-4 m trunk band, which is calibrated on forest inventory plots.
50 # Roadside trees in this corpus are 3-7 m tall with crown base near 2 m, so
51 # a fixed band is ~50% foliage and the stem cluster drowns in leaves. The
52 # band top is therefore the estimated crown base: the lowest height above
53 # which the 0.25 m density profile stays at density_frac of its peak for
54 # run_bins consecutive bins (a persistent ramp, not a single noisy bin).
55 # crown_base_min_m keeps a sparse-trunk tree from collapsing the band to
56 # nothing; band_cap_m keeps a tall tree's band inside the literature range
57 # where a stem is still straight. A band thinner than min_thickness_m
58 # cannot support a vertical-reach test, so the cluster gets no seeds at all
59 # rather than seeds fitted to 20 cm of trunk.
60 ti_crown_base_bin_m: float = section_field("tree_instance.crown_base_bin_m", 0.25)
61 ti_crown_base_density_frac: float = section_field("tree_instance.crown_base_density_frac", 0.35)
62 ti_crown_base_run_bins: int = section_field("tree_instance.crown_base_run_bins", 3)
63 ti_crown_base_min_m: float = section_field("tree_instance.crown_base_min_m", 1.2)
64 ti_stem_band_low_m: float = section_field("tree_instance.stem_band_low_m", 0.5)
65 ti_stem_band_cap_m: float = section_field("tree_instance.stem_band_cap_m", 4.0)
66 ti_stem_band_min_thickness_m: float = section_field(
67 "tree_instance.stem_band_min_thickness_m", 0.7
68 )
69
70 # Stem seeds: 2D DBSCAN on the band's XY, then a four-cue evidence score.
71 # eps_m is a trunk-scale neighbourhood (0.35 m spans a 0.7 m trunk, wider
72 # than anything in this corpus) so two stems 3 m apart never chain.
73 # max_diameter_m is the hard foliage gate: a band blob whose horizontal RMS
74 # radius exceeds half of it is a bush or a hedge cross-section, not a stem,
75 # and no amount of verticality may rescue it. min_vertical_reach is the
76 # fraction of the band a seed must span - a stem is a column through the
77 # whole band, low scrub only touches its bottom. exg_bonus is the only
78 # colour term: bark is measurably less green than the crown around it, but
79 # RGB is not universal in this corpus (several datasets carry intensity
80 # only), so colour may add at most this much and never gates.
81 ti_stem_eps_m: float = section_field("tree_instance.stem_eps_m", 0.35)
82 ti_stem_min_samples: int = section_field("tree_instance.stem_min_samples", 20)
83 ti_stem_max_diameter_m: float = section_field("tree_instance.stem_max_diameter_m", 1.2)
84 ti_stem_min_vertical_reach: float = section_field("tree_instance.stem_min_vertical_reach", 0.5)
85 ti_stem_min_verticality: float = section_field("tree_instance.stem_min_verticality", 0.6)
86 ti_stem_min_score: float = section_field("tree_instance.stem_min_score", 0.45)
87 ti_stem_exg_bonus: float = section_field("tree_instance.stem_exg_bonus", 0.1)
88 # Two stems closer than merge_dist_m are one stem that DBSCAN split (a
89 # forked trunk, or a stem seen from two scan passes) and are merged.
90 # Survivors closer than uncertain_dist_m are kept as separate instances but
91 # demote the owning cluster to 'uncertain': at that spacing the geometry
92 # cannot say whether it is one multi-stem tree or two, and the caller must
93 # be told rather than shown a confident two-way split.
94 # v3 run evidence: DBSCAN pile-ups put 3+ "stems" inside ~1 m on sparse
95 # scatter, so the merge radius is wider than the classic 0.8 m occlusion
96 # split. Pairs surviving the merge but closer than uncertain_dist_m demote
97 # the cluster verdict instead โ€” one multi-stem tree and two touching trees
98 # are the same picture at that spacing.
99 ti_stem_merge_dist_m: float = section_field("tree_instance.stem_merge_dist_m", 1.2)
100 ti_stem_uncertain_dist_m: float = section_field("tree_instance.stem_uncertain_dist_m", 2.0)
101
102 # Crown-apex fallback seeding. Stem seeding assumes a clean trunk band,
103 # which is a forest-plot assumption: on A1 roadside MLS 43% of clusters
104 # yielded ZERO seeds because the vegetation is bushy to the ground and
105 # every band blob fails the stem diameter gate. The fallback rasterizes the
106 # top surface (max height per cell_m cell), fills single-cell holes,
107 # smooths it with a normalized gaussian (sigma in metres) and takes the
108 # local maxima as seeds. min_separation_m is both the maxima window and the
109 # distance inside which an apex is considered the same tree as an already
110 # accepted stem (and dropped) - roughly the smallest crown worth splitting
111 # off. min_prominence_m is the rise above the lowest cell in that window: a
112 # bump smaller than this is crown texture, not a second tree. min_height_m
113 # keeps the pass off knee-high scrub. trigger_span_m is when the fallback
114 # runs at all: no stem seeds, or fewer than one stem per this much major
115 # axis, because a single trunk cannot own 20 m of continuous canopy.
116 # seed_radius_m collects the source points around an apex in (x, y, height)
117 # space, which lets the existing voxel-graph dijkstra grow apex seeds
118 # unchanged. confidence_scale is the standing discount on an apex-seeded
119 # instance: the apex is where the canopy is highest, which is where a tree
120 # usually is - but a wide crown can carry two.
121 ti_apex_fallback_enabled: bool = section_field("tree_instance.apex_fallback_enabled", True)
122 ti_apex_cell_m: float = section_field("tree_instance.apex_cell_m", 0.5, gt=0.0)
123 ti_apex_smooth_sigma_m: float = section_field("tree_instance.apex_smooth_sigma_m", 0.7)
124 ti_apex_min_separation_m: float = section_field("tree_instance.apex_min_separation_m", 2.5)
125 ti_apex_min_prominence_m: float = section_field("tree_instance.apex_min_prominence_m", 0.8)
126 ti_apex_min_height_m: float = section_field("tree_instance.apex_min_height_m", 2.0)
127 ti_apex_trigger_span_m: float = section_field("tree_instance.apex_trigger_span_m", 8.0)
128 ti_apex_seed_radius_m: float = section_field("tree_instance.apex_seed_radius_m", 0.6)
129 ti_apex_confidence_scale: float = section_field("tree_instance.apex_confidence_scale", 0.6)
130 # Seed damper. The v2 run painted 3-7 instances onto 900-3,000 point sparse
131 # scatters (three seeds inside 2 m on a 1,200 point blob), because both
132 # seeders answer "where is the local evidence" and neither asks whether the
133 # cluster holds enough returns to BE that many trees. A fully scanned
134 # roadside tree in this corpus is thousands of points, so the number of
135 # kept seeds (stem and apex together, best score first) is capped at
136 # n_points / min_points_per_instance - at least one, so a small tree is
137 # never damped away. Together with the min_instance_points floor this
138 # collapses sparse scatter to 0-1 instances instead of a micro-thicket.
139 ti_min_points_per_instance: int = section_field("tree_instance.min_points_per_instance", 1200)
140
141 # Seedless single. A compact, ground-connected, tree-height cluster that
142 # yielded no seed from EITHER mechanism is emitted as one instance covering
143 # all of it instead of abstaining: the detector already asserted "tree",
144 # and an isolated crown with no recoverable stem is far more often one
145 # small tree than a mistake. Deliberately low confidence - the reasoning is
146 # thin and the caller must be able to see that. The footprint bound is what
147 # keeps it honest: above it the cluster certainly holds several trees and
148 # the old 'partial' abstention is still the right answer.
149 # The 0.35-confidence single fired on junk in the v2 run (wire scraps,
150 # facade slivers), so three cheap shape conjuncts were added: p95 height
151 # (not p99, which one stray return can carry), plan aspect - a tree crown
152 # is not a 4:1 sliver - and a point floor, since a genuine crown scanned by
153 # MLS is never a few hundred returns. Failing any of them the cluster is
154 # 'uncertain' again, which is an abstention and not a deletion.
155 ti_seedless_single_max_footprint_m: float = section_field(
156 "tree_instance.seedless_single_max_footprint_m", 10.0
157 )
158 ti_seedless_single_min_height_m: float = section_field(
159 "tree_instance.seedless_single_min_height_m", 1.5
160 )
161 ti_seedless_single_max_height_m: float = section_field(
162 "tree_instance.seedless_single_max_height_m", 25.0
163 )
164 ti_seedless_single_confidence: float = section_field(
165 "tree_instance.seedless_single_confidence", 0.35
166 )
167 ti_seedless_min_p95_h_m: float = section_field("tree_instance.seedless_min_p95_h_m", 2.0)
168 ti_seedless_max_aspect: float = section_field("tree_instance.seedless_max_aspect", 2.5)
169 ti_seedless_min_points: int = section_field("tree_instance.seedless_min_points", 800)
170
171 # Junk guards, all evaluated BEFORE seeding. float_fragment_min_h_m: a tree
172 # is attached to the ground it grows out of, so its 5th height percentile
173 # is near zero even when the trunk was never scanned; a catenary wire, a
174 # mast head or a facade scrap has nothing below 3 m and is 'non_tree'.
175 # min_tree_footprint_m: below it the cluster is a pole cross-section with
176 # nothing to split - 'uncertain', never 'non_tree', because the module may
177 # not delete anything on size. max_tree_footprint_m / megacluster_points
178 # mark detector mask leakage (the first run produced a 5.4M-point,
179 # 63 x 82 m blob holding a road and a roof); such a cluster never gets the
180 # apex fallback and is never reported better than 'partial'. The planar
181 # test is the one that can refuse it outright: the fraction of points in
182 # planar_cell_m cells whose height spread is under planar_max_spread_m.
183 # Vegetation cannot be flat at metre scale, so a fraction above
184 # planar_fraction_min is a roof or a road; it is asked only of footprints
185 # above planar_min_footprint_m, where a flat patch cannot be a crown.
186 # float_fragment_p25_h_m is the same guard read on the MASS rather than on
187 # the tail: a facade arc or a wire bundle with a handful of low returns
188 # under it passes the p5 test and is still not a tree, because a quarter of
189 # a tree's returns are never above 4 m of its own crown base. Kept separate
190 # from float_fragment_min_h_m so the two can be tuned apart.
191 ti_float_fragment_min_h_m: float = section_field("tree_instance.float_fragment_min_h_m", 3.0)
192 ti_float_fragment_p25_h_m: float = section_field("tree_instance.float_fragment_p25_h_m", 4.0)
193 ti_min_tree_footprint_m: float = section_field("tree_instance.min_tree_footprint_m", 1.5)
194 # 60, not 45: the v3 run showed 45 catching a genuine 47 m merged
195 # vegetation complex (segment_014) and suppressing its apex fallback, while
196 # every true leak seen so far is either far larger (63 x 82 m) or dies on
197 # the planarity / megacluster-points guards anyway.
198 ti_max_tree_footprint_m: float = section_field("tree_instance.max_tree_footprint_m", 60.0)
199 ti_megacluster_points: int = section_field("tree_instance.megacluster_points", 1_000_000)
200 ti_planar_min_footprint_m: float = section_field("tree_instance.planar_min_footprint_m", 12.0)
201 ti_planar_cell_m: float = section_field("tree_instance.planar_cell_m", 1.0, gt=0.0)
202 ti_planar_max_spread_m: float = section_field("tree_instance.planar_max_spread_m", 0.3)
203 ti_planar_fraction_min: float = section_field("tree_instance.planar_fraction_min", 0.55)
204
205 # Hedge verdict: ONE rule, the wide continuous band. A hedge row is a
206 # FIRST-CLASS output class, not a failure, and splitting it into "trees"
207 # every few metres is the most expensive mistake this module can make.
208 # The v1/v2 pair of aspect-driven rules got this exactly backwards on real
209 # data - they fired ONCE over 24 segments, on a 2.2 x 0.8 m fragment 14 m
210 # up, while textbook bands (25.8 x 17.6 m at 4.2 m tall, 41.7 x 26.1 m)
211 # were sliced into straight-cut fake tree slabs. Aspect was the culprit:
212 # a real clipped band is as often stubby as it is thin, so it is gone as a
213 # criterion. What is left is what a hedge actually is, all conjunctive:
214 # grounded p5 of height below max_ground_gap_m - foliage runs
215 # down to the ground, unlike a facade or wire scrap;
216 # low p99 height at most max_height_m;
217 # long major axis at least min_length_m;
218 # substantial occupied plan area at least min_area_m2, so a thin
219 # sliver cannot qualify on length alone;
220 # continuous at least min_continuity of the continuity_bin_m bins
221 # along the major axis hold points (two crowns 18 m
222 # apart have a band's extent and none of its substance);
223 # FLAT-TOPPED p90 - p10 of the smoothed crown-surface cell heights
224 # is at most max_top_relief_m. This is the conjunct
225 # that replaces aspect and separates a clipped band
226 # from a row of distinct crowns, whose tops undulate by
227 # metres between crown and gap;
228 # stemless fewer than max_seed_per_10m stem seeds per 10 m of
229 # length - a planted avenue has trunks along it and is
230 # never a hedge, however neatly it is clipped.
231 # The stemless conjunct counts only stems scoring at least
232 # stem_score_min: the v3 run showed sparse foliage shattering into weak
233 # "trunklets" (3 low-score seeds on a 26 m clipped band) that defeated
234 # the rule and got the band sliced anyway. A real avenue trunk scores
235 # well above this; band-noise blobs do not.
236 # max_height_m is 7.5, not 5.0: A1 carries uncut continuous vegetation
237 # walls up to ~7 m (segment_070) that are bands in every other conjunct;
238 # the flat-top relief test is what keeps genuine tree rows out.
239 ti_hedge_max_ground_gap_m: float = section_field("tree_instance.hedge_max_ground_gap_m", 2.0)
240 ti_hedge_max_height_m: float = section_field("tree_instance.hedge_max_height_m", 7.5)
241 ti_hedge_min_length_m: float = section_field("tree_instance.hedge_min_length_m", 8.0)
242 ti_hedge_min_area_m2: float = section_field("tree_instance.hedge_min_area_m2", 20.0)
243 ti_hedge_min_continuity: float = section_field("tree_instance.hedge_min_continuity", 0.75)
244 ti_hedge_continuity_bin_m: float = section_field("tree_instance.hedge_continuity_bin_m", 1.0)
245 ti_hedge_max_top_relief_m: float = section_field("tree_instance.hedge_max_top_relief_m", 1.5)
246 ti_hedge_max_seed_per_10m: float = section_field("tree_instance.hedge_max_seed_per_10m", 1.0)
247 ti_hedge_stem_score_min: float = section_field("tree_instance.hedge_stem_score_min", 0.6)
248
249 # Crown assignment. Points are voxelized and each voxel is given to the
250 # graph-nearest seed, so a crown is grown through its own occupied space
251 # instead of by straight-line distance: a low branch reaching across a
252 # neighbour's trunk stays with the tree it hangs from. max_gap_m is how far
253 # the graph may jump across empty space between voxel centroids - large
254 # enough to close occlusion shadows in a single crown, small enough that
255 # two crowns separated by a real gap stay separate components, and anything
256 # left disconnected abstains rather than being handed to the nearest seed.
257 # max_graph_dist_m bounds the PATH LENGTH of one instance; beyond it a
258 # voxel is unreachable even along a connected path. It is deliberately
259 # generous, because the path from a stem seed at the ground up through a
260 # 13 m crown is 13 m of graph before the crown even starts to spread.
261 # max_claim_radius_m is the crown-radius bound and is HORIZONTAL: the plan
262 # distance from a voxel to its owning seed. That distinction is the whole
263 # rule. Capping the GRAPH distance at 9 m (v2) sent every tall crown to
264 # ABSTAIN_UNREACHABLE - a canopy 8-13 m up is more than 9 m of path from a
265 # seed on the ground, so only the understory fringe was ever assigned and
266 # point-weighted abstention regressed. Capping the horizontal distance
267 # instead still kills what the cap was FOR (a seed walking 20-30 m of
268 # connected roadside band laterally and calling the chain one tree: those
269 # chains are horizontal) while a tall tree stays fully reachable, because
270 # no crown is nine metres wide about its own trunk.
271 # max_gap_m 1.25, not 0.6: v3's renders still showed dense canopy tops gray
272 # ABOVE their own assigned understory โ€” one-sided MLS leaves the mid-story
273 # so sparse that 0.6 m cannot bridge it, so the crown top was disconnected
274 # from its trunk. Lateral crown-to-crown bridging this may add is bounded
275 # by the horizontal claim radius below.
276 ti_assign_voxel_m: float = section_field("tree_instance.assign_voxel_m", 0.3)
277 ti_assign_max_gap_m: float = section_field("tree_instance.assign_max_gap_m", 1.25)
278 ti_assign_max_graph_dist_m: float = section_field("tree_instance.assign_max_graph_dist_m", 30.0)
279 ti_max_claim_radius_m: float = section_field("tree_instance.max_claim_radius_m", 9.0)
280 # Ambiguity between the best two seeds, as a normalized distance margin.
281 # Below the floor the point is still assigned (dropping it would punch a
282 # hole through the middle of every merged canopy) but it drags the owning
283 # instance's confidence down. low_evidence_abstain turns the same band into
284 # a hard abstention for callers who would rather lose the seam than
285 # mislabel it.
286 ti_low_evidence_margin: float = section_field("tree_instance.low_evidence_margin", 0.05)
287 ti_low_evidence_abstain: bool = section_field("tree_instance.low_evidence_abstain", False)
288
289 # Verdict thresholds. min_cluster_points is a floor on stem detection, NOT
290 # a tree-vs-not test: below it the band holds too few returns for DBSCAN to
291 # form any cluster, so the splitter abstains and leaves the detection whole.
292 # Small conifers must survive this - they are reported 'uncertain', never
293 # dropped. single_tree_footprint_m separates "one tree whose stem is
294 # occluded" (abstain, 'uncertain') from "a big canopy that clearly holds
295 # several trees but yields no stem" (abstain, 'partial').
296 ti_min_cluster_points: int = section_field("tree_instance.min_cluster_points", 150)
297 ti_single_tree_footprint_m: float = section_field("tree_instance.single_tree_footprint_m", 8.0)
298 ti_partial_abstain_fraction: float = section_field(
299 "tree_instance.partial_abstain_fraction", 0.2
300 )
301 # Instance sanity floor. An instance owning a few dozen points is a branch
302 # tip, not a tree, and the first run asserted several of those. Both forms
303 # are needed: the absolute one catches micro-instances everywhere, the
304 # relative one catches a 300-point splinter off a 200k-point blob. The
305 # absolute floor is internally capped at half the cluster so it can never
306 # erase a genuinely small detection. Dropped points abstain under
307 # ABSTAIN_LOW_EVIDENCE.
308 ti_min_instance_points: int = section_field("tree_instance.min_instance_points", 120)
309 ti_min_instance_fraction: float = section_field("tree_instance.min_instance_fraction", 0.01)
310 # Instance SHAPE floor, applied to the grown instance rather than to its
311 # seed. Wires, poles, facade arcs and planar scan stripes survive every
312 # cluster-level guard when they arrive mixed into a vegetation cluster, and
313 # v2 painted them as trees: straight horizontal wire lines, a pole column,
314 # a scan stripe. All three are recognisable from the instance's own points.
315 # max_linearity is the share of variance on the first principal axis of the
316 # instance in (x, y, height): a wire or a pole is a 1D object and sits
317 # above 0.92, a crown of any species is nowhere near it. min_minor_m is the
318 # minor plan extent - a crown is a blob, not a ribbon - and
319 # min_vertical_m rejects a flat sheet with no vertical structure. Dropped
320 # instances give their points back as ABSTAIN_LOW_EVIDENCE.
321 # min_thickness_share is the complementary 2D refusal: a planar sheet (road
322 # scan stripes on a slope, a facade panel) is not 1D, so it passes the
323 # linearity test โ€” but its SMALLEST principal axis carries almost no
324 # variance. A crown is thick in all three axes; a sheet is not.
325 ti_instance_max_linearity: float = section_field("tree_instance.instance_max_linearity", 0.92)
326 ti_instance_min_minor_m: float = section_field("tree_instance.instance_min_minor_m", 1.0)
327 ti_instance_min_vertical_m: float = section_field("tree_instance.instance_min_vertical_m", 1.5)
328 ti_instance_min_thickness_share: float = section_field(
329 "tree_instance.instance_min_thickness_share", 0.02
330 )
331 # Instance confidence is seed evidence blended with how unambiguous its
332 # points were (seed_weight is the seed's share), then scaled by size -
333 # min(1, n / size_ref_points) ** 0.3, so a few hundred points cannot look
334 # like a fully observed tree - and by provenance. confidence_max applies to
335 # everything and is below 1.0 on purpose: a geometric splitter with no
336 # ground truth is never certain, and the first run emitting 1.00 on wire
337 # fragments is exactly how a downstream consumer learns to distrust the
338 # number. fallback_max is the tighter cap on apex-seeded and seedless
339 # instances.
340 ti_confidence_seed_weight: float = section_field("tree_instance.confidence_seed_weight", 0.6)
341 ti_confidence_size_ref_points: float = section_field(
342 "tree_instance.confidence_size_ref_points", 2000.0
343 )
344 ti_confidence_max: float = section_field("tree_instance.confidence_max", 0.95)
345 ti_confidence_fallback_max: float = section_field("tree_instance.confidence_fallback_max", 0.9)
0
Importance #63: src/iolabs_point_cloud_detection_verticalsigns/_model_vegetation.py @@ -0,0 +1,150 @@
1"""Tree rejection, chromaticity vegetation reject and radius fitting.
2
3Also core compactness and the crown-circle overlay knobs.
4
5One slice of the flat ``DetectorConfig``. Every field declares, via
6``section_field``, the ``verticalsigns.default.json`` section and key it is
7loaded from; ``_config`` recombines the slices into the model.
8"""
9
10from iolabs.common import config_loader
11
12from ._model_base import section_field
13
14
15class VerticalSignsVegetationFields(config_loader.ConfigModel):
16 """Tree rejection, chromaticity vegetation reject and radius fitting.
17
18 Also core compactness and the crown-circle overlay knobs.
19
20 Metres unless stated otherwise.
21 """
22
23 # Tree rejection
24 tree_crown_h_min_m: float = section_field("tree.crown_h_min_m", 2.0)
25 tree_crown_max_area_m2: float = section_field("tree.crown_max_area_m2", 4.0)
26 tree_isotropy_ratio: float = section_field("tree.isotropy_ratio", 0.75)
27 tree_greenness_hint: float = section_field("tree.greenness_hint", 0.45)
28
29 # Chromaticity vegetation reject (experimental, opt-in per dataset).
30 #
31 # A SEPARATE lever from tree_greenness_hint above. That one thresholds the
32 # legacy `greenness`, which is normalized by a SEGMENT-WIDE RGB max, so one
33 # retroreflective sign in the segment deflates every other cluster's value.
34 # These thresholds read `greenness_exg`, a per-point chromaticity that has
35 # no cross-cluster coupling and lives in a completely different numeric
36 # range (foliage ~0.05-0.4, not ~0.45). Never copy a value between the two.
37 #
38 # Off by default. RGB is not universal in this corpus: several datasets
39 # carry intensity only, or write a constant RGB sentinel. On any of those,
40 # ExG is identically 0 (see features.excess_green_chromaticity), and
41 # classify._chroma_vegetation additionally requires greenness_exg > 0, so it
42 # is a structural no-op there regardless of how these are tuned.
43 #
44 # Thresholds fitted on the A1 corpus (139 segments, 37,627 clusters โ€” see
45 # docs/research/greenness-exg-phase6.md) against three measured populations:
46 # tree crowns (n=7), accepted man-made detections (n=28), and tree trunks
47 # (n=37). Chosen so COLOUR ALONE separates greenery from both of the others,
48 # with the geometric cue as an independent second barrier rather than as the
49 # thing carrying the whole decision.
50 #
51 # feature man-made trunks crowns threshold
52 # greenness_exg max 0.1667 max 0.0455 min 0.0909 0.155 (*)
53 # greenness_exg_iqr max 0.2945 max 0.1530 min 0.1917 0.210 (*)
54 # plate_thickness_m max 0.130 max 0.094 min 0.236 0.175
55 #
56 # (*) READ THESE TWO ROWS CAREFULLY: the threshold does NOT sit in a gap.
57 # Man-made reach 0.1667 on ExG and 0.2945 on IQR, i.e. ABOVE both gates.
58 # Neither colour cue separates the populations on its own. What excludes
59 # every man-made and trunk cluster is that no single one is high on BOTH
60 # axes โ€” the max-ExG row and the max-IQR row are different clusters. So the
61 # conjunction is load-bearing, and neither gate may be relaxed on the
62 # strength of the other. Only plate_thickness_m has a true single-axis gap.
63 #
64 # Result: 5/7 crowns selected, 0/28 man-made, 0/37 trunks. The two crowns
65 # dropped (ExG 0.091 and 0.119) are the least green; A1 is an October
66 # capture, so senescent crowns are the expected loss.
67 #
68 # min_change_of_curvature is deliberately INERT at 0.20. That cue turned out
69 # to be anti-discriminative: man-made clusters reach 0.0815 and trunks 0.1743,
70 # both ABOVE the crown p25 of 0.0273, so an OR-branch on curvature admits
71 # exactly what the rule is meant to exclude. It is kept (rather than deleted)
72 # so a genuinely isotropic clump could still qualify, and so the key stays
73 # configurable.
74 #
75 # "Inert" is scoped, not absolute: corpus-wide 2,584 of 37,627 clusters do
76 # clear 0.20 (max 0.3141), but every one is already rejected by geometry.
77 # Among ACCEPTED man-made the max is 0.0815, and among the 18 clusters this
78 # veto may act on it is 0.0106 โ€” ~19x under the gate. The branch cannot fire
79 # on anything the rule can reach, which is the property that matters.
80 #
81 # Earlier drafts got two of these badly wrong in opposite directions:
82 # exg_iqr_min=0.06 sat BELOW the dark man-made IQR median (0.120), where
83 # 8-bit ExG quantization noise alone clears it; and
84 # min_change_of_curvature=0.12 was picked from the feature's [0, 1/3] range
85 # when real crowns only reach 0.051.
86 #
87 # max_hi_intensity_fraction stays anchored to config rather than data: it
88 # matches delineator_min_hi_intensity_fraction, the weakest brightness at
89 # which anything here may claim to be a man-made reflector.
90 chroma_veg_enabled: bool = section_field("chroma_vegetation.enabled", False)
91 chroma_veg_exg_min: float = section_field("chroma_vegetation.exg_min", 0.155)
92 chroma_veg_exg_iqr_min: float = section_field("chroma_vegetation.exg_iqr_min", 0.210)
93 chroma_veg_max_hi_intensity_fraction: float = section_field(
94 "chroma_vegetation.max_hi_intensity_fraction", 0.08
95 )
96 chroma_veg_min_change_of_curvature: float = section_field(
97 "chroma_vegetation.min_change_of_curvature", 0.20
98 )
99 chroma_veg_min_plate_thickness_m: float = section_field(
100 "chroma_vegetation.min_plate_thickness_m", 0.175
101 )
102
103 # Core compactness: per-height-bin XY RMS radius over the near-ground core.
104 core_rms_bin_m: float = section_field("classification.core_rms_bin_m", 0.25)
105 core_rms_h_min_m: float = section_field("classification.core_rms_h_min_m", 0.30)
106 core_rms_h_cap_m: float = section_field("classification.core_rms_h_cap_m", 3.0)
107
108 # Circle-fit radius estimation (radius.py). Per-height-bin Taubin circle fits
109 # replace the RMS-from-centroid for the *emitted* radii (pole radius_m, tree
110 # trunk_radius_m). A bin is accepted only when its points lie tight on a
111 # well-covered arc, so a bush with no coherent trunk yields radius 0.0. The
112 # ClusterFeatures RMS values are untouched (the .joblib classifiers use them).
113 radius_fit_bin_m: float = section_field("radius.fit_bin_m", 0.25)
114 radius_fit_min_bin_points: int = section_field("radius.fit_min_bin_points", 8)
115 radius_fit_min_arc_deg: float = section_field("radius.fit_min_arc_deg", 60.0)
116 radius_fit_residual_frac: float = section_field("radius.fit_residual_frac", 0.35)
117 radius_fit_residual_abs_m: float = section_field("radius.fit_residual_abs_m", 0.03)
118 radius_fit_divergence_factor: float = section_field("radius.fit_divergence_factor", 4.0)
119 # r_max caps: a roadside pole/post is < 0.5 m radius, a tree trunk < 0.8 m.
120 pole_radius_max_m: float = section_field("radius.pole_radius_max_m", 0.5)
121 trunk_radius_max_m: float = section_field("radius.trunk_radius_max_m", 0.8)
122 # Crown circle = trimmed minimum-enclosing circle of the lobe: the radially
123 # farthest (100 - this)% of points are dropped before enclosing the rest.
124 crown_radius_percentile: float = section_field(
125 "radius.crown_radius_percentile", 95.0, ge=0.0, le=100.0
126 )
127 # Multi-lobe crown overlay: the coarse tree DBSCAN can merge several
128 # neighbouring bushes/trees into one detection whose canopy points form
129 # disjoint blobs around an empty centre. The crown points are re-clustered
130 # with a density-based DBSCAN (neighbourhood crown_lobe_gap_m, core count
131 # crown_lobe_min_samples) so the low-density valley between two canopies
132 # breaks the chain. Lobe selection is coverage-driven: every lobe with >=
133 # crown_lobe_min_points (an absolute floor) is eligible, and lobes are
134 # accepted largest-first until the accepted union covers
135 # crown_lobe_coverage_target of the clustered crown points or the
136 # crown_lobe_max_count satellite cap is hit โ€” so most detached blobs get a
137 # circle while tiny fragments/noise do not. Selection stops at the coverage
138 # target, so a sub-(1 - coverage_target) detached lobe can stay uncircled. A
139 # clean single-canopy tree yields one lobe.
140 crown_lobe_gap_m: float = section_field("radius.crown_lobe_gap_m", 0.5)
141 crown_lobe_min_samples: int = section_field("radius.crown_lobe_min_samples", 10)
142 crown_lobe_min_points: int = section_field("radius.crown_lobe_min_points", 30)
143 crown_lobe_coverage_target: float = section_field("radius.crown_lobe_coverage_target", 0.95)
144 crown_lobe_max_count: int = section_field("radius.crown_lobe_max_count", 8)
145 # Opt-in diagnostics sidecar: when true, detect writes cluster_points.npz
146 # (float64 copies of every detection's fitted points, MBs per segment) so
147 # scripts/radius_diagnostics.py can re-fit the estimator's exact points.
148 # Off on production runs; the diagnostics tool falls back to a neighbourhood
149 # gather when the sidecar is absent.
150 radius_debug_cluster_points: bool = section_field("radius.debug_cluster_points", False)
0
Importance #64: src/iolabs_point_cloud_detection_verticalsigns/config.py @@ -1,135 +1,26 @@
1"""Detector configuration.1"""Public import path for the detector configuration.
22
3The 379-field :class:`DetectorConfig` and its ``from_mapping`` flattener are3The schema, the loading entry points and the error class live in `_config`;
4split by section across the ``_config_<section>`` modules; this module4this module re-exports them so the documented ``from
5recombines them and re-exports every piece, so ``from .config import X``5iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig`` keeps
6keeps working for every name that used to live here.6working. The field declarations themselves are split across the
77``_model_<topic>`` slices.
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``.
11"""8"""
129
13from pathlib import Path10from ._config import (
14from typing import Any11 DetectorConfig,
1512 VerticalSignsConfigError,
16from ._config import load_verticalsigns_config13 build_verticalsigns_config,
17from ._config_conic import ConicFields, conic_kwargs14 load_default_config,
18from ._config_corridor import CorridorFields, corridor_kwargs15 load_verticalsigns_config,
19from ._config_devices import DeviceFields, device_kwargs16 normalize_verticalsigns_config,
20from ._config_evidence import EvidenceFields, evidence_kwargs17)
21from ._config_grid import GridFields, grid_kwargs
22from ._config_perspective import PerspectiveFields, perspective_kwargs
23from ._config_roadcontext import RoadContextFields, road_context_kwargs
24from ._config_stages import StageFields, stage_kwargs
25from ._config_treedetect import TreeDetectionFields, tree_detection_kwargs
26from ._config_treeinstance import TreeInstanceFields, tree_instance_kwargs
27from ._config_vegetation import VegetationFields, vegetation_kwargs
2818
29__all__ = [19__all__ = [
30 "DetectorConfig",20 "DetectorConfig",
31 "GridFields",21 "VerticalSignsConfigError",
32 "DeviceFields",22 "build_verticalsigns_config",
33 "VegetationFields",23 "load_default_config",
34 "RoadContextFields",24 "load_verticalsigns_config",
35 "CorridorFields",25 "normalize_verticalsigns_config",
36 "EvidenceFields",
37 "StageFields",
38 "TreeDetectionFields",
39 "TreeInstanceFields",
40 "ConicFields",
41 "PerspectiveFields",
42 "grid_kwargs",
43 "device_kwargs",
44 "vegetation_kwargs",
45 "road_context_kwargs",
46 "corridor_kwargs",
47 "evidence_kwargs",
48 "stage_kwargs",
49 "tree_detection_kwargs",
50 "tree_instance_kwargs",
51 "conic_kwargs",
52 "perspective_kwargs",
53]26]
54
55
56class DetectorConfig( # noqa: D101 - docstring below, after the base list
57 # The bases are listed in REVERSE section order ON PURPOSE: both
58 # dataclasses and pydantic collect fields by walking the MRO backwards, so
59 # this ordering reproduces the original single-class field order exactly
60 # (ground first, then perspective, then the slices added since).
61 # Reordering these lines reorders the fields, so a NEW slice goes at the
62 # TOP of this list to have its fields appended at the end.
63 TreeInstanceFields,
64 PerspectiveFields,
65 ConicFields,
66 TreeDetectionFields,
67 StageFields,
68 EvidenceFields,
69 CorridorFields,
70 RoadContextFields,
71 VegetationFields,
72 DeviceFields,
73 GridFields,
74):
75 """Spatial and geometric thresholds, in metres unless stated otherwise."""
76
77 @classmethod
78 def from_mapping(cls, config: dict[str, Any]) -> "DetectorConfig":
79 """Builds a DetectorConfig by flattening the nested config sections.
80
81 Only keys present in a section override the corresponding model
82 default, so a partial (or default) config reproduces the built-in
83 thresholds exactly.
84
85 Args:
86 config: The nested config document (packaged defaults merged with
87 an optional user JSON).
88
89 Returns:
90 The flattened configuration.
91 """
92 defaults = cls()
93 return cls(
94 **grid_kwargs(config, defaults),
95 **device_kwargs(config, defaults),
96 **vegetation_kwargs(config, defaults),
97 **road_context_kwargs(config, defaults),
98 **corridor_kwargs(config, defaults),
99 **evidence_kwargs(config, defaults),
100 **stage_kwargs(config, defaults),
101 **tree_detection_kwargs(config, defaults),
102 **conic_kwargs(config, defaults),
103 **perspective_kwargs(config, defaults),
104 **tree_instance_kwargs(config, defaults),
105 )
106
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 a wrongly typed value would be
112 stored uncoerced. The names are checked here and the values are run
113 through the model, so this validates where ``dataclasses.replace``
114 merely type-checked the call.
115
116 Args:
117 overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``.
118
119 Returns:
120 A new frozen config carrying *overrides*.
121
122 Raises:
123 ValueError: An override names a field this config does not declare,
124 or carries a value the field rejects (a
125 ``pydantic.ValidationError``, itself a ``ValueError``).
126 """
127 unknown = sorted(set(overrides) - set(type(self).model_fields))
128 if unknown:
129 raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}")
130 return type(self).model_validate({**self.model_dump(), **overrides})
131
132 @classmethod
133 def load(cls, config_path: str | Path | None = None) -> "DetectorConfig":
134 """Load config from the packaged defaults merged with an optional user JSON."""
135 return cls.from_mapping(load_verticalsigns_config(config_path))
Importance #65: tests/conftest.py @@ -4,29 +4,37 @@
4from collections.abc import Callable4from collections.abc import Callable
5from typing import Any5from typing import Any
66
7import pytest7import pytest
8from iolabs.common import config_loader
98
9from iolabs_point_cloud_detection_verticalsigns import _model_base
10from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig
1011
11def _section_values(model: type[config_loader.ConfigModel]) -> dict[str, Any]:12
12 """Return one valid non-default value per field of *model*."""13def _section_values(section: str) -> dict[str, Any]:
14 """Return one valid non-default value per key of config section *section*."""
13 values: dict[str, Any] = {}15 values: dict[str, Any] = {}
14 for name, field in model.model_fields.items():16 for field in DetectorConfig.model_fields.values():
17 field_section, key = _model_base.section_path(field)
18 if field_section != section:
19 continue
15 annotation = field.annotation20 annotation = field.annotation
21 options = typing.get_args(annotation) if typing.get_origin(annotation) is None else ()
16 if annotation is bool:22 if annotation is bool:
17 values[name] = not field.default23 values[key] = not field.default
18 elif annotation is int:24 elif annotation is int:
19 values[name] = int(field.default) + 125 values[key] = int(field.default) + 1
20 elif annotation is str:26 elif annotation is str:
21 values[name] = f"{field.default}_x"27 values[key] = f"{field.default}_x"
22 elif typing.get_origin(annotation) is tuple:28 elif typing.get_origin(annotation) is tuple:
23 values[name] = [f"{item}_x" for item in field.default]29 values[key] = [f"{item}_x" for item in field.default]
30 elif options and all(isinstance(option, str) for option in options):
31 values[key] = next(o for o in options if o != field.default)
24 else:32 else:
25 values[name] = 0.533 values[key] = 0.5
26 return values34 return values
2735
2836
29@pytest.fixture37@pytest.fixture
30def section_values() -> Callable[[type[config_loader.ConfigModel]], dict[str, Any]]:38def section_values() -> Callable[[str], dict[str, Any]]:
31 """Return a builder for a full override of one config section."""39 """Return a builder for a full override of one config section."""
32 return _section_values40 return _section_values
Importance #66: tests/test_chroma_vegetation.py @@ -13,9 +13,9 @@
1313
14import numpy as np14import numpy as np
15import pytest15import pytest
1616
17from iolabs_point_cloud_detection_verticalsigns import _config, _model_tree17from iolabs_point_cloud_detection_verticalsigns import _config
18from iolabs_point_cloud_detection_verticalsigns.classify import (18from iolabs_point_cloud_detection_verticalsigns.classify import (
19 CHROMA_VETOABLE_TYPES,19 CHROMA_VETOABLE_TYPES,
20 apply_tree_emission,20 apply_tree_emission,
21 classify_cluster,21 classify_cluster,
Importance #67: tests/test_chroma_vegetation.py @@ -303,9 +303,9 @@
303303
304304
305def test_config_accepts_every_documented_key(tmp_path, section_values) -> None:305def test_config_accepts_every_documented_key(tmp_path, section_values) -> None:
306 """The other half: no modelled key is rejected."""306 """The other half: no modelled key is rejected."""
307 section = section_values(_model_tree.ChromaVegetationConfig)307 section = section_values("chroma_vegetation")
308 section["enabled"] = True308 section["enabled"] = True
309 path = tmp_path / "override.json"309 path = tmp_path / "override.json"
310 path.write_text(json.dumps({"chroma_vegetation": section}))310 path.write_text(json.dumps({"chroma_vegetation": section}))
311 assert _config.load_verticalsigns_config(path)["chroma_vegetation"]["enabled"]311 assert _config.load_verticalsigns_config(path)["chroma_vegetation"]["enabled"]
Importance #68: tests/test_config.py @@ -0,0 +1,195 @@
1"""Schema guards for the flat :class:`DetectorConfig` and the packaged JSON.
2
3The detector reads a FLAT config while the packaged
4``verticalsigns.default.json`` is grouped into sections, and each flat field
5declares the section and key it is loaded from (``_model_base.section_field``).
6Three things must stay true for that to be invisible to callers:
7
8* every field is reachable from the nested config document, and only from the
9 section/key it declares,
10* the model and the JSON declare exactly the same keys with the same defaults,
11* an absent key still falls back to the model default.
12"""
13
14import json
15from pathlib import Path
16
17import pydantic
18import pytest
19from iolabs.common import config_loader
20
21from iolabs_point_cloud_detection_verticalsigns import _config, _model_base
22from iolabs_point_cloud_detection_verticalsigns.config import (
23 DetectorConfig,
24 VerticalSignsConfigError,
25 build_verticalsigns_config,
26 load_default_config,
27 load_verticalsigns_config,
28 normalize_verticalsigns_config,
29)
30
31PACKAGED_JSON = (
32 Path(__file__).resolve().parents[1]
33 / "src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json"
34)
35
36
37def _packaged() -> dict:
38 return json.loads(PACKAGED_JSON.read_text(encoding="utf-8"))
39
40
41def _bounds(field: pydantic.fields.FieldInfo) -> tuple[float, float]:
42 """Return the ``(low, high)`` a field accepts, as declared by its constraints."""
43 low, high = -1e9, 1e9
44 for constraint in field.metadata:
45 low = max(low, getattr(constraint, "ge", low), getattr(constraint, "gt", low))
46 high = min(high, getattr(constraint, "le", high), getattr(constraint, "lt", high))
47 return low, high
48
49
50def _distinct_value(field: pydantic.fields.FieldInfo, salt: int) -> object:
51 """A value that differs from the field default but keeps its type and bounds."""
52 default = field.default
53 if isinstance(default, bool):
54 return not default
55 low, high = _bounds(field)
56 if isinstance(default, int):
57 return int(min(default + salt, high))
58 if isinstance(default, float):
59 step = default + salt * 0.25
60 return step if low < step < high else round((default + low) / 2 + 1e-3, 6)
61 if isinstance(default, str):
62 return f"{default}_x{salt}"
63 return default
64
65
66def _saturating_document() -> tuple[dict, dict]:
67 """Build a nested document that overrides every single field.
68
69 Returns:
70 ``(config_document, expected_field_values)``.
71 """
72 document: dict[str, dict] = {}
73 expected: dict[str, object] = {}
74 for salt, (name, field) in enumerate(DetectorConfig.model_fields.items(), start=1):
75 if field.annotation is not None and field.annotation not in (bool, int, float, str):
76 continue # Literal / tuple fields have no free-form distinct value.
77 section, key = _model_base.section_path(field)
78 value = _distinct_value(field, salt)
79 assert value != field.default, name
80 document.setdefault(section, {})[key] = value
81 expected[name] = value
82 return document, expected
83
84
85def test_model_defaults_match_packaged_json() -> None:
86 assert DetectorConfig().to_document() == _packaged()
87
88
89def test_load_verticalsigns_config_returns_packaged_defaults() -> None:
90 packaged = _packaged()
91 assert load_verticalsigns_config() == packaged
92 assert load_default_config() == packaged
93 assert build_verticalsigns_config() == packaged
94 assert normalize_verticalsigns_config({}) == packaged
95 assert json.loads(json.dumps(packaged)) == packaged # plain JSON types only
96
97
98def test_error_class_is_config_error() -> None:
99 assert issubclass(VerticalSignsConfigError, config_loader.ConfigError)
100 assert issubclass(VerticalSignsConfigError, ValueError)
101
102
103def test_unknown_top_level_key_is_rejected() -> None:
104 with pytest.raises(VerticalSignsConfigError, match="grund"):
105 DetectorConfig.from_mapping({"grund": {"cell_m": 1.0}})
106
107
108def test_unknown_nested_key_is_rejected() -> None:
109 with pytest.raises(VerticalSignsConfigError, match="cell_metres"):
110 DetectorConfig.from_mapping({"ground": {"cell_metres": 1.0}})
111
112
113def test_overrides_deep_merge_onto_defaults() -> None:
114 built = build_verticalsigns_config(overrides={"ground": {"cell_m": 1.25}})
115 assert built["ground"]["cell_m"] == 1.25
116 assert built["ground"]["percentile"] == _packaged()["ground"]["percentile"]
117 assert built["occupancy"] == _packaged()["occupancy"]
118
119
120def test_set_override_coercion_and_rejection() -> None:
121 overrides = config_loader.parse_set_overrides(
122 ["clustering.min_samples=1e3", "classification.emit_trees=on"],
123 error_cls=VerticalSignsConfigError,
124 nested=True,
125 )
126 built = DetectorConfig.from_mapping(
127 config_loader.deep_merge_dicts(load_default_config(), overrides)
128 )
129 assert built.cluster_min_samples == 1000
130 assert built.emit_trees is True
131 with pytest.raises(VerticalSignsConfigError):
132 DetectorConfig.from_mapping({"classification": {"emit_trees": "flase"}})
133
134
135def test_a_user_config_file_merges_onto_the_defaults(tmp_path) -> None:
136 """A user JSON carries only the keys it changes (``prod2_*.config.json``)."""
137 path = tmp_path / "override.json"
138 path.write_text(json.dumps({"ground": {"cell_m": 1.25}}))
139 loaded = load_verticalsigns_config(path)
140 assert loaded["ground"] == {"cell_m": 1.25, "percentile": _packaged()["ground"]["percentile"]}
141 assert DetectorConfig.load(path).ground_cell_m == 1.25
142
143
144def test_every_field_declares_a_section_path() -> None:
145 """A field without a path is unreachable from the config document."""
146 for field in DetectorConfig.model_fields.values():
147 _model_base.section_path(field)
148
149
150def test_every_field_is_reachable_from_the_nested_document() -> None:
151 document, expected = _saturating_document()
152 built = DetectorConfig.from_mapping(document)
153 wrong = {n: (getattr(built, n), v) for n, v in expected.items() if getattr(built, n) != v}
154 assert not wrong
155
156
157def test_absent_sections_fall_back_to_the_model_defaults() -> None:
158 assert DetectorConfig.from_mapping({}) == DetectorConfig()
159 assert DetectorConfig.from_mapping(load_default_config()) == DetectorConfig()
160
161
162def test_a_partial_section_only_overrides_the_keys_it_carries() -> None:
163 built = DetectorConfig.from_mapping({"ground": {"cell_m": 1.25}})
164 assert built.ground_cell_m == 1.25
165 assert built.ground_percentile == DetectorConfig().ground_percentile
166 assert built.perspective_coverage_tol_m == DetectorConfig().perspective_coverage_tol_m
167
168
169def test_flat_field_names_never_collide_with_section_names() -> None:
170 """The section expansion keys off the section names, so they must be distinct."""
171 sections = {_model_base.section_path(f)[0] for f in DetectorConfig.model_fields.values()}
172 assert not sections & set(DetectorConfig.model_fields)
173
174
175def test_the_document_round_trips_through_the_model() -> None:
176 document, _ = _saturating_document()
177 merged = config_loader.deep_merge_dicts(load_default_config(), document)
178 assert DetectorConfig.from_mapping(merged).to_document() == merged
179
180
181def test_with_overrides_rejects_a_misspelled_field() -> None:
182 """A typo must not become a new attribute while the threshold keeps its default.
183
184 ``model_copy(update=...)`` skips validation, so this is the only thing
185 standing between a misspelled override and a silently ignored threshold.
186 """
187 assert DetectorConfig().with_overrides(cluster_eps_m=0.9).cluster_eps_m == 0.9
188 with pytest.raises(ValueError, match="cluster_eps"):
189 DetectorConfig().with_overrides(cluster_eps=0.9)
190
191
192def test_the_config_module_constants_name_the_package() -> None:
193 assert _config._PACKAGE_NAME == "iolabs_point_cloud_detection_verticalsigns"
194 assert _config._DEFAULT_FILENAME == PACKAGED_JSON.name
195 assert _config._CONTEXT == "verticalsigns config"
0
Importance #69: tests/test_config_split.py @@ -1,143 +0,0 @@
1"""Schema guards for the section-split :class:`DetectorConfig`.
2
3``config.py`` no longer declares the 370 fields itself: they live in the
4``_config_<section>`` slices and are recombined by multiple inheritance, and
5``from_mapping`` is the merge of the slices' ``*_kwargs`` functions. Three
6things must stay true for that split to be invisible to callers:
7
8* every field is still reachable from the nested config document,
9* the slices partition the fields (no field lost, none declared twice),
10* an absent key still falls back to the model default.
11"""
12
13import json
14import re
15from pathlib import Path
16
17from iolabs.common import config_loader
18
19from iolabs_point_cloud_detection_verticalsigns import _config_model
20from iolabs_point_cloud_detection_verticalsigns._config import load_default_config
21from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig
22
23CONFIG_PY = (
24 Path(__file__).resolve().parents[1]
25 / "src/iolabs_point_cloud_detection_verticalsigns/config.py"
26)
27
28
29def _distinct_value(default: object, salt: int) -> object:
30 """A value that differs from *default* but keeps its type."""
31 if isinstance(default, bool):
32 return not default
33 if isinstance(default, int):
34 return default + salt
35 if isinstance(default, float):
36 return default + salt * 0.25
37 if isinstance(default, str):
38 return f"{default}_x{salt}"
39 return default
40
41
42def _saturating_config() -> tuple[dict, dict]:
43 """Builds a nested config that overrides every single field.
44
45 Returns:
46 ``(config_document, expected_field_values)``.
47 """
48 section_locals: dict[str, str] = {}
49 document: dict[str, dict] = {}
50 expected: dict[str, object] = {}
51 fields = DetectorConfig.model_fields
52
53 for path in sorted(CONFIG_PY.parent.glob("_config_*.py")):
54 text = path.read_text()
55 section_locals.update(
56 dict(re.findall(r'^ (\w+) = config\.get\("([^"]+)", \{\}\)$', text, re.M))
57 )
58 for salt, (field_name, local, key) in enumerate(
59 re.findall(r'"(\w+)": (\w+)\.get\(\s*"([^"]+)"', text), start=1
60 ):
61 value = _distinct_value(fields[field_name].default, salt + len(expected))
62 document.setdefault(section_locals[local], {})[key] = value
63 expected[field_name] = value
64
65 return document, expected
66
67
68def test_every_field_is_reachable_from_the_nested_document() -> None:
69 document, expected = _saturating_config()
70 built = DetectorConfig.from_mapping(document)
71 wrong = {n: (getattr(built, n), v) for n, v in expected.items() if getattr(built, n) != v}
72 assert not wrong
73
74
75def test_absent_sections_fall_back_to_the_model_defaults() -> None:
76 assert DetectorConfig.from_mapping({}) == DetectorConfig()
77
78
79def test_a_partial_section_only_overrides_the_keys_it_carries() -> None:
80 built = DetectorConfig.from_mapping({"ground": {"cell_m": 1.25}})
81 assert built.ground_cell_m == 1.25
82 assert built.ground_percentile == DetectorConfig().ground_percentile
83 assert built.perspective_coverage_tol_m == DetectorConfig().perspective_coverage_tol_m
84
85
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
97def test_the_packaged_defaults_round_trip() -> None:
98 packaged = load_default_config()
99 assert json.loads(json.dumps(packaged)) == packaged # plain JSON types only
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")
128
129
130def test_the_packaged_json_declares_exactly_the_model_keys() -> None:
131 """The packaged JSON and the model must not drift apart in SHAPE either.
132
133 ``load_verticalsigns_config`` returns the validated model dump, so a key
134 the model declares but the JSON omits would be injected into the returned
135 document (and a JSON key the model lacks would be rejected outright).
136 """
137 packaged = json.loads(
138 (CONFIG_PY.parent / "verticalsigns.default.json").read_text(encoding="utf-8")
139 )
140 model = _config_model.VerticalSignsConfig().model_dump(mode="json")
141 assert {s: sorted(keys) for s, keys in packaged.items()} == {
142 s: sorted(keys) for s, keys in model.items()
143 }
0
Importance #70: tests/test_tree_instances.py @@ -17,9 +17,9 @@
1717
18import numpy as np18import numpy as np
19import pytest19import pytest
2020
21from iolabs_point_cloud_detection_verticalsigns import _config, _model_tree21from iolabs_point_cloud_detection_verticalsigns import _config
22from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig22from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig
23from iolabs_point_cloud_detection_verticalsigns.tree_instances import (23from iolabs_point_cloud_detection_verticalsigns.tree_instances import (
24 ABSTAIN_ASSIGNED,24 ABSTAIN_ASSIGNED,
25 ABSTAIN_HEDGE,25 ABSTAIN_HEDGE,
Importance #71: tests/test_tree_instances.py @@ -1070,9 +1070,9 @@
1070 _config.load_verticalsigns_config(path)1070 _config.load_verticalsigns_config(path)
10711071
10721072
1073def test_config_accepts_every_documented_key(tmp_path, section_values) -> None:1073def test_config_accepts_every_documented_key(tmp_path, section_values) -> None:
1074 section = section_values(_model_tree.TreeInstanceConfig)1074 section = section_values("tree_instance")
1075 section["enabled"] = True1075 section["enabled"] = True
1076 path = tmp_path / "override.json"1076 path = tmp_path / "override.json"
1077 path.write_text(json.dumps({"tree_instance": section}))1077 path.write_text(json.dumps({"tree_instance": section}))
1078 assert _config.load_verticalsigns_config(path)["tree_instance"]["enabled"]1078 assert _config.load_verticalsigns_config(path)["tree_instance"]["enabled"]