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(-)
| 53 | Raises: | 324 | Raises: |
| 54 | VerticalSignsConfigError: The user JSON is malformed, or the merged | 325 | 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 None | 328 | return build_verticalsigns_config(config_path=config_path) |
| 58 | config = config_loader.load_config( | 329 | |
| 59 | VerticalSignsConfig, | 330 | |
| 60 | package=_PACKAGE_NAME, | 331 | def 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 | |||
| 69 | def _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 |
| 1 | """Detector configuration. | 1 | """Public import path for the detector configuration. |
| 2 | 2 | ||
| 3 | The 379-field :class:`DetectorConfig` and its ``from_mapping`` flattener are | 3 | The schema, the loading entry points and the error class live in `_config`; |
| 4 | split by section across the ``_config_<section>`` modules; this module | 4 | this module re-exports them so the documented ``from |
| 5 | recombines them and re-exports every piece, so ``from .config import X`` | 5 | iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig`` keeps |
| 6 | keeps working for every name that used to live here. | 6 | working. The field declarations themselves are split across the |
| 7 | 7 | ``_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 | ||
| 10 | by the :class:`VerticalSignsConfig` model tree in ``_config_model``. | ||
| 11 | """ | 8 | """ |
| 12 | 9 | ||
| 13 | from pathlib import Path | 10 | from ._config import ( |
| 14 | from typing import Any | 11 | DetectorConfig, |
| 15 | 12 | VerticalSignsConfigError, | |
| 16 | from ._config import load_verticalsigns_config | 13 | build_verticalsigns_config, |
| 17 | from ._config_conic import ConicFields, conic_kwargs | 14 | load_default_config, |
| 18 | from ._config_corridor import CorridorFields, corridor_kwargs | 15 | load_verticalsigns_config, |
| 19 | from ._config_devices import DeviceFields, device_kwargs | 16 | normalize_verticalsigns_config, |
| 20 | from ._config_evidence import EvidenceFields, evidence_kwargs | 17 | ) |
| 21 | from ._config_grid import GridFields, grid_kwargs | ||
| 22 | from ._config_perspective import PerspectiveFields, perspective_kwargs | ||
| 23 | from ._config_roadcontext import RoadContextFields, road_context_kwargs | ||
| 24 | from ._config_stages import StageFields, stage_kwargs | ||
| 25 | from ._config_treedetect import TreeDetectionFields, tree_detection_kwargs | ||
| 26 | from ._config_treeinstance import TreeInstanceFields, tree_instance_kwargs | ||
| 27 | from ._config_vegetation import VegetationFields, vegetation_kwargs | ||
| 28 | 18 | ||
| 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 | |||
| 56 | class 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)) |
| 1 | """The colour-free conic gate and the conifer rule that rides on it. | ||
| 2 | |||
| 3 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 4 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 5 | re-exports both names defined here. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from typing import Any | ||
| 9 | |||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | |||
| 13 | class 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 | |||
| 111 | def 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 |
| 1 | """Road corridor rasterization and on-carriageway rejection. | ||
| 2 | |||
| 3 | Also plate planarity, the bright-panel class and the free-space ring. | ||
| 4 | |||
| 5 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 6 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 7 | re-exports both names defined here. | ||
| 8 | """ | ||
| 9 | |||
| 10 | from typing import Any | ||
| 11 | |||
| 12 | from iolabs.common import config_loader | ||
| 13 | |||
| 14 | |||
| 15 | class 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 | |||
| 114 | def 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 |
| 1 | """Per-device thresholds for delineators, sign posts and gantries. | ||
| 2 | |||
| 3 | Also isolated-floating-pole rejection and duplicate suppression. | ||
| 4 | |||
| 5 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 6 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 7 | re-exports both names defined here. | ||
| 8 | """ | ||
| 9 | |||
| 10 | from typing import Any | ||
| 11 | |||
| 12 | from iolabs.common import config_loader | ||
| 13 | |||
| 14 | |||
| 15 | class 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 | |||
| 145 | def 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 |
| 1 | """Ground, occupancy grid, candidate band and clustering thresholds. | ||
| 2 | |||
| 3 | Also the first classification gates and vehicle rejection. | ||
| 4 | |||
| 5 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 6 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 7 | re-exports both names defined here. | ||
| 8 | """ | ||
| 9 | |||
| 10 | from typing import Any | ||
| 11 | |||
| 12 | from iolabs.common import config_loader | ||
| 13 | |||
| 14 | |||
| 15 | class 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 | |||
| 74 | def 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 |
| 1 | """The nested pydantic config model for the vertical-sign detector. | ||
| 2 | |||
| 3 | ``VerticalSignsConfig`` mirrors ``verticalsigns.default.json`` section for | ||
| 4 | section and key for key: it is the single source of truth for which config | ||
| 5 | keys exist and what type each one has. Adding a key means adding a field to | ||
| 6 | the matching section model and the same default to the packaged JSON; the two | ||
| 7 | sides must stay in lockstep, and ``tests/test_config_split.py`` fails if they | ||
| 8 | drift. A key the detector modules read also needs its flat ``DetectorConfig`` | ||
| 9 | field and the ``*_kwargs`` line that maps it (see ``config.py``). | ||
| 10 | """ | ||
| 11 | |||
| 12 | from iolabs.common import config_loader | ||
| 13 | |||
| 14 | from . import _model_devices, _model_grid, _model_road, _model_tree | ||
| 15 | |||
| 16 | |||
| 17 | class 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 |
| 1 | """Perspective-projection QC overlay cameras and coverage tolerances. | ||
| 2 | |||
| 3 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 4 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 5 | re-exports both names defined here. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from typing import Any | ||
| 9 | |||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | |||
| 13 | class 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 | |||
| 46 | def 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 |
| 1 | """Road-context gate, driven-lane band and repetitive-row rejection. | ||
| 2 | |||
| 3 | Also field-stake rows and embedded-marker extraction. | ||
| 4 | |||
| 5 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 6 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 7 | re-exports both names defined here. | ||
| 8 | """ | ||
| 9 | |||
| 10 | from typing import Any | ||
| 11 | |||
| 12 | from iolabs.common import config_loader | ||
| 13 | |||
| 14 | |||
| 15 | class 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 | |||
| 176 | def 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 |
| 1 | """Opt-in post-classification stages. | ||
| 2 | |||
| 3 | The rail-relative half-post pass, the reject-rescue second look and | ||
| 4 | the ML verifier. | ||
| 5 | |||
| 6 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 7 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 8 | re-exports both names defined here. | ||
| 9 | """ | ||
| 10 | |||
| 11 | from typing import Any | ||
| 12 | |||
| 13 | from iolabs.common import config_loader | ||
| 14 | |||
| 15 | |||
| 16 | class 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 | |||
| 125 | def 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 |
| 1 | """Experimental tree detection and TCS ground filtering of the DEM input. | ||
| 2 | |||
| 3 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 4 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 5 | re-exports both names defined here. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from typing import Any | ||
| 9 | |||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | |||
| 13 | class 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 | |||
| 81 | def 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 |
| 1 | """Field-declaration helper shared by the ``_model_<topic>`` config slices. | ||
| 2 | |||
| 3 | The detector reads a FLAT config (``config.ground_cell_m``) while the packaged | ||
| 4 | ``verticalsigns.default.json`` โ and every user override file โ is grouped into | ||
| 5 | sections (``{"ground": {"cell_m": 0.75}}``). :func:`section_field` is what joins | ||
| 6 | the two: each flat field declares the JSON section and key it comes from right | ||
| 7 | where it declares its type and default, so a new config key costs exactly two | ||
| 8 | edits (the field here, the same key in the JSON) and no separate mapping table. | ||
| 9 | |||
| 10 | :class:`iolabs_point_cloud_detection_verticalsigns._config.DetectorConfig` | ||
| 11 | walks that metadata to translate a nested document into flat keyword arguments | ||
| 12 | (``DetectorConfig.from_mapping``) and back (``DetectorConfig.to_document``). | ||
| 13 | """ | ||
| 14 | |||
| 15 | from __future__ import annotations | ||
| 16 | |||
| 17 | from typing import Any | ||
| 18 | |||
| 19 | import pydantic | ||
| 20 | |||
| 21 | _SECTION_METADATA_KEY = "config_section_path" | ||
| 22 | |||
| 23 | |||
| 24 | def 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 | |||
| 51 | def 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 |
| 1 | """The colour-free conic gate and the conifer rule that rides on it. | ||
| 2 | |||
| 3 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 4 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 5 | loaded from; ``_config`` recombines the slices into the model. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from iolabs.common import config_loader | ||
| 9 | |||
| 10 | from ._model_base import section_field | ||
| 11 | |||
| 12 | |||
| 13 | class 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 |
| 1 | """Road corridor rasterization and on-carriageway rejection. | ||
| 2 | |||
| 3 | Also plate planarity, the bright-panel class and the free-space ring. | ||
| 4 | |||
| 5 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 6 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 7 | loaded from; ``_config`` recombines the slices into the model. | ||
| 8 | """ | ||
| 9 | |||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | from ._model_base import section_field | ||
| 13 | |||
| 14 | |||
| 15 | class 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 |
| 1 | """Per-device acceptance gates and the two probe stages. | 1 | """Per-device thresholds for delineators, sign posts and gantries. |
| 2 | 2 | ||
| 3 | One slice of the nested :class:`VerticalSignsConfig` model tree; the sections | 3 | Also isolated-floating-pole rejection and duplicate suppression. |
| 4 | mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines | 4 | |
| 5 | the slices. | 5 | One slice of the flat ``DetectorConfig``. Every field declares, via |
| 6 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 7 | loaded from; ``_config`` recombines the slices into the model. | ||
| 6 | """ | 8 | """ |
| 7 | 9 | ||
| 8 | from iolabs.common import config_loader | 10 | from iolabs.common import config_loader |
| 9 | 11 | ||
| 10 | 12 | from ._model_base import section_field | |
| 11 | class DelineatorConfig(config_loader.ConfigModel): | 13 | |
| 12 | """Delineator (Leitpfosten) acceptance gates.""" | 14 | |
| 13 | 15 | class VerticalSignsDeviceFields(config_loader.ConfigModel): | |
| 14 | h_min_m: float = 0.7 | 16 | """Per-device thresholds for delineators, sign posts and gantries. |
| 15 | h_max_m: float = 1.5 | 17 | |
| 16 | max_footprint_m: float = 0.45 | 18 | Also isolated-floating-pole rejection and duplicate suppression. |
| 17 | relaxed_footprint_m: float = 0.85 | 19 | |
| 18 | relaxed_min_verticality: float = 0.85 | 20 | Metres unless stated otherwise. |
| 19 | relaxed_max_ring_fill_ratio: float = 1.0 | 21 | """ |
| 20 | relaxed_min_hi_intensity_fraction: float = 0.15 | 22 | |
| 21 | min_hi_intensity_fraction: float = 0.08 | 23 | # Delineator (Leitpfosten). The height ceiling (1.5 m) and footprint cap |
| 22 | min_points: int = 300 | 24 | # (0.45 m) admit taller guide posts and the mild along-track smear that gore |
| 23 | 25 | # posts pick up in MLS (segment 131's junction posts read 0.42 m major, | |
| 24 | 26 | # h 1.2-1.5); real Leitpfosten cores stay ~0.12 m so the cap change does not | |
| 25 | class 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) |
| 27 | 29 | delineator_h_max_m: float = section_field("delineator.h_max_m", 1.5) | |
| 28 | max_len_minor_m: float = 0.8 | 30 | delineator_max_footprint_m: float = section_field("delineator.max_footprint_m", 0.45) |
| 29 | h_min_m: float = 1.5 | 31 | # Relaxed footprint band for a delineator whose along-track MLS smear at a |
| 30 | h_max_m: float = 6.0 | 32 | # junction/gore pushes its major extent past the tight 0.45 m cap (segment |
| 31 | min_continuity: float = 0.6 | 33 | # 134's splitter-island posts read 0.47-0.63 m major). Only admitted when the |
| 32 | plate_hi_intensity_fraction: float = 0.4 | 34 | # cluster is strongly vertical (a genuine post), so a flat bright road-marking |
| 33 | plate_hi_intensity_fraction_weak: float = 0.3 | 35 | # fragment (verticality ~0.1) can never sneak in through the wider cap. Purely |
| 34 | plate_upper_surplus_ratio: float = 2.0 | 36 | # additive: clusters at or under delineator_max_footprint_m keep the original |
| 35 | min_upper_half_surplus: float = 0.3 | 37 | # (verticality-free) path, so no existing detection is affected. |
| 36 | plate_min_core_rms_m: float = 0.1 | 38 | # 0.65 -> 0.85 (AI3D-339 pass 3): Abschnitt-1 Leitpfosten merge with verge |
| 37 | max_plate_thickness_m: float = 0.15 | 39 | # 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.5 | 40 | # was the single failing conjunct for 8 adversarially judged-real posts. |
| 39 | bare_post_max_core_rms_m: float = 0.065 | 41 | # At 0.85: A4_5 +3 judged-real delineators / 0 lost; A1 +~18 judged-real vs |
| 40 | bare_post_min_verticality: float = 0.9 | 42 | # +5 judged-veg. Real (0.66-0.83) and FP (0.68-0.85) footprints fully |
| 41 | bare_post_min_points: int = 450 | 43 | # overlap, so no tighter cap separates them โ the veg leak is a texture |
| 42 | 44 | # problem (multi-radius plate regularity, task #14), not a threshold one. | |
| 43 | 45 | delineator_relaxed_footprint_m: float = section_field("delineator.relaxed_footprint_m", 0.85) | |
| 44 | class PanelConfig(config_loader.ConfigModel): | 46 | delineator_relaxed_min_verticality: float = section_field( |
| 45 | """Large panel acceptance gates.""" | 47 | "delineator.relaxed_min_verticality", 0.85 |
| 46 | 48 | ) | |
| 47 | min_hi: float = 0.4 | 49 | # The wider relaxed band admits more smear, so it is guarded harder than the |
| 48 | max_thickness_m: float = 0.2 | 50 | # compact path: the post must stand clear (a near-empty free-space ring, so a |
| 49 | h_min_m: float = 0.9 | 51 | # bright speck embedded in roadside vegetation โ segment 084 โ is rejected) |
| 50 | len_major_min_m: float = 1.5 | 52 | # and be clearly retroreflective (a higher brightness floor than the compact |
| 51 | len_major_max_m: float = 5.0 | 53 | # 0.08, so a modest-brightness on-carriageway edge feature โ segment 096 โ is |
| 52 | 54 | # rejected). Genuine gore/island posts pass both (ring ~0, hi 0.28-0.66). | |
| 53 | 55 | delineator_relaxed_max_ring_fill_ratio: float = section_field( | |
| 54 | class GantryConfig(config_loader.ConfigModel): | 56 | "delineator.relaxed_max_ring_fill_ratio", 1.0 |
| 55 | """Gantry leg and pairing gates.""" | 57 | ) |
| 56 | 58 | delineator_relaxed_min_hi_intensity_fraction: float = section_field( | |
| 57 | h_min_m: float = 4.5 | 59 | "delineator.relaxed_min_hi_intensity_fraction", 0.15 |
| 58 | len_major_m: float = 8.0 | 60 | ) |
| 59 | max_len_minor_m: float = 6.0 | 61 | delineator_min_hi_intensity_fraction: float = section_field( |
| 60 | pair_station_tolerance_m: float = 5.0 | 62 | "delineator.min_hi_intensity_fraction", 0.08 |
| 61 | pair_min_separation_m: float = 3.0 | 63 | ) |
| 62 | overhead_h_min_m: float = 4.5 | 64 | # Real Leitpfosten return a few hundred points; sub-~300 bright specks are |
| 63 | pair_isolation_radius_m: float = 8.0 | 65 | # reflective vegetation/debris (segment 048 FP had ~100; segment 084's bright |
| 64 | 66 | # speck embedded in verge scrub, newly reachable once the corridor keeps | |
| 65 | 67 | # branch roads, had 239). Every genuine delineator across the dataset returns | |
| 66 | class 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) |
| 68 | 70 | ||
| 69 | min_members: int = 4 | 71 | # Sign post / plate |
| 70 | max_spacing_m: float = 5.0 | 72 | sign_post_max_len_minor_m: float = section_field("sign_post.max_len_minor_m", 0.8) |
| 71 | max_perp_spread_m: float = 1.5 | 73 | sign_post_h_min_m: float = section_field("sign_post.h_min_m", 1.5) |
| 72 | max_h_max_range_m: float = 0.7 | 74 | sign_post_h_max_m: float = section_field("sign_post.h_max_m", 6.0) |
| 73 | member_max_len_major_m: float = 2.0 | 75 | sign_post_min_continuity: float = section_field("sign_post.min_continuity", 0.60) |
| 74 | member_max_len_minor_m: float = 0.8 | 76 | # Plate evidence needs strong retroreflectivity: verified real sign plates |
| 75 | 77 | # (segments 006/030/132/134) return an upper-half high-intensity fraction of | |
| 76 | 78 | # 0.44-0.94, while every dull false-positive "sign" (vegetation mounds, | |
| 77 | class 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 |
| 79 | 81 | # panel; the weak path allows a moderately-bright, upper-piled plate. | |
| 80 | row_emit: bool = True | 82 | plate_hi_intensity_fraction: float = section_field( |
| 81 | min_members: int = 4 | 83 | "sign_post.plate_hi_intensity_fraction", 0.40 |
| 82 | min_spacing_m: float = 2.0 | 84 | ) |
| 83 | max_spacing_m: float = 10.0 | 85 | plate_hi_intensity_fraction_weak: float = section_field( |
| 84 | max_spacing_cv: float = 0.35 | 86 | "sign_post.plate_hi_intensity_fraction_weak", 0.30 |
| 85 | 87 | ) | |
| 86 | 88 | # Upper-half point pile-up ratio required as weak-plate evidence and as | |
| 87 | class 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 |
| 89 | 91 | # returns up high (good signs sit at 2.8-4.6, or carry a broad bright core). | |
| 90 | min_len_major_m: float = 6.0 | 92 | sign_plate_upper_surplus_ratio: float = section_field( |
| 91 | bright_h_min_m: float = 1.5 | 93 | "sign_post.plate_upper_surplus_ratio", 2.0 |
| 92 | min_bright_points: int = 400 | 94 | ) |
| 93 | window_m: float = 2.5 | 95 | # A genuine plate sits high on its post, so the upper half must hold at least |
| 94 | min_bright_fraction: float = 0.45 | 96 | # as many returns as ~1/3 of the lower half. Low-lying bright blobs at the |
| 95 | min_h_max_m: float = 1.6 | 97 | # foot of a vehicle/truck (segment 106 FPs at ~0.09) are not plates. |
| 96 | min_vertical_span_m: float = 0.5 | 98 | sign_min_upper_half_surplus: float = section_field("sign_post.min_upper_half_surplus", 0.30) |
| 97 | 99 | # A real sign PLATE spreads returns laterally (broad core) or piles them in | |
| 98 | 100 | # the upper half; brightness alone on a tight thin core is a reflective | |
| 99 | class 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) |
| 101 | 103 | ||
| 102 | band_lat_m: float = 0.8 | 104 | # Bare posts (no plate evidence) must be tall, tight, vertical, and |
| 103 | band_z_hi_m: float = 1.5 | 105 | # well-sampled. 0.065 m tightness rejects tall roadside vegetation (whose |
| 104 | band_z_lo_m: float = 0.15 | 106 | # per-bin core reaches ~0.17 m); real marker posts sit near ~0.04 m. The |
| 105 | cluster_cell_m: float = 0.15 | 107 | # point-count floor rejects small bright reflective specks (~<450 returns). |
| 106 | dedupe_m: float = 1.5 | 108 | # Plate-less posts below gantry-leg height are indistinguishable from tree |
| 107 | enabled: bool = False | 109 | # guards / fence posts by LiDAR geometry alone (confirmed FP in seg 132). |
| 108 | ground_cell_m: float = 2.0 | 110 | bare_post_min_h_max_m: float = section_field("sign_post.bare_post_min_h_max_m", 4.5) |
| 109 | ground_percentile: float = 10.0 | 111 | 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.8 | 112 | bare_post_min_verticality: float = section_field("sign_post.bare_post_min_verticality", 0.90) |
| 111 | h_min_m: float = 0.2 | 113 | bare_post_min_points: int = section_field("sign_post.bare_post_min_points", 450) |
| 112 | max_lateral_m: float = 0.5 | 114 | |
| 113 | max_width_m: float = 0.2 | 115 | # Isolated floating-pole rejection (far-range boundary ghost, defect class 1a). |
| 114 | min_emit_points: int = 8 | 116 | # A "floating" pole_other whose base sits well off the ground (h_min high โ no |
| 115 | min_points: int = 15 | 117 | # ground-connected shaft, just an upper vertical smear) is a range-smear |
| 116 | min_z_extent_m: float = 0.1 | 118 | # 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 = 2 | 120 | # of several such columns clustered together (a genuine gantry-leg / mast group |
| 119 | prime_min_sat: int = 1 | 121 | # โ segments 046, 066, 025). Verified across the full sweep: the only isolated |
| 120 | sample_step_m: float = 0.1 | 122 | # floating poles (no floating-pole neighbour within pole_isolated_radius_m) are |
| 121 | saturation_intensity: float = 55000.0 | 123 | # exactly the 005/015 ghosts; every real gantry-leg pole has >=1 neighbour. |
| 122 | 124 | pole_floating_min_h_min_m: float = section_field( | |
| 123 | 125 | "classification.pole_floating_min_h_min_m", 3.5 | |
| 124 | class 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) |
| 126 | 128 | ||
| 127 | accepted_exclusion_m: float = 2.0 | 129 | # Post-classification duplicate suppression (defect class 4). Two detections |
| 128 | enabled: bool = False | 130 | # within dedup_radius_m XY of each other describe the same physical marker |
| 129 | h_max_m: float = 1.6 | 131 | # (e.g. a striped gore post firing both a delineator and a sign); keep the |
| 130 | h_min_m: float = 0.85 | 132 | # higher-priority type (sign > delineator > sign_post > pole_other > |
| 131 | max_core_rms_m: float = 0.2 | 133 | # gantry_or_gate), breaking ties by point count, and drop the other. |
| 132 | merge_radius_m: float = 1.0 | 134 | dedup_radius_m: float = section_field("classification.dedup_radius_m", 0.8) |
| 133 | min_continuity: float = 0.8 | 135 | |
| 134 | min_decile_fill: float = 0.6 | 136 | # Gantry / gate |
| 135 | min_h_over_width: float = 1.4 | 137 | gantry_h_min_m: float = section_field("gantry.h_min_m", 4.5) |
| 136 | min_points: int = 30 | 138 | gantry_len_major_m: float = section_field("gantry.len_major_m", 8.0) |
| 137 | min_records: int = 2 | 139 | # A road-spanning overhead beam is thin; a tilted reflective truck-trailer |
| 138 | min_roadctx_sat: int = 17 | 140 | # slab (segment 106) is broad (len_minor ~9.8 m). Cap the single-cluster |
| 139 | min_verticality: float = 0.9 | 141 | # overhead_span footprint minor extent (real gantry cluster ~4.75 m). |
| 140 | per_segment_cap: int = 0 | 142 | 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) |
| 1 | """Evidence-level thresholds: sentinels, vetoes and reference percentiles. | ||
| 2 | |||
| 3 | Covers the verticality sentinel, tier-2 robust extent statistics, | ||
| 4 | retroreflectivity references, the single-record transient and | ||
| 5 | vegetation-texture vetoes, the delineator lattice and tree emission. | ||
| 6 | |||
| 7 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 8 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 9 | loaded from; ``_config`` recombines the slices into the model. | ||
| 10 | """ | ||
| 11 | |||
| 12 | from iolabs.common import config_loader | ||
| 13 | |||
| 14 | from ._model_base import section_field | ||
| 15 | |||
| 16 | |||
| 17 | class 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 |
| 1 | """Grid, candidate, classification, radius and corridor config sections. | 1 | """Ground, occupancy grid, candidate band and clustering thresholds. |
| 2 | 2 | ||
| 3 | One slice of the nested :class:`VerticalSignsConfig` model tree; the sections | 3 | Also the first classification gates and vehicle rejection. |
| 4 | mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines | 4 | |
| 5 | the slices. | 5 | One slice of the flat ``DetectorConfig``. Every field declares, via |
| 6 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 7 | loaded from; ``_config`` recombines the slices into the model. | ||
| 6 | """ | 8 | """ |
| 7 | 9 | ||
| 8 | from iolabs.common import config_loader | 10 | from iolabs.common import config_loader |
| 9 | 11 | ||
| 10 | 12 | from ._model_base import section_field | |
| 11 | class GroundConfig(config_loader.ConfigModel): | 13 | |
| 12 | """Ground-model raster cell size and percentile.""" | 14 | |
| 13 | 15 | class VerticalSignsGridFields(config_loader.ConfigModel): | |
| 14 | cell_m: float = 0.75 | 16 | """Ground, occupancy grid, candidate band and clustering thresholds. |
| 15 | percentile: float = 8.0 | 17 | |
| 16 | 18 | Also the first classification gates and vehicle rejection. | |
| 17 | 19 | ||
| 18 | class OccupancyConfig(config_loader.ConfigModel): | 20 | Metres unless stated otherwise. |
| 19 | """Occupancy grid used to find candidate cells.""" | 21 | """ |
| 20 | 22 | ||
| 21 | cell_m: float = 0.15 | 23 | # Ground model |
| 22 | 24 | ground_cell_m: float = section_field("ground.cell_m", 0.75, gt=0.0) | |
| 23 | 25 | ground_percentile: float = section_field("ground.percentile", 8.0, ge=0.0, le=100.0) | |
| 24 | class CandidatesConfig(config_loader.ConfigModel): | 26 | |
| 25 | """Height band and seed-cell gates for candidate points.""" | 27 | # Occupancy grid for candidate cells |
| 26 | 28 | occupancy_cell_m: float = section_field("occupancy.cell_m", 0.15, gt=0.0) | |
| 27 | min_height_m: float = 0.3 | 29 | |
| 28 | max_height_m: float = 10.0 | 30 | # Height band for off-ground candidate points |
| 29 | seed_min_vertical_span_m: float = 0.8 | 31 | min_height_m: float = section_field("candidates.min_height_m", 0.30) |
| 30 | seed_min_h_max_m: float = 0.9 | 32 | max_height_m: float = section_field("candidates.max_height_m", 10.0) |
| 31 | seed_bright_min_vertical_span_m: float = 0.45 | 33 | |
| 32 | seed_bright_min_h_max_m: float = 0.6 | 34 | # Seed-cell gates (vertical span and max height above ground) |
| 33 | seed_bright_min_points: int = 3 | 35 | seed_min_vertical_span_m: float = section_field("candidates.seed_min_vertical_span_m", 0.80) |
| 34 | 36 | seed_min_h_max_m: float = section_field("candidates.seed_min_h_max_m", 0.90) | |
| 35 | 37 | ||
| 36 | class 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 |
| 38 | 40 | # cell (base clipped by min_height_m=0.30), so they fall just under the | |
| 39 | eps_m: float = 0.45 | 41 | # 0.80 m primary span gate and never seed a cluster โ the round-4 recall |
| 40 | min_samples: int = 1 | 42 | # gap. A second, relaxed seed pass recovers them, but is restricted to |
| 41 | hull_margin_m: float = 0.2 | 43 | # cells holding >= seed_bright_min_points retroreflective returns |
| 42 | 44 | # (intensity >= the segment's hi-intensity threshold): a Leitpfosten head | |
| 43 | 45 | # is always retroreflective, so the extra candidate cells stay few and the | |
| 44 | class 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. |
| 46 | 48 | seed_bright_min_vertical_span_m: float = section_field( | |
| 47 | continuity_bin_m: float = 0.25 | 49 | "candidates.seed_bright_min_vertical_span_m", 0.45 |
| 48 | reject_len_major_m: float = 6.0 | 50 | ) |
| 49 | reject_h_max_with_large_footprint_m: float = 4.5 | 51 | seed_bright_min_h_max_m: float = section_field("candidates.seed_bright_min_h_max_m", 0.60) |
| 50 | min_continuity: float = 0.5 | 52 | seed_bright_min_points: int = section_field("candidates.seed_bright_min_points", 3) |
| 51 | min_accept_h_max_m: float = 0.9 | 53 | |
| 52 | core_rms_bin_m: float = 0.25 | 54 | # DBSCAN clustering on seed-cell centres |
| 53 | core_rms_h_min_m: float = 0.3 | 55 | cluster_eps_m: float = section_field("clustering.eps_m", 0.45) |
| 54 | core_rms_h_cap_m: float = 3.0 | 56 | cluster_min_samples: int = section_field("clustering.min_samples", 1) |
| 55 | hi_intensity_all_points_percentile: float = 98.0 | 57 | cluster_hull_margin_m: float = section_field("clustering.hull_margin_m", 0.20) |
| 56 | min_volumetric_density: float = 8000.0 | 58 | |
| 57 | pole_floating_min_h_min_m: float = 3.5 | 59 | # Per-cluster feature bins |
| 58 | pole_isolated_radius_m: float = 8.0 | 60 | continuity_bin_m: float = section_field("classification.continuity_bin_m", 0.25) |
| 59 | dedup_radius_m: float = 0.8 | 61 | |
| 60 | emit_trees: bool = False | 62 | # Classification thresholds |
| 61 | ml_verifier_enabled: bool = True | 63 | reject_len_major_m: float = section_field("classification.reject_len_major_m", 6.0) |
| 62 | ml_veto_threshold: float = -1.0 | 64 | 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 = True | 66 | ) |
| 65 | lattice_max_seed_spacing_m: float = 60.0 | 67 | min_continuity: float = section_field("classification.min_continuity", 0.50) |
| 66 | lattice_max_skip: int = 6 | 68 | min_accept_h_max_m: float = section_field("classification.min_accept_h_max_m", 0.90) |
| 67 | lattice_max_spacing_resid: float = 0.15 | 69 | |
| 68 | lattice_min_anchors: int = 4 | 70 | # Vehicle rejection |
| 69 | lattice_min_seed_spacing_m: float = 15.0 | 71 | vehicle_h_min_m: float = section_field("vehicle.h_min_m", 1.5) |
| 70 | lattice_pool_h_max_max_m: float = 1.4 | 72 | vehicle_h_max_m: float = section_field("vehicle.h_max_m", 4.5) |
| 71 | lattice_pool_h_max_min_m: float = 0.8 | 73 | vehicle_len_major_m: float = section_field("vehicle.len_major_m", 2.5) |
| 72 | lattice_pool_max_len_major_m: float = 1.2 | 74 | vehicle_len_minor_m: float = section_field("vehicle.len_minor_m", 1.5) |
| 73 | lattice_pool_max_plate_thickness_m: float = 0.05 | 75 | vehicle_max_hi_intensity_fraction: float = section_field( |
| 74 | lattice_pool_min_hi_seed_fraction: float = 0.15 | 76 | "vehicle.max_hi_intensity_fraction", 0.10 |
| 75 | lattice_pool_min_points: int = 20 | 77 | ) |
| 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 | |||
| 94 | class 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 | |||
| 114 | class 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 | |||
| 131 | class 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 | |||
| 145 | class 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 |
| 1 | """Perspective-projection QC overlay cameras and per-detection QC views. | ||
| 2 | |||
| 3 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 4 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 5 | loaded from; ``_config`` recombines the slices into the model. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from iolabs.common import config_loader | ||
| 9 | |||
| 10 | from ._model_base import section_field | ||
| 11 | |||
| 12 | |||
| 13 | class 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 | |||
| 46 | class 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 |
| 1 | """Road-context, edge-line and QC rendering config sections. | 1 | """Road-context gate, driven-lane band and repetitive-row rejection. |
| 2 | 2 | ||
| 3 | One slice of the nested :class:`VerticalSignsConfig` model tree; the sections | 3 | Also field-stake rows and embedded-marker extraction. |
| 4 | mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines | 4 | |
| 5 | the slices. | 5 | One slice of the flat ``DetectorConfig``. Every field declares, via |
| 6 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 7 | loaded from; ``_config`` recombines the slices into the model. | ||
| 6 | """ | 8 | """ |
| 7 | 9 | ||
| 8 | from iolabs.common import config_loader | 10 | from iolabs.common import config_loader |
| 9 | 11 | ||
| 12 | from ._model_base import section_field | ||
| 10 | 13 | ||
| 11 | class RoadContextConfig(config_loader.ConfigModel): | ||
| 12 | """Road-context saturation raster and XML carriageway votes.""" | ||
| 13 | |||
| 14 | gate_enabled: bool = True | ||
| 15 | xml_enabled: bool = True | ||
| 16 | xml_min_agreement: float = 0.6 | ||
| 17 | xml_vote_slack_m: float = 3.0 | ||
| 18 | xml_max_distance_m: float = 60.0 | ||
| 19 | xml_station_tolerance_m: float = 2.0 | ||
| 20 | xml_station_step_m: float = 10.0 | ||
| 21 | min_carriageway_width_m: float = 3.0 | ||
| 22 | max_carriageway_width_m: float = 20.0 | ||
| 23 | paint_fallback_enabled: bool = False | ||
| 24 | saturation_intensity: float = 55000.0 | ||
| 25 | radius_m: float = 15.0 | ||
| 26 | neighbour_span: int = 1 | ||
| 27 | cache_dir: str = "" | ||
| 28 | min_neighbourhood_saturated: int = 1000 | ||
| 29 | 14 | ||
| 15 | class VerticalSignsRoadContextFields(config_loader.ConfigModel): | ||
| 16 | """Road-context gate, driven-lane band and repetitive-row rejection. | ||
| 30 | 17 | ||
| 31 | class EdgeLineConfig(config_loader.ConfigModel): | 18 | Also field-stake rows and embedded-marker extraction. |
| 32 | """Edge-line paint detection and far-distance filtering.""" | ||
| 33 | 19 | ||
| 34 | gate_enabled: bool = True | 20 | Metres unless stated otherwise. |
| 35 | paint_max_height_m: float = 0.35 | 21 | """ |
| 36 | paint_min_height_m: float = -0.25 | ||
| 37 | paint_intensity_percentile: float = 95.0 | ||
| 38 | paint_subsample: int = 20 | ||
| 39 | station_len_m: float = 10.0 | ||
| 40 | min_window_returns: int = 2000 | ||
| 41 | lateral_bin_m: float = 0.1 | ||
| 42 | min_line_points: int = 40 | ||
| 43 | max_line_width_m: float = 1.5 | ||
| 44 | min_line_along_fill: float = 0.4 | ||
| 45 | drive_line_bin_m: float = 0.5 | ||
| 46 | min_band_width_m: float = 2.0 | ||
| 47 | max_band_width_m: float = 9.0 | ||
| 48 | inward_margin_m: float = 0.3 | ||
| 49 | min_coverage_frac: float = 0.6 | ||
| 50 | min_axis_contrast: float = 3.0 | ||
| 51 | axis_search_radius_m: float = 40.0 | ||
| 52 | axis_max_angle_cos: float = 0.8 | ||
| 53 | axis_max_distance_m: float = 150.0 | ||
| 54 | exempt_h_max_m: float = 4.5 | ||
| 55 | reject_requires_transient: bool = True | ||
| 56 | transient_max_records: int = 1 | ||
| 57 | far_filter_enabled: bool = True | ||
| 58 | far_max_distance_m: float = 30.0 | ||
| 59 | far_include_lane_lines: bool = True | ||
| 60 | far_tier2_enabled: bool = True | ||
| 61 | far_tier2_distance_m: float = 15.0 | ||
| 62 | far_tier2_max_saturation: int = 150 | ||
| 63 | max_carriageway_width_m: float = 20.0 | ||
| 64 | min_carriageway_width_m: float = 3.0 | ||
| 65 | paint_fallback_enabled: bool = False | ||
| 66 | xml_enabled: bool = True | ||
| 67 | xml_max_distance_m: float = 60.0 | ||
| 68 | xml_min_agreement: float = 0.6 | ||
| 69 | xml_station_step_m: float = 10.0 | ||
| 70 | xml_station_tolerance_m: float = 2.0 | ||
| 71 | xml_vote_slack_m: float = 3.0 | ||
| 72 | 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 = 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) | ||
| 73 | 33 | ||
| 74 | class 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", "") | ||
| 76 | 56 | ||
| 77 | near_radius_m: float = 45.0 | 57 | # Driven-lane band gate (AI3D-339 pass 8, Miro directive). A short |
| 78 | fov_deg: float = 55.0 | 58 | # candidate standing in the lane the survey vehicle drove is a vehicle, not |
| 79 | splat: int = 2 | 59 | # road furniture. The pass-8 census killed the wider "between the two edge |
| 80 | image_width: int = 1100 | 60 | # lines of the carriageway" form โ run4 is absent on A1 and a featureless |
| 81 | image_height: int = 750 | 61 | # 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 | ) | ||
| 83 | 158 | ||
| 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) | ||
| 84 | 168 | ||
| 85 | class 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 | ) | ||
| 87 | 194 | ||
| 88 | depth_tol_m: float = 0.5 | 195 | # Legacy ``road_context`` copies of the ``edge_line`` keys of the same name. |
| 89 | line_samples: int = 20 | 196 | # The detector reads the ``edge_line`` fields above; these are declared so |
| 90 | occluded_alpha: int = 90 | 197 | # the packaged JSON keeps validating, and so an override file written |
| 91 | solid_width_px: int = 3 | 198 | # against the old section spelling is still accepted rather than rejected. |
| 92 | halo_width_px: int = 6 | 199 | roadctx_xml_enabled: bool = section_field("road_context.xml_enabled", True) |
| 93 | base_marker_radius_px: int = 6 | 200 | roadctx_xml_min_agreement: float = section_field("road_context.xml_min_agreement", 0.6) |
| 94 | back_distance_m: float = 22.0 | 201 | roadctx_xml_vote_slack_m: float = section_field("road_context.xml_vote_slack_m", 3.0) |
| 95 | back_height_m: float = 4.0 | 202 | roadctx_xml_max_distance_m: float = section_field("road_context.xml_max_distance_m", 60.0) |
| 96 | context_distance_m: float = 40.0 | 203 | roadctx_xml_station_tolerance_m: float = section_field( |
| 97 | context_height_m: float = 6.0 | 204 | "road_context.xml_station_tolerance_m", 2.0 |
| 98 | share_radius_m: float = 15.0 | 205 | ) |
| 99 | coverage_tol_m: float = 0.5 | 206 | 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 | ) |
| 1 | """Opt-in post-classification stages. | ||
| 2 | |||
| 3 | The rail-relative half-post pass, the reject-rescue second look and | ||
| 4 | the ML verifier. | ||
| 5 | |||
| 6 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 7 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 8 | loaded from; ``_config`` recombines the slices into the model. | ||
| 9 | """ | ||
| 10 | |||
| 11 | from iolabs.common import config_loader | ||
| 12 | |||
| 13 | from ._model_base import section_field | ||
| 14 | |||
| 15 | |||
| 16 | class 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 |
| 1 | """Tree, vegetation and ground-filter config sections. | ||
| 2 | |||
| 3 | One slice of the nested :class:`VerticalSignsConfig` model tree; the sections | ||
| 4 | mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines | ||
| 5 | the slices. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from iolabs.common import config_loader | ||
| 9 | |||
| 10 | |||
| 11 | class 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 | |||
| 20 | class 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 | |||
| 38 | class 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 | |||
| 118 | class 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 | |||
| 129 | class 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 | |||
| 143 | class 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 | |||
| 163 | class 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 |
| 1 | """Experimental tree detection and TCS ground filtering of the DEM input. | ||
| 2 | |||
| 3 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 4 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 5 | loaded from; ``_config`` recombines the slices into the model. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from typing import Literal, TypeAlias | ||
| 9 | |||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | from ._model_base import section_field | ||
| 13 | |||
| 14 | #: Ground-filter mechanism, spelled exactly as ``iolabs_point_cloud_tablecloth`` types it. | ||
| 15 | TcsMechanism: TypeAlias = Literal["none", "smrf_numpy", "csf_cloth"] | ||
| 16 | |||
| 17 | |||
| 18 | class 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 |
| 1 | """Per-point tree instance splitting of merged canopy blobs. | ||
| 2 | |||
| 3 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 4 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 5 | loaded from; ``_config`` recombines the slices into the model. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from iolabs.common import config_loader | ||
| 9 | |||
| 10 | from ._model_base import section_field | ||
| 11 | |||
| 12 | |||
| 13 | class 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 |
| 1 | """Tree rejection, chromaticity vegetation reject and radius fitting. | ||
| 2 | |||
| 3 | Also core compactness and the crown-circle overlay knobs. | ||
| 4 | |||
| 5 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 6 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 7 | loaded from; ``_config`` recombines the slices into the model. | ||
| 8 | """ | ||
| 9 | |||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | from ._model_base import section_field | ||
| 13 | |||
| 14 | |||
| 15 | class 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 |
| 1 | """Detector configuration. | 1 | """Public import path for the detector configuration. |
| 2 | 2 | ||
| 3 | The 379-field :class:`DetectorConfig` and its ``from_mapping`` flattener are | 3 | The schema, the loading entry points and the error class live in `_config`; |
| 4 | split by section across the ``_config_<section>`` modules; this module | 4 | this module re-exports them so the documented ``from |
| 5 | recombines them and re-exports every piece, so ``from .config import X`` | 5 | iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig`` keeps |
| 6 | keeps working for every name that used to live here. | 6 | working. The field declarations themselves are split across the |
| 7 | 7 | ``_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 | ||
| 10 | by the :class:`VerticalSignsConfig` model tree in ``_config_model``. | ||
| 11 | """ | 8 | """ |
| 12 | 9 | ||
| 13 | from pathlib import Path | 10 | from ._config import ( |
| 14 | from typing import Any | 11 | DetectorConfig, |
| 15 | 12 | VerticalSignsConfigError, | |
| 16 | from ._config import load_verticalsigns_config | 13 | build_verticalsigns_config, |
| 17 | from ._config_conic import ConicFields, conic_kwargs | 14 | load_default_config, |
| 18 | from ._config_corridor import CorridorFields, corridor_kwargs | 15 | load_verticalsigns_config, |
| 19 | from ._config_devices import DeviceFields, device_kwargs | 16 | normalize_verticalsigns_config, |
| 20 | from ._config_evidence import EvidenceFields, evidence_kwargs | 17 | ) |
| 21 | from ._config_grid import GridFields, grid_kwargs | ||
| 22 | from ._config_perspective import PerspectiveFields, perspective_kwargs | ||
| 23 | from ._config_roadcontext import RoadContextFields, road_context_kwargs | ||
| 24 | from ._config_stages import StageFields, stage_kwargs | ||
| 25 | from ._config_treedetect import TreeDetectionFields, tree_detection_kwargs | ||
| 26 | from ._config_treeinstance import TreeInstanceFields, tree_instance_kwargs | ||
| 27 | from ._config_vegetation import VegetationFields, vegetation_kwargs | ||
| 28 | 18 | ||
| 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 | |||
| 56 | class 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)) |
| 2 | 2 | ||
| 3 | from importlib.metadata import PackageNotFoundError, version | 3 | from importlib.metadata import PackageNotFoundError, version |
| 4 | 4 | ||
| 5 | from ._config import ( | 5 | from ._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 | ) |
| 10 | from .config import DetectorConfig | ||
| 11 | 13 | ||
| 12 | try: | 14 | try: |
| 13 | __version__ = version("iolabs-point-cloud-detection-verticalsigns") | 15 | __version__ = version("iolabs-point-cloud-detection-verticalsigns") |
| 14 | except PackageNotFoundError: # pragma: no cover - source tree without an install | 16 | except PackageNotFoundError: # pragma: no cover - source tree without an install |
| 16 | 18 | ||
| 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 | ] |
| 4 | from collections.abc import Callable | 4 | from collections.abc import Callable |
| 5 | from typing import Any | 5 | from typing import Any |
| 6 | 6 | ||
| 7 | import pytest | 7 | import pytest |
| 8 | from iolabs.common import config_loader | ||
| 9 | 8 | ||
| 9 | from iolabs_point_cloud_detection_verticalsigns import _model_base | ||
| 10 | from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig | ||
| 10 | 11 | ||
| 11 | def _section_values(model: type[config_loader.ConfigModel]) -> dict[str, Any]: | 12 | |
| 12 | """Return one valid non-default value per field of *model*.""" | 13 | def _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.annotation | 20 | 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.default | 23 | values[key] = not field.default |
| 18 | elif annotation is int: | 24 | elif annotation is int: |
| 19 | values[name] = int(field.default) + 1 | 25 | 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.5 | 33 | values[key] = 0.5 |
| 26 | return values | 34 | return values |
| 27 | 35 | ||
| 28 | 36 | ||
| 29 | @pytest.fixture | 37 | @pytest.fixture |
| 30 | def section_values() -> Callable[[type[config_loader.ConfigModel]], dict[str, Any]]: | 38 | def 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_values | 40 | return _section_values |
| 13 | 13 | ||
| 14 | import numpy as np | 14 | import numpy as np |
| 15 | import pytest | 15 | import pytest |
| 16 | 16 | ||
| 17 | from iolabs_point_cloud_detection_verticalsigns import _config, _model_tree | 17 | from iolabs_point_cloud_detection_verticalsigns import _config |
| 18 | from iolabs_point_cloud_detection_verticalsigns.classify import ( | 18 | from 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, |
| 303 | 303 | ||
| 304 | 304 | ||
| 305 | def test_config_accepts_every_documented_key(tmp_path, section_values) -> None: | 305 | def 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"] = True | 308 | 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"] |
| 1 | """Schema guards for the flat :class:`DetectorConfig` and the packaged JSON. | ||
| 2 | |||
| 3 | The detector reads a FLAT config while the packaged | ||
| 4 | ``verticalsigns.default.json`` is grouped into sections, and each flat field | ||
| 5 | declares the section and key it is loaded from (``_model_base.section_field``). | ||
| 6 | Three 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 | |||
| 14 | import json | ||
| 15 | from pathlib import Path | ||
| 16 | |||
| 17 | import pydantic | ||
| 18 | import pytest | ||
| 19 | from iolabs.common import config_loader | ||
| 20 | |||
| 21 | from iolabs_point_cloud_detection_verticalsigns import _config, _model_base | ||
| 22 | from 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 | |||
| 31 | PACKAGED_JSON = ( | ||
| 32 | Path(__file__).resolve().parents[1] | ||
| 33 | / "src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json" | ||
| 34 | ) | ||
| 35 | |||
| 36 | |||
| 37 | def _packaged() -> dict: | ||
| 38 | return json.loads(PACKAGED_JSON.read_text(encoding="utf-8")) | ||
| 39 | |||
| 40 | |||
| 41 | def _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 | |||
| 50 | def _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 | |||
| 66 | def _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 | |||
| 85 | def test_model_defaults_match_packaged_json() -> None: | ||
| 86 | assert DetectorConfig().to_document() == _packaged() | ||
| 87 | |||
| 88 | |||
| 89 | def 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 | |||
| 98 | def test_error_class_is_config_error() -> None: | ||
| 99 | assert issubclass(VerticalSignsConfigError, config_loader.ConfigError) | ||
| 100 | assert issubclass(VerticalSignsConfigError, ValueError) | ||
| 101 | |||
| 102 | |||
| 103 | def 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 | |||
| 108 | def 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 | |||
| 113 | def 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 | |||
| 120 | def 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 | |||
| 135 | def 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 | |||
| 144 | def 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 | |||
| 150 | def 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 | |||
| 157 | def 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 | |||
| 162 | def 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 | |||
| 169 | def 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 | |||
| 175 | def 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 | |||
| 181 | def 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 | |||
| 192 | def 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 |
| 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 | ||
| 6 | things 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 | |||
| 13 | import json | ||
| 14 | import re | ||
| 15 | from pathlib import Path | ||
| 16 | |||
| 17 | from iolabs.common import config_loader | ||
| 18 | |||
| 19 | from iolabs_point_cloud_detection_verticalsigns import _config_model | ||
| 20 | from iolabs_point_cloud_detection_verticalsigns._config import load_default_config | ||
| 21 | from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig | ||
| 22 | |||
| 23 | CONFIG_PY = ( | ||
| 24 | Path(__file__).resolve().parents[1] | ||
| 25 | / "src/iolabs_point_cloud_detection_verticalsigns/config.py" | ||
| 26 | ) | ||
| 27 | |||
| 28 | |||
| 29 | def _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 | |||
| 42 | def _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 | |||
| 68 | def 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 | |||
| 75 | def test_absent_sections_fall_back_to_the_model_defaults() -> None: | ||
| 76 | assert DetectorConfig.from_mapping({}) == DetectorConfig() | ||
| 77 | |||
| 78 | |||
| 79 | def 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 | |||
| 86 | def 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 | |||
| 97 | def 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 | |||
| 103 | def 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 | |||
| 115 | def 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 | |||
| 130 | def 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 |
| 17 | 17 | ||
| 18 | import numpy as np | 18 | import numpy as np |
| 19 | import pytest | 19 | import pytest |
| 20 | 20 | ||
| 21 | from iolabs_point_cloud_detection_verticalsigns import _config, _model_tree | 21 | from iolabs_point_cloud_detection_verticalsigns import _config |
| 22 | from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig | 22 | from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig |
| 23 | from iolabs_point_cloud_detection_verticalsigns.tree_instances import ( | 23 | from iolabs_point_cloud_detection_verticalsigns.tree_instances import ( |
| 24 | ABSTAIN_ASSIGNED, | 24 | ABSTAIN_ASSIGNED, |
| 25 | ABSTAIN_HEDGE, | 25 | ABSTAIN_HEDGE, |
| 1070 | _config.load_verticalsigns_config(path) | 1070 | _config.load_verticalsigns_config(path) |
| 1071 | 1071 | ||
| 1072 | 1072 | ||
| 1073 | def test_config_accepts_every_documented_key(tmp_path, section_values) -> None: | 1073 | def 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"] = True | 1075 | 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"] |
| 95 | synthetic unit tests (a fake pole, a fake wall, a fake tree โ correct | 95 | synthetic unit tests (a fake pole, a fake wall, a fake tree โ correct |
| 96 | classification; world_to_pixel round-trip). Config thresholds live in | 96 | classification; world_to_pixel round-trip). Config thresholds live in |
| 97 | `verticalsigns.default.json`; `_config.load_verticalsigns_config` deep-merges | 97 | `verticalsigns.default.json`; `_config.load_verticalsigns_config` deep-merges |
| 98 | a user `--config` JSON over the defaults and validates the result against the | 98 | 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. Logging | 100 | 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 -m | 103 | (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. |
| 18 | 18 | ||
| 19 | `python -m iolabs_point_cloud_detection_verticalsigns.detect ...` works too, as | 19 | `python -m iolabs_point_cloud_detection_verticalsigns.detect ...` works too, as |
| 20 | does `uv run verticalsigns-views ...` for the per-detection close-up renders. | 20 | does `uv run verticalsigns-views ...` for the per-detection close-up renders. |
| 21 | 21 | ||
| 22 | Thresholds live in the packaged `verticalsigns.default.json`, one nested | 22 | ## Configuration |
| 23 | section per detector stage (`ground`, `occupancy`, `candidates`, `clustering`, | 23 | |
| 24 | `classification`, `radius`, `corridor`, `context`, `delineator`, `sign_post`, | 24 | Defaults live in `src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json`, |
| 25 | `panel`, `gantry`, `repetitive_row`, `field_stake`, `marker_extract`, | 25 | one 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`). Pass | 28 | `marker_extract`, `rail_halfpost`, `reject_rescue`, `road_context`, |
| 29 | `--config` to deep-merge a partial JSON over those defaults; unknown keys and | 29 | `edge_line`, `tree`, `tree_detection`, `tree_instance`, `chroma_vegetation`, |
| 30 | bad values are rejected. | 30 | `tcs_ground`, `conic_gate`, `conifer_rule`, `vehicle`, `views`, `perspective`). |
| 31 | 31 | ||
| 32 | The schema of that JSON is the `VerticalSignsConfig` pydantic model tree | 32 | The schema is `DetectorConfig` in `_config` (a `config_loader.ConfigModel`), |
| 33 | (`_config_model.py` plus the `_model_<slice>.py` sections, built on | 33 | re-exported from `...verticalsigns.config` and `...verticalsigns`. The model is |
| 34 | `iolabs.common.config_loader.ConfigModel`): one nested model per JSON section, | 34 | FLAT โ the detector modules read `config.ground_cell_m` โ while the JSON is |
| 35 | one field per key, and the two sides must agree key for key (guarded by | 35 | sectioned, 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 its | 36 | where it is declared (`ground_cell_m: float = section_field("ground.cell_m", |
| 37 | section model and the same default to `verticalsigns.default.json`** โ plus, | 37 | 0.75)`, in the `_model_<topic>.py` slices). Unknown sections and keys are |
| 38 | if a detector module reads it, the flat field and `*_kwargs` line below. | 38 | rejected. **To add a config key: add the field (with its type, default and any |
| 39 | 39 | `Field` range) to the model and the same key with the same default to the JSON | |
| 40 | The 379-field `DetectorConfig` is the FLAT view the detector modules read | 40 | โ nothing else.** |
| 41 | (`config.ground_cell_m`): it is declared across the `_config_<section>` modules | 41 | |
| 42 | and recombined in `config.py`, which re-exports every name โ import from | 42 | `load_verticalsigns_config`, `load_default_config`, `build_verticalsigns_config` |
| 43 | `...verticalsigns.config` exactly as before. A new key that the detector reads | 43 | and `normalize_verticalsigns_config` return a plain nested `dict`; |
| 44 | needs its flat field here and the `*_kwargs` line that maps the section key | 44 | `DetectorConfig.load` / `.from_mapping` return the frozen model. Runtime |
| 45 | onto it; a key only consumed from the nested document (e.g. `views`) does not. | 45 | overrides come from `--config <partial.json>`, deep-merged over the packaged |
| 46 | defaults; never a repo-local full copy of the JSON. | ||
| 46 | 47 | ||
| 47 | ## QC rendering is an optional extra | 48 | ## QC rendering is an optional extra |
| 48 | 49 | ||
| 49 | `verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need | 50 | `verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need |
| 18 | 18 | ||
| 19 | `python -m iolabs_point_cloud_detection_verticalsigns.detect ...` works too, as | 19 | `python -m iolabs_point_cloud_detection_verticalsigns.detect ...` works too, as |
| 20 | does `uv run verticalsigns-views ...` for the per-detection close-up renders. | 20 | does `uv run verticalsigns-views ...` for the per-detection close-up renders. |
| 21 | 21 | ||
| 22 | Thresholds live in the packaged `verticalsigns.default.json`, one nested | 22 | ## Configuration |
| 23 | section per detector stage (`ground`, `occupancy`, `candidates`, `clustering`, | 23 | |
| 24 | `classification`, `radius`, `corridor`, `context`, `delineator`, `sign_post`, | 24 | Defaults live in `src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json`, |
| 25 | `panel`, `gantry`, `repetitive_row`, `field_stake`, `marker_extract`, | 25 | one 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`). Pass | 28 | `marker_extract`, `rail_halfpost`, `reject_rescue`, `road_context`, |
| 29 | `--config` to deep-merge a partial JSON over those defaults; unknown keys and | 29 | `edge_line`, `tree`, `tree_detection`, `tree_instance`, `chroma_vegetation`, |
| 30 | bad values are rejected. | 30 | `tcs_ground`, `conic_gate`, `conifer_rule`, `vehicle`, `views`, `perspective`). |
| 31 | 31 | ||
| 32 | The schema of that JSON is the `VerticalSignsConfig` pydantic model tree | 32 | The schema is `DetectorConfig` in `_config` (a `config_loader.ConfigModel`), |
| 33 | (`_config_model.py` plus the `_model_<slice>.py` sections, built on | 33 | re-exported from `...verticalsigns.config` and `...verticalsigns`. The model is |
| 34 | `iolabs.common.config_loader.ConfigModel`): one nested model per JSON section, | 34 | FLAT โ the detector modules read `config.ground_cell_m` โ while the JSON is |
| 35 | one field per key, and the two sides must agree key for key (guarded by | 35 | sectioned, 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 its | 36 | where it is declared (`ground_cell_m: float = section_field("ground.cell_m", |
| 37 | section model and the same default to `verticalsigns.default.json`** โ plus, | 37 | 0.75)`, in the `_model_<topic>.py` slices). Unknown sections and keys are |
| 38 | if a detector module reads it, the flat field and `*_kwargs` line below. | 38 | rejected. **To add a config key: add the field (with its type, default and any |
| 39 | 39 | `Field` range) to the model and the same key with the same default to the JSON | |
| 40 | The 379-field `DetectorConfig` is the FLAT view the detector modules read | 40 | โ nothing else.** |
| 41 | (`config.ground_cell_m`): it is declared across the `_config_<section>` modules | 41 | |
| 42 | and recombined in `config.py`, which re-exports every name โ import from | 42 | `load_verticalsigns_config`, `load_default_config`, `build_verticalsigns_config` |
| 43 | `...verticalsigns.config` exactly as before. A new key that the detector reads | 43 | and `normalize_verticalsigns_config` return a plain nested `dict`; |
| 44 | needs its flat field here and the `*_kwargs` line that maps the section key | 44 | `DetectorConfig.load` / `.from_mapping` return the frozen model. Runtime |
| 45 | onto it; a key only consumed from the nested document (e.g. `views`) does not. | 45 | overrides come from `--config <partial.json>`, deep-merged over the packaged |
| 46 | defaults; never a repo-local full copy of the JSON. | ||
| 46 | 47 | ||
| 47 | ## QC rendering is an optional extra | 48 | ## QC rendering is an optional extra |
| 48 | 49 | ||
| 49 | `verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need | 50 | `verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need |
| 2 | 2 | ||
| 3 | from importlib.metadata import PackageNotFoundError, version | 3 | from importlib.metadata import PackageNotFoundError, version |
| 4 | 4 | ||
| 5 | from ._config import ( | 5 | from ._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 | ) |
| 10 | from .config import DetectorConfig | ||
| 11 | 13 | ||
| 12 | try: | 14 | try: |
| 13 | __version__ = version("iolabs-point-cloud-detection-verticalsigns") | 15 | __version__ = version("iolabs-point-cloud-detection-verticalsigns") |
| 14 | except PackageNotFoundError: # pragma: no cover - source tree without an install | 16 | except PackageNotFoundError: # pragma: no cover - source tree without an install |
| 16 | 18 | ||
| 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 | ] |
| 1 | """Packaged-default configuration loading and validation for the detector. | 1 | """Configuration of the vertical-sign detector. |
| 2 | 2 | ||
| 3 | The canonical configuration lives in ``verticalsigns.default.json`` packaged | 3 | The schema is `DetectorConfig` (a `config_loader.ConfigModel`), mirroring |
| 4 | next to this module, and its schema is the :class:`VerticalSignsConfig` pydantic | 4 | `verticalsigns.default.json` key for key: the JSON is grouped into sections |
| 5 | model tree in ``_config_model``. ``load_verticalsigns_config`` returns a | 5 | while the model is flat (``config.ground_cell_m``), and every field names the |
| 6 | validated plain dict that deep-merges an optional user JSON over those defaults, | 6 | section and key it is loaded from via ``_model_base.section_field``. |
| 7 | rejecting unknown keys (per section) and bad values with a clear error. The | 7 | |
| 8 | internal :class:`DetectorConfig` model is built from that dict via | 8 | Adding 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 |
| 10 | 10 | `verticalsigns.default.json`; nothing else. Unknown keys are rejected. | |
| 11 | Loading, deep-merge and validation are provided by | 11 | |
| 12 | ``iolabs.common.config_loader``; the schema and entrypoints stay here. | 12 | ``load_verticalsigns_config`` / ``build_verticalsigns_config`` return the |
| 13 | config as a plain nested ``dict``; ``DetectorConfig.load`` returns the frozen | ||
| 14 | model the detector modules read. | ||
| 13 | """ | 15 | """ |
| 14 | 16 | ||
| 15 | from __future__ import annotations | 17 | from __future__ import annotations |
| 16 | 18 | ||
| 17 | import json | ||
| 18 | import logging | 19 | import logging |
| 20 | from collections.abc import Mapping | ||
| 19 | from pathlib import Path | 21 | from pathlib import Path |
| 20 | from typing import Any | 22 | from typing import Any |
| 21 | 23 | ||
| 24 | import pydantic | ||
| 22 | from iolabs.common import config_loader | 25 | from iolabs.common import config_loader |
| 23 | 26 | ||
| 24 | from ._config_model import VerticalSignsConfig | 27 | from . import _model_base |
| 28 | from ._model_conic import VerticalSignsConicFields | ||
| 29 | from ._model_corridor import VerticalSignsCorridorFields | ||
| 30 | from ._model_devices import VerticalSignsDeviceFields | ||
| 31 | from ._model_evidence import VerticalSignsEvidenceFields | ||
| 32 | from ._model_grid import VerticalSignsGridFields | ||
| 33 | from ._model_perspective import VerticalSignsPerspectiveFields, VerticalSignsViewsFields | ||
| 34 | from ._model_road import VerticalSignsRoadContextFields | ||
| 35 | from ._model_stages import VerticalSignsStageFields | ||
| 36 | from ._model_treedetect import VerticalSignsTreeDetectionFields | ||
| 37 | from ._model_treeinstance import VerticalSignsTreeInstanceFields | ||
| 38 | from ._model_vegetation import VerticalSignsVegetationFields | ||
| 25 | 39 | ||
| 26 | logger = logging.getLogger(__name__) | 40 | logger = logging.getLogger(__name__) |
| 27 | 41 | ||
| 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" |
| 31 | 45 | ||
| 46 | _SECTION_MAPS: dict[type, dict[str, dict[str, str]]] = {} | ||
| 47 | |||
| 32 | 48 | ||
| 33 | class VerticalSignsConfigError(config_loader.ConfigError): | 49 | class 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.""" |
| 35 | 51 | ||
| 36 | 52 | ||
| 37 | def load_default_config() -> dict[str, Any]: | 53 | class 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 | |||
| 215 | def _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 | |||
| 234 | def _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 | |||
| 269 | def 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 | |||
| 286 | def 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() | ||
| 40 | 311 | ||
| 41 | 312 | ||
| 42 | def load_verticalsigns_config(config_path: str | Path | None = None) -> dict[str, Any]: | 313 | def 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. |
| 53 | Raises: | 324 | Raises: |
| 54 | VerticalSignsConfigError: The user JSON is malformed, or the merged | 325 | 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 None | 328 | return build_verticalsigns_config(config_path=config_path) |
| 58 | config = config_loader.load_config( | 329 | |
| 59 | VerticalSignsConfig, | 330 | |
| 60 | package=_PACKAGE_NAME, | 331 | def 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 | |||
| 69 | def _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 |
| 1 | """The colour-free conic gate and the conifer rule that rides on it. | ||
| 2 | |||
| 3 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 4 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 5 | re-exports both names defined here. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from typing import Any | ||
| 9 | |||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | |||
| 13 | class 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 | |||
| 111 | def 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 |
| 1 | """Road corridor rasterization and on-carriageway rejection. | ||
| 2 | |||
| 3 | Also plate planarity, the bright-panel class and the free-space ring. | ||
| 4 | |||
| 5 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 6 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 7 | re-exports both names defined here. | ||
| 8 | """ | ||
| 9 | |||
| 10 | from typing import Any | ||
| 11 | |||
| 12 | from iolabs.common import config_loader | ||
| 13 | |||
| 14 | |||
| 15 | class 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 | |||
| 114 | def 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 |
| 1 | """Per-device thresholds for delineators, sign posts and gantries. | ||
| 2 | |||
| 3 | Also isolated-floating-pole rejection and duplicate suppression. | ||
| 4 | |||
| 5 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 6 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 7 | re-exports both names defined here. | ||
| 8 | """ | ||
| 9 | |||
| 10 | from typing import Any | ||
| 11 | |||
| 12 | from iolabs.common import config_loader | ||
| 13 | |||
| 14 | |||
| 15 | class 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 | |||
| 145 | def 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 |
| 1 | """Ground, occupancy grid, candidate band and clustering thresholds. | ||
| 2 | |||
| 3 | Also the first classification gates and vehicle rejection. | ||
| 4 | |||
| 5 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 6 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 7 | re-exports both names defined here. | ||
| 8 | """ | ||
| 9 | |||
| 10 | from typing import Any | ||
| 11 | |||
| 12 | from iolabs.common import config_loader | ||
| 13 | |||
| 14 | |||
| 15 | class 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 | |||
| 74 | def 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 |
| 1 | """The nested pydantic config model for the vertical-sign detector. | ||
| 2 | |||
| 3 | ``VerticalSignsConfig`` mirrors ``verticalsigns.default.json`` section for | ||
| 4 | section and key for key: it is the single source of truth for which config | ||
| 5 | keys exist and what type each one has. Adding a key means adding a field to | ||
| 6 | the matching section model and the same default to the packaged JSON; the two | ||
| 7 | sides must stay in lockstep, and ``tests/test_config_split.py`` fails if they | ||
| 8 | drift. A key the detector modules read also needs its flat ``DetectorConfig`` | ||
| 9 | field and the ``*_kwargs`` line that maps it (see ``config.py``). | ||
| 10 | """ | ||
| 11 | |||
| 12 | from iolabs.common import config_loader | ||
| 13 | |||
| 14 | from . import _model_devices, _model_grid, _model_road, _model_tree | ||
| 15 | |||
| 16 | |||
| 17 | class 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 |
| 1 | """Perspective-projection QC overlay cameras and coverage tolerances. | ||
| 2 | |||
| 3 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 4 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 5 | re-exports both names defined here. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from typing import Any | ||
| 9 | |||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | |||
| 13 | class 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 | |||
| 46 | def 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 |
| 1 | """Road-context gate, driven-lane band and repetitive-row rejection. | ||
| 2 | |||
| 3 | Also field-stake rows and embedded-marker extraction. | ||
| 4 | |||
| 5 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 6 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 7 | re-exports both names defined here. | ||
| 8 | """ | ||
| 9 | |||
| 10 | from typing import Any | ||
| 11 | |||
| 12 | from iolabs.common import config_loader | ||
| 13 | |||
| 14 | |||
| 15 | class 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 | |||
| 176 | def 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 |
| 1 | """Opt-in post-classification stages. | ||
| 2 | |||
| 3 | The rail-relative half-post pass, the reject-rescue second look and | ||
| 4 | the ML verifier. | ||
| 5 | |||
| 6 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 7 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 8 | re-exports both names defined here. | ||
| 9 | """ | ||
| 10 | |||
| 11 | from typing import Any | ||
| 12 | |||
| 13 | from iolabs.common import config_loader | ||
| 14 | |||
| 15 | |||
| 16 | class 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 | |||
| 125 | def 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 |
| 1 | """Experimental tree detection and TCS ground filtering of the DEM input. | ||
| 2 | |||
| 3 | One slice of the flat ``DetectorConfig``, moved out of | ||
| 4 | ``config.py`` verbatim. ``config.py`` recombines the slices and | ||
| 5 | re-exports both names defined here. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from typing import Any | ||
| 9 | |||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | |||
| 13 | class 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 | |||
| 81 | def 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 |
| 1 | """Field-declaration helper shared by the ``_model_<topic>`` config slices. | ||
| 2 | |||
| 3 | The detector reads a FLAT config (``config.ground_cell_m``) while the packaged | ||
| 4 | ``verticalsigns.default.json`` โ and every user override file โ is grouped into | ||
| 5 | sections (``{"ground": {"cell_m": 0.75}}``). :func:`section_field` is what joins | ||
| 6 | the two: each flat field declares the JSON section and key it comes from right | ||
| 7 | where it declares its type and default, so a new config key costs exactly two | ||
| 8 | edits (the field here, the same key in the JSON) and no separate mapping table. | ||
| 9 | |||
| 10 | :class:`iolabs_point_cloud_detection_verticalsigns._config.DetectorConfig` | ||
| 11 | walks that metadata to translate a nested document into flat keyword arguments | ||
| 12 | (``DetectorConfig.from_mapping``) and back (``DetectorConfig.to_document``). | ||
| 13 | """ | ||
| 14 | |||
| 15 | from __future__ import annotations | ||
| 16 | |||
| 17 | from typing import Any | ||
| 18 | |||
| 19 | import pydantic | ||
| 20 | |||
| 21 | _SECTION_METADATA_KEY = "config_section_path" | ||
| 22 | |||
| 23 | |||
| 24 | def 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 | |||
| 51 | def 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 |
| 1 | """The colour-free conic gate and the conifer rule that rides on it. | ||
| 2 | |||
| 3 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 4 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 5 | loaded from; ``_config`` recombines the slices into the model. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from iolabs.common import config_loader | ||
| 9 | |||
| 10 | from ._model_base import section_field | ||
| 11 | |||
| 12 | |||
| 13 | class 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 |
| 1 | """Road corridor rasterization and on-carriageway rejection. | ||
| 2 | |||
| 3 | Also plate planarity, the bright-panel class and the free-space ring. | ||
| 4 | |||
| 5 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 6 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 7 | loaded from; ``_config`` recombines the slices into the model. | ||
| 8 | """ | ||
| 9 | |||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | from ._model_base import section_field | ||
| 13 | |||
| 14 | |||
| 15 | class 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 |
| 1 | """Per-device acceptance gates and the two probe stages. | 1 | """Per-device thresholds for delineators, sign posts and gantries. |
| 2 | 2 | ||
| 3 | One slice of the nested :class:`VerticalSignsConfig` model tree; the sections | 3 | Also isolated-floating-pole rejection and duplicate suppression. |
| 4 | mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines | 4 | |
| 5 | the slices. | 5 | One slice of the flat ``DetectorConfig``. Every field declares, via |
| 6 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 7 | loaded from; ``_config`` recombines the slices into the model. | ||
| 6 | """ | 8 | """ |
| 7 | 9 | ||
| 8 | from iolabs.common import config_loader | 10 | from iolabs.common import config_loader |
| 9 | 11 | ||
| 10 | 12 | from ._model_base import section_field | |
| 11 | class DelineatorConfig(config_loader.ConfigModel): | 13 | |
| 12 | """Delineator (Leitpfosten) acceptance gates.""" | 14 | |
| 13 | 15 | class VerticalSignsDeviceFields(config_loader.ConfigModel): | |
| 14 | h_min_m: float = 0.7 | 16 | """Per-device thresholds for delineators, sign posts and gantries. |
| 15 | h_max_m: float = 1.5 | 17 | |
| 16 | max_footprint_m: float = 0.45 | 18 | Also isolated-floating-pole rejection and duplicate suppression. |
| 17 | relaxed_footprint_m: float = 0.85 | 19 | |
| 18 | relaxed_min_verticality: float = 0.85 | 20 | Metres unless stated otherwise. |
| 19 | relaxed_max_ring_fill_ratio: float = 1.0 | 21 | """ |
| 20 | relaxed_min_hi_intensity_fraction: float = 0.15 | 22 | |
| 21 | min_hi_intensity_fraction: float = 0.08 | 23 | # Delineator (Leitpfosten). The height ceiling (1.5 m) and footprint cap |
| 22 | min_points: int = 300 | 24 | # (0.45 m) admit taller guide posts and the mild along-track smear that gore |
| 23 | 25 | # posts pick up in MLS (segment 131's junction posts read 0.42 m major, | |
| 24 | 26 | # h 1.2-1.5); real Leitpfosten cores stay ~0.12 m so the cap change does not | |
| 25 | class 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) |
| 27 | 29 | delineator_h_max_m: float = section_field("delineator.h_max_m", 1.5) | |
| 28 | max_len_minor_m: float = 0.8 | 30 | delineator_max_footprint_m: float = section_field("delineator.max_footprint_m", 0.45) |
| 29 | h_min_m: float = 1.5 | 31 | # Relaxed footprint band for a delineator whose along-track MLS smear at a |
| 30 | h_max_m: float = 6.0 | 32 | # junction/gore pushes its major extent past the tight 0.45 m cap (segment |
| 31 | min_continuity: float = 0.6 | 33 | # 134's splitter-island posts read 0.47-0.63 m major). Only admitted when the |
| 32 | plate_hi_intensity_fraction: float = 0.4 | 34 | # cluster is strongly vertical (a genuine post), so a flat bright road-marking |
| 33 | plate_hi_intensity_fraction_weak: float = 0.3 | 35 | # fragment (verticality ~0.1) can never sneak in through the wider cap. Purely |
| 34 | plate_upper_surplus_ratio: float = 2.0 | 36 | # additive: clusters at or under delineator_max_footprint_m keep the original |
| 35 | min_upper_half_surplus: float = 0.3 | 37 | # (verticality-free) path, so no existing detection is affected. |
| 36 | plate_min_core_rms_m: float = 0.1 | 38 | # 0.65 -> 0.85 (AI3D-339 pass 3): Abschnitt-1 Leitpfosten merge with verge |
| 37 | max_plate_thickness_m: float = 0.15 | 39 | # 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.5 | 40 | # was the single failing conjunct for 8 adversarially judged-real posts. |
| 39 | bare_post_max_core_rms_m: float = 0.065 | 41 | # At 0.85: A4_5 +3 judged-real delineators / 0 lost; A1 +~18 judged-real vs |
| 40 | bare_post_min_verticality: float = 0.9 | 42 | # +5 judged-veg. Real (0.66-0.83) and FP (0.68-0.85) footprints fully |
| 41 | bare_post_min_points: int = 450 | 43 | # overlap, so no tighter cap separates them โ the veg leak is a texture |
| 42 | 44 | # problem (multi-radius plate regularity, task #14), not a threshold one. | |
| 43 | 45 | delineator_relaxed_footprint_m: float = section_field("delineator.relaxed_footprint_m", 0.85) | |
| 44 | class PanelConfig(config_loader.ConfigModel): | 46 | delineator_relaxed_min_verticality: float = section_field( |
| 45 | """Large panel acceptance gates.""" | 47 | "delineator.relaxed_min_verticality", 0.85 |
| 46 | 48 | ) | |
| 47 | min_hi: float = 0.4 | 49 | # The wider relaxed band admits more smear, so it is guarded harder than the |
| 48 | max_thickness_m: float = 0.2 | 50 | # compact path: the post must stand clear (a near-empty free-space ring, so a |
| 49 | h_min_m: float = 0.9 | 51 | # bright speck embedded in roadside vegetation โ segment 084 โ is rejected) |
| 50 | len_major_min_m: float = 1.5 | 52 | # and be clearly retroreflective (a higher brightness floor than the compact |
| 51 | len_major_max_m: float = 5.0 | 53 | # 0.08, so a modest-brightness on-carriageway edge feature โ segment 096 โ is |
| 52 | 54 | # rejected). Genuine gore/island posts pass both (ring ~0, hi 0.28-0.66). | |
| 53 | 55 | delineator_relaxed_max_ring_fill_ratio: float = section_field( | |
| 54 | class GantryConfig(config_loader.ConfigModel): | 56 | "delineator.relaxed_max_ring_fill_ratio", 1.0 |
| 55 | """Gantry leg and pairing gates.""" | 57 | ) |
| 56 | 58 | delineator_relaxed_min_hi_intensity_fraction: float = section_field( | |
| 57 | h_min_m: float = 4.5 | 59 | "delineator.relaxed_min_hi_intensity_fraction", 0.15 |
| 58 | len_major_m: float = 8.0 | 60 | ) |
| 59 | max_len_minor_m: float = 6.0 | 61 | delineator_min_hi_intensity_fraction: float = section_field( |
| 60 | pair_station_tolerance_m: float = 5.0 | 62 | "delineator.min_hi_intensity_fraction", 0.08 |
| 61 | pair_min_separation_m: float = 3.0 | 63 | ) |
| 62 | overhead_h_min_m: float = 4.5 | 64 | # Real Leitpfosten return a few hundred points; sub-~300 bright specks are |
| 63 | pair_isolation_radius_m: float = 8.0 | 65 | # reflective vegetation/debris (segment 048 FP had ~100; segment 084's bright |
| 64 | 66 | # speck embedded in verge scrub, newly reachable once the corridor keeps | |
| 65 | 67 | # branch roads, had 239). Every genuine delineator across the dataset returns | |
| 66 | class 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) |
| 68 | 70 | ||
| 69 | min_members: int = 4 | 71 | # Sign post / plate |
| 70 | max_spacing_m: float = 5.0 | 72 | sign_post_max_len_minor_m: float = section_field("sign_post.max_len_minor_m", 0.8) |
| 71 | max_perp_spread_m: float = 1.5 | 73 | sign_post_h_min_m: float = section_field("sign_post.h_min_m", 1.5) |
| 72 | max_h_max_range_m: float = 0.7 | 74 | sign_post_h_max_m: float = section_field("sign_post.h_max_m", 6.0) |
| 73 | member_max_len_major_m: float = 2.0 | 75 | sign_post_min_continuity: float = section_field("sign_post.min_continuity", 0.60) |
| 74 | member_max_len_minor_m: float = 0.8 | 76 | # Plate evidence needs strong retroreflectivity: verified real sign plates |
| 75 | 77 | # (segments 006/030/132/134) return an upper-half high-intensity fraction of | |
| 76 | 78 | # 0.44-0.94, while every dull false-positive "sign" (vegetation mounds, | |
| 77 | class 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 |
| 79 | 81 | # panel; the weak path allows a moderately-bright, upper-piled plate. | |
| 80 | row_emit: bool = True | 82 | plate_hi_intensity_fraction: float = section_field( |
| 81 | min_members: int = 4 | 83 | "sign_post.plate_hi_intensity_fraction", 0.40 |
| 82 | min_spacing_m: float = 2.0 | 84 | ) |
| 83 | max_spacing_m: float = 10.0 | 85 | plate_hi_intensity_fraction_weak: float = section_field( |
| 84 | max_spacing_cv: float = 0.35 | 86 | "sign_post.plate_hi_intensity_fraction_weak", 0.30 |
| 85 | 87 | ) | |
| 86 | 88 | # Upper-half point pile-up ratio required as weak-plate evidence and as | |
| 87 | class 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 |
| 89 | 91 | # returns up high (good signs sit at 2.8-4.6, or carry a broad bright core). | |
| 90 | min_len_major_m: float = 6.0 | 92 | sign_plate_upper_surplus_ratio: float = section_field( |
| 91 | bright_h_min_m: float = 1.5 | 93 | "sign_post.plate_upper_surplus_ratio", 2.0 |
| 92 | min_bright_points: int = 400 | 94 | ) |
| 93 | window_m: float = 2.5 | 95 | # A genuine plate sits high on its post, so the upper half must hold at least |
| 94 | min_bright_fraction: float = 0.45 | 96 | # as many returns as ~1/3 of the lower half. Low-lying bright blobs at the |
| 95 | min_h_max_m: float = 1.6 | 97 | # foot of a vehicle/truck (segment 106 FPs at ~0.09) are not plates. |
| 96 | min_vertical_span_m: float = 0.5 | 98 | sign_min_upper_half_surplus: float = section_field("sign_post.min_upper_half_surplus", 0.30) |
| 97 | 99 | # A real sign PLATE spreads returns laterally (broad core) or piles them in | |
| 98 | 100 | # the upper half; brightness alone on a tight thin core is a reflective | |
| 99 | class 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) |
| 101 | 103 | ||
| 102 | band_lat_m: float = 0.8 | 104 | # Bare posts (no plate evidence) must be tall, tight, vertical, and |
| 103 | band_z_hi_m: float = 1.5 | 105 | # well-sampled. 0.065 m tightness rejects tall roadside vegetation (whose |
| 104 | band_z_lo_m: float = 0.15 | 106 | # per-bin core reaches ~0.17 m); real marker posts sit near ~0.04 m. The |
| 105 | cluster_cell_m: float = 0.15 | 107 | # point-count floor rejects small bright reflective specks (~<450 returns). |
| 106 | dedupe_m: float = 1.5 | 108 | # Plate-less posts below gantry-leg height are indistinguishable from tree |
| 107 | enabled: bool = False | 109 | # guards / fence posts by LiDAR geometry alone (confirmed FP in seg 132). |
| 108 | ground_cell_m: float = 2.0 | 110 | bare_post_min_h_max_m: float = section_field("sign_post.bare_post_min_h_max_m", 4.5) |
| 109 | ground_percentile: float = 10.0 | 111 | 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.8 | 112 | bare_post_min_verticality: float = section_field("sign_post.bare_post_min_verticality", 0.90) |
| 111 | h_min_m: float = 0.2 | 113 | bare_post_min_points: int = section_field("sign_post.bare_post_min_points", 450) |
| 112 | max_lateral_m: float = 0.5 | 114 | |
| 113 | max_width_m: float = 0.2 | 115 | # Isolated floating-pole rejection (far-range boundary ghost, defect class 1a). |
| 114 | min_emit_points: int = 8 | 116 | # A "floating" pole_other whose base sits well off the ground (h_min high โ no |
| 115 | min_points: int = 15 | 117 | # ground-connected shaft, just an upper vertical smear) is a range-smear |
| 116 | min_z_extent_m: float = 0.1 | 118 | # 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 = 2 | 120 | # of several such columns clustered together (a genuine gantry-leg / mast group |
| 119 | prime_min_sat: int = 1 | 121 | # โ segments 046, 066, 025). Verified across the full sweep: the only isolated |
| 120 | sample_step_m: float = 0.1 | 122 | # floating poles (no floating-pole neighbour within pole_isolated_radius_m) are |
| 121 | saturation_intensity: float = 55000.0 | 123 | # exactly the 005/015 ghosts; every real gantry-leg pole has >=1 neighbour. |
| 122 | 124 | pole_floating_min_h_min_m: float = section_field( | |
| 123 | 125 | "classification.pole_floating_min_h_min_m", 3.5 | |
| 124 | class 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) |
| 126 | 128 | ||
| 127 | accepted_exclusion_m: float = 2.0 | 129 | # Post-classification duplicate suppression (defect class 4). Two detections |
| 128 | enabled: bool = False | 130 | # within dedup_radius_m XY of each other describe the same physical marker |
| 129 | h_max_m: float = 1.6 | 131 | # (e.g. a striped gore post firing both a delineator and a sign); keep the |
| 130 | h_min_m: float = 0.85 | 132 | # higher-priority type (sign > delineator > sign_post > pole_other > |
| 131 | max_core_rms_m: float = 0.2 | 133 | # gantry_or_gate), breaking ties by point count, and drop the other. |
| 132 | merge_radius_m: float = 1.0 | 134 | dedup_radius_m: float = section_field("classification.dedup_radius_m", 0.8) |
| 133 | min_continuity: float = 0.8 | 135 | |
| 134 | min_decile_fill: float = 0.6 | 136 | # Gantry / gate |
| 135 | min_h_over_width: float = 1.4 | 137 | gantry_h_min_m: float = section_field("gantry.h_min_m", 4.5) |
| 136 | min_points: int = 30 | 138 | gantry_len_major_m: float = section_field("gantry.len_major_m", 8.0) |
| 137 | min_records: int = 2 | 139 | # A road-spanning overhead beam is thin; a tilted reflective truck-trailer |
| 138 | min_roadctx_sat: int = 17 | 140 | # slab (segment 106) is broad (len_minor ~9.8 m). Cap the single-cluster |
| 139 | min_verticality: float = 0.9 | 141 | # overhead_span footprint minor extent (real gantry cluster ~4.75 m). |
| 140 | per_segment_cap: int = 0 | 142 | 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) |
| 1 | """Evidence-level thresholds: sentinels, vetoes and reference percentiles. | ||
| 2 | |||
| 3 | Covers the verticality sentinel, tier-2 robust extent statistics, | ||
| 4 | retroreflectivity references, the single-record transient and | ||
| 5 | vegetation-texture vetoes, the delineator lattice and tree emission. | ||
| 6 | |||
| 7 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 8 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 9 | loaded from; ``_config`` recombines the slices into the model. | ||
| 10 | """ | ||
| 11 | |||
| 12 | from iolabs.common import config_loader | ||
| 13 | |||
| 14 | from ._model_base import section_field | ||
| 15 | |||
| 16 | |||
| 17 | class 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 |
| 1 | """Grid, candidate, classification, radius and corridor config sections. | 1 | """Ground, occupancy grid, candidate band and clustering thresholds. |
| 2 | 2 | ||
| 3 | One slice of the nested :class:`VerticalSignsConfig` model tree; the sections | 3 | Also the first classification gates and vehicle rejection. |
| 4 | mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines | 4 | |
| 5 | the slices. | 5 | One slice of the flat ``DetectorConfig``. Every field declares, via |
| 6 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 7 | loaded from; ``_config`` recombines the slices into the model. | ||
| 6 | """ | 8 | """ |
| 7 | 9 | ||
| 8 | from iolabs.common import config_loader | 10 | from iolabs.common import config_loader |
| 9 | 11 | ||
| 10 | 12 | from ._model_base import section_field | |
| 11 | class GroundConfig(config_loader.ConfigModel): | 13 | |
| 12 | """Ground-model raster cell size and percentile.""" | 14 | |
| 13 | 15 | class VerticalSignsGridFields(config_loader.ConfigModel): | |
| 14 | cell_m: float = 0.75 | 16 | """Ground, occupancy grid, candidate band and clustering thresholds. |
| 15 | percentile: float = 8.0 | 17 | |
| 16 | 18 | Also the first classification gates and vehicle rejection. | |
| 17 | 19 | ||
| 18 | class OccupancyConfig(config_loader.ConfigModel): | 20 | Metres unless stated otherwise. |
| 19 | """Occupancy grid used to find candidate cells.""" | 21 | """ |
| 20 | 22 | ||
| 21 | cell_m: float = 0.15 | 23 | # Ground model |
| 22 | 24 | ground_cell_m: float = section_field("ground.cell_m", 0.75, gt=0.0) | |
| 23 | 25 | ground_percentile: float = section_field("ground.percentile", 8.0, ge=0.0, le=100.0) | |
| 24 | class CandidatesConfig(config_loader.ConfigModel): | 26 | |
| 25 | """Height band and seed-cell gates for candidate points.""" | 27 | # Occupancy grid for candidate cells |
| 26 | 28 | occupancy_cell_m: float = section_field("occupancy.cell_m", 0.15, gt=0.0) | |
| 27 | min_height_m: float = 0.3 | 29 | |
| 28 | max_height_m: float = 10.0 | 30 | # Height band for off-ground candidate points |
| 29 | seed_min_vertical_span_m: float = 0.8 | 31 | min_height_m: float = section_field("candidates.min_height_m", 0.30) |
| 30 | seed_min_h_max_m: float = 0.9 | 32 | max_height_m: float = section_field("candidates.max_height_m", 10.0) |
| 31 | seed_bright_min_vertical_span_m: float = 0.45 | 33 | |
| 32 | seed_bright_min_h_max_m: float = 0.6 | 34 | # Seed-cell gates (vertical span and max height above ground) |
| 33 | seed_bright_min_points: int = 3 | 35 | seed_min_vertical_span_m: float = section_field("candidates.seed_min_vertical_span_m", 0.80) |
| 34 | 36 | seed_min_h_max_m: float = section_field("candidates.seed_min_h_max_m", 0.90) | |
| 35 | 37 | ||
| 36 | class 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 |
| 38 | 40 | # cell (base clipped by min_height_m=0.30), so they fall just under the | |
| 39 | eps_m: float = 0.45 | 41 | # 0.80 m primary span gate and never seed a cluster โ the round-4 recall |
| 40 | min_samples: int = 1 | 42 | # gap. A second, relaxed seed pass recovers them, but is restricted to |
| 41 | hull_margin_m: float = 0.2 | 43 | # cells holding >= seed_bright_min_points retroreflective returns |
| 42 | 44 | # (intensity >= the segment's hi-intensity threshold): a Leitpfosten head | |
| 43 | 45 | # is always retroreflective, so the extra candidate cells stay few and the | |
| 44 | class 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. |
| 46 | 48 | seed_bright_min_vertical_span_m: float = section_field( | |
| 47 | continuity_bin_m: float = 0.25 | 49 | "candidates.seed_bright_min_vertical_span_m", 0.45 |
| 48 | reject_len_major_m: float = 6.0 | 50 | ) |
| 49 | reject_h_max_with_large_footprint_m: float = 4.5 | 51 | seed_bright_min_h_max_m: float = section_field("candidates.seed_bright_min_h_max_m", 0.60) |
| 50 | min_continuity: float = 0.5 | 52 | seed_bright_min_points: int = section_field("candidates.seed_bright_min_points", 3) |
| 51 | min_accept_h_max_m: float = 0.9 | 53 | |
| 52 | core_rms_bin_m: float = 0.25 | 54 | # DBSCAN clustering on seed-cell centres |
| 53 | core_rms_h_min_m: float = 0.3 | 55 | cluster_eps_m: float = section_field("clustering.eps_m", 0.45) |
| 54 | core_rms_h_cap_m: float = 3.0 | 56 | cluster_min_samples: int = section_field("clustering.min_samples", 1) |
| 55 | hi_intensity_all_points_percentile: float = 98.0 | 57 | cluster_hull_margin_m: float = section_field("clustering.hull_margin_m", 0.20) |
| 56 | min_volumetric_density: float = 8000.0 | 58 | |
| 57 | pole_floating_min_h_min_m: float = 3.5 | 59 | # Per-cluster feature bins |
| 58 | pole_isolated_radius_m: float = 8.0 | 60 | continuity_bin_m: float = section_field("classification.continuity_bin_m", 0.25) |
| 59 | dedup_radius_m: float = 0.8 | 61 | |
| 60 | emit_trees: bool = False | 62 | # Classification thresholds |
| 61 | ml_verifier_enabled: bool = True | 63 | reject_len_major_m: float = section_field("classification.reject_len_major_m", 6.0) |
| 62 | ml_veto_threshold: float = -1.0 | 64 | 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 = True | 66 | ) |
| 65 | lattice_max_seed_spacing_m: float = 60.0 | 67 | min_continuity: float = section_field("classification.min_continuity", 0.50) |
| 66 | lattice_max_skip: int = 6 | 68 | min_accept_h_max_m: float = section_field("classification.min_accept_h_max_m", 0.90) |
| 67 | lattice_max_spacing_resid: float = 0.15 | 69 | |
| 68 | lattice_min_anchors: int = 4 | 70 | # Vehicle rejection |
| 69 | lattice_min_seed_spacing_m: float = 15.0 | 71 | vehicle_h_min_m: float = section_field("vehicle.h_min_m", 1.5) |
| 70 | lattice_pool_h_max_max_m: float = 1.4 | 72 | vehicle_h_max_m: float = section_field("vehicle.h_max_m", 4.5) |
| 71 | lattice_pool_h_max_min_m: float = 0.8 | 73 | vehicle_len_major_m: float = section_field("vehicle.len_major_m", 2.5) |
| 72 | lattice_pool_max_len_major_m: float = 1.2 | 74 | vehicle_len_minor_m: float = section_field("vehicle.len_minor_m", 1.5) |
| 73 | lattice_pool_max_plate_thickness_m: float = 0.05 | 75 | vehicle_max_hi_intensity_fraction: float = section_field( |
| 74 | lattice_pool_min_hi_seed_fraction: float = 0.15 | 76 | "vehicle.max_hi_intensity_fraction", 0.10 |
| 75 | lattice_pool_min_points: int = 20 | 77 | ) |
| 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 | |||
| 94 | class 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 | |||
| 114 | class 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 | |||
| 131 | class 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 | |||
| 145 | class 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 |
| 1 | """Perspective-projection QC overlay cameras and per-detection QC views. | ||
| 2 | |||
| 3 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 4 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 5 | loaded from; ``_config`` recombines the slices into the model. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from iolabs.common import config_loader | ||
| 9 | |||
| 10 | from ._model_base import section_field | ||
| 11 | |||
| 12 | |||
| 13 | class 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 | |||
| 46 | class 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 |
| 1 | """Road-context, edge-line and QC rendering config sections. | 1 | """Road-context gate, driven-lane band and repetitive-row rejection. |
| 2 | 2 | ||
| 3 | One slice of the nested :class:`VerticalSignsConfig` model tree; the sections | 3 | Also field-stake rows and embedded-marker extraction. |
| 4 | mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines | 4 | |
| 5 | the slices. | 5 | One slice of the flat ``DetectorConfig``. Every field declares, via |
| 6 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 7 | loaded from; ``_config`` recombines the slices into the model. | ||
| 6 | """ | 8 | """ |
| 7 | 9 | ||
| 8 | from iolabs.common import config_loader | 10 | from iolabs.common import config_loader |
| 9 | 11 | ||
| 12 | from ._model_base import section_field | ||
| 10 | 13 | ||
| 11 | class RoadContextConfig(config_loader.ConfigModel): | ||
| 12 | """Road-context saturation raster and XML carriageway votes.""" | ||
| 13 | |||
| 14 | gate_enabled: bool = True | ||
| 15 | xml_enabled: bool = True | ||
| 16 | xml_min_agreement: float = 0.6 | ||
| 17 | xml_vote_slack_m: float = 3.0 | ||
| 18 | xml_max_distance_m: float = 60.0 | ||
| 19 | xml_station_tolerance_m: float = 2.0 | ||
| 20 | xml_station_step_m: float = 10.0 | ||
| 21 | min_carriageway_width_m: float = 3.0 | ||
| 22 | max_carriageway_width_m: float = 20.0 | ||
| 23 | paint_fallback_enabled: bool = False | ||
| 24 | saturation_intensity: float = 55000.0 | ||
| 25 | radius_m: float = 15.0 | ||
| 26 | neighbour_span: int = 1 | ||
| 27 | cache_dir: str = "" | ||
| 28 | min_neighbourhood_saturated: int = 1000 | ||
| 29 | 14 | ||
| 15 | class VerticalSignsRoadContextFields(config_loader.ConfigModel): | ||
| 16 | """Road-context gate, driven-lane band and repetitive-row rejection. | ||
| 30 | 17 | ||
| 31 | class EdgeLineConfig(config_loader.ConfigModel): | 18 | Also field-stake rows and embedded-marker extraction. |
| 32 | """Edge-line paint detection and far-distance filtering.""" | ||
| 33 | 19 | ||
| 34 | gate_enabled: bool = True | 20 | Metres unless stated otherwise. |
| 35 | paint_max_height_m: float = 0.35 | 21 | """ |
| 36 | paint_min_height_m: float = -0.25 | ||
| 37 | paint_intensity_percentile: float = 95.0 | ||
| 38 | paint_subsample: int = 20 | ||
| 39 | station_len_m: float = 10.0 | ||
| 40 | min_window_returns: int = 2000 | ||
| 41 | lateral_bin_m: float = 0.1 | ||
| 42 | min_line_points: int = 40 | ||
| 43 | max_line_width_m: float = 1.5 | ||
| 44 | min_line_along_fill: float = 0.4 | ||
| 45 | drive_line_bin_m: float = 0.5 | ||
| 46 | min_band_width_m: float = 2.0 | ||
| 47 | max_band_width_m: float = 9.0 | ||
| 48 | inward_margin_m: float = 0.3 | ||
| 49 | min_coverage_frac: float = 0.6 | ||
| 50 | min_axis_contrast: float = 3.0 | ||
| 51 | axis_search_radius_m: float = 40.0 | ||
| 52 | axis_max_angle_cos: float = 0.8 | ||
| 53 | axis_max_distance_m: float = 150.0 | ||
| 54 | exempt_h_max_m: float = 4.5 | ||
| 55 | reject_requires_transient: bool = True | ||
| 56 | transient_max_records: int = 1 | ||
| 57 | far_filter_enabled: bool = True | ||
| 58 | far_max_distance_m: float = 30.0 | ||
| 59 | far_include_lane_lines: bool = True | ||
| 60 | far_tier2_enabled: bool = True | ||
| 61 | far_tier2_distance_m: float = 15.0 | ||
| 62 | far_tier2_max_saturation: int = 150 | ||
| 63 | max_carriageway_width_m: float = 20.0 | ||
| 64 | min_carriageway_width_m: float = 3.0 | ||
| 65 | paint_fallback_enabled: bool = False | ||
| 66 | xml_enabled: bool = True | ||
| 67 | xml_max_distance_m: float = 60.0 | ||
| 68 | xml_min_agreement: float = 0.6 | ||
| 69 | xml_station_step_m: float = 10.0 | ||
| 70 | xml_station_tolerance_m: float = 2.0 | ||
| 71 | xml_vote_slack_m: float = 3.0 | ||
| 72 | 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 = 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) | ||
| 73 | 33 | ||
| 74 | class 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", "") | ||
| 76 | 56 | ||
| 77 | near_radius_m: float = 45.0 | 57 | # Driven-lane band gate (AI3D-339 pass 8, Miro directive). A short |
| 78 | fov_deg: float = 55.0 | 58 | # candidate standing in the lane the survey vehicle drove is a vehicle, not |
| 79 | splat: int = 2 | 59 | # road furniture. The pass-8 census killed the wider "between the two edge |
| 80 | image_width: int = 1100 | 60 | # lines of the carriageway" form โ run4 is absent on A1 and a featureless |
| 81 | image_height: int = 750 | 61 | # 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 | ) | ||
| 83 | 158 | ||
| 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) | ||
| 84 | 168 | ||
| 85 | class 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 | ) | ||
| 87 | 194 | ||
| 88 | depth_tol_m: float = 0.5 | 195 | # Legacy ``road_context`` copies of the ``edge_line`` keys of the same name. |
| 89 | line_samples: int = 20 | 196 | # The detector reads the ``edge_line`` fields above; these are declared so |
| 90 | occluded_alpha: int = 90 | 197 | # the packaged JSON keeps validating, and so an override file written |
| 91 | solid_width_px: int = 3 | 198 | # against the old section spelling is still accepted rather than rejected. |
| 92 | halo_width_px: int = 6 | 199 | roadctx_xml_enabled: bool = section_field("road_context.xml_enabled", True) |
| 93 | base_marker_radius_px: int = 6 | 200 | roadctx_xml_min_agreement: float = section_field("road_context.xml_min_agreement", 0.6) |
| 94 | back_distance_m: float = 22.0 | 201 | roadctx_xml_vote_slack_m: float = section_field("road_context.xml_vote_slack_m", 3.0) |
| 95 | back_height_m: float = 4.0 | 202 | roadctx_xml_max_distance_m: float = section_field("road_context.xml_max_distance_m", 60.0) |
| 96 | context_distance_m: float = 40.0 | 203 | roadctx_xml_station_tolerance_m: float = section_field( |
| 97 | context_height_m: float = 6.0 | 204 | "road_context.xml_station_tolerance_m", 2.0 |
| 98 | share_radius_m: float = 15.0 | 205 | ) |
| 99 | coverage_tol_m: float = 0.5 | 206 | 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 | ) |
| 1 | """Opt-in post-classification stages. | ||
| 2 | |||
| 3 | The rail-relative half-post pass, the reject-rescue second look and | ||
| 4 | the ML verifier. | ||
| 5 | |||
| 6 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 7 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 8 | loaded from; ``_config`` recombines the slices into the model. | ||
| 9 | """ | ||
| 10 | |||
| 11 | from iolabs.common import config_loader | ||
| 12 | |||
| 13 | from ._model_base import section_field | ||
| 14 | |||
| 15 | |||
| 16 | class 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 |
| 1 | """Tree, vegetation and ground-filter config sections. | ||
| 2 | |||
| 3 | One slice of the nested :class:`VerticalSignsConfig` model tree; the sections | ||
| 4 | mirror ``verticalsigns.default.json`` key for key. ``_config_model`` recombines | ||
| 5 | the slices. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from iolabs.common import config_loader | ||
| 9 | |||
| 10 | |||
| 11 | class 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 | |||
| 20 | class 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 | |||
| 38 | class 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 | |||
| 118 | class 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 | |||
| 129 | class 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 | |||
| 143 | class 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 | |||
| 163 | class 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 |
| 1 | """Experimental tree detection and TCS ground filtering of the DEM input. | ||
| 2 | |||
| 3 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 4 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 5 | loaded from; ``_config`` recombines the slices into the model. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from typing import Literal, TypeAlias | ||
| 9 | |||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | from ._model_base import section_field | ||
| 13 | |||
| 14 | #: Ground-filter mechanism, spelled exactly as ``iolabs_point_cloud_tablecloth`` types it. | ||
| 15 | TcsMechanism: TypeAlias = Literal["none", "smrf_numpy", "csf_cloth"] | ||
| 16 | |||
| 17 | |||
| 18 | class 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 |
| 1 | """Per-point tree instance splitting of merged canopy blobs. | ||
| 2 | |||
| 3 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 4 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 5 | loaded from; ``_config`` recombines the slices into the model. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from iolabs.common import config_loader | ||
| 9 | |||
| 10 | from ._model_base import section_field | ||
| 11 | |||
| 12 | |||
| 13 | class 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 |
| 1 | """Tree rejection, chromaticity vegetation reject and radius fitting. | ||
| 2 | |||
| 3 | Also core compactness and the crown-circle overlay knobs. | ||
| 4 | |||
| 5 | One slice of the flat ``DetectorConfig``. Every field declares, via | ||
| 6 | ``section_field``, the ``verticalsigns.default.json`` section and key it is | ||
| 7 | loaded from; ``_config`` recombines the slices into the model. | ||
| 8 | """ | ||
| 9 | |||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | from ._model_base import section_field | ||
| 13 | |||
| 14 | |||
| 15 | class 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 |
| 1 | """Detector configuration. | 1 | """Public import path for the detector configuration. |
| 2 | 2 | ||
| 3 | The 379-field :class:`DetectorConfig` and its ``from_mapping`` flattener are | 3 | The schema, the loading entry points and the error class live in `_config`; |
| 4 | split by section across the ``_config_<section>`` modules; this module | 4 | this module re-exports them so the documented ``from |
| 5 | recombines them and re-exports every piece, so ``from .config import X`` | 5 | iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig`` keeps |
| 6 | keeps working for every name that used to live here. | 6 | working. The field declarations themselves are split across the |
| 7 | 7 | ``_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 | ||
| 10 | by the :class:`VerticalSignsConfig` model tree in ``_config_model``. | ||
| 11 | """ | 8 | """ |
| 12 | 9 | ||
| 13 | from pathlib import Path | 10 | from ._config import ( |
| 14 | from typing import Any | 11 | DetectorConfig, |
| 15 | 12 | VerticalSignsConfigError, | |
| 16 | from ._config import load_verticalsigns_config | 13 | build_verticalsigns_config, |
| 17 | from ._config_conic import ConicFields, conic_kwargs | 14 | load_default_config, |
| 18 | from ._config_corridor import CorridorFields, corridor_kwargs | 15 | load_verticalsigns_config, |
| 19 | from ._config_devices import DeviceFields, device_kwargs | 16 | normalize_verticalsigns_config, |
| 20 | from ._config_evidence import EvidenceFields, evidence_kwargs | 17 | ) |
| 21 | from ._config_grid import GridFields, grid_kwargs | ||
| 22 | from ._config_perspective import PerspectiveFields, perspective_kwargs | ||
| 23 | from ._config_roadcontext import RoadContextFields, road_context_kwargs | ||
| 24 | from ._config_stages import StageFields, stage_kwargs | ||
| 25 | from ._config_treedetect import TreeDetectionFields, tree_detection_kwargs | ||
| 26 | from ._config_treeinstance import TreeInstanceFields, tree_instance_kwargs | ||
| 27 | from ._config_vegetation import VegetationFields, vegetation_kwargs | ||
| 28 | 18 | ||
| 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 | |||
| 56 | class 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)) |
| 4 | from collections.abc import Callable | 4 | from collections.abc import Callable |
| 5 | from typing import Any | 5 | from typing import Any |
| 6 | 6 | ||
| 7 | import pytest | 7 | import pytest |
| 8 | from iolabs.common import config_loader | ||
| 9 | 8 | ||
| 9 | from iolabs_point_cloud_detection_verticalsigns import _model_base | ||
| 10 | from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig | ||
| 10 | 11 | ||
| 11 | def _section_values(model: type[config_loader.ConfigModel]) -> dict[str, Any]: | 12 | |
| 12 | """Return one valid non-default value per field of *model*.""" | 13 | def _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.annotation | 20 | 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.default | 23 | values[key] = not field.default |
| 18 | elif annotation is int: | 24 | elif annotation is int: |
| 19 | values[name] = int(field.default) + 1 | 25 | 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.5 | 33 | values[key] = 0.5 |
| 26 | return values | 34 | return values |
| 27 | 35 | ||
| 28 | 36 | ||
| 29 | @pytest.fixture | 37 | @pytest.fixture |
| 30 | def section_values() -> Callable[[type[config_loader.ConfigModel]], dict[str, Any]]: | 38 | def 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_values | 40 | return _section_values |
| 13 | 13 | ||
| 14 | import numpy as np | 14 | import numpy as np |
| 15 | import pytest | 15 | import pytest |
| 16 | 16 | ||
| 17 | from iolabs_point_cloud_detection_verticalsigns import _config, _model_tree | 17 | from iolabs_point_cloud_detection_verticalsigns import _config |
| 18 | from iolabs_point_cloud_detection_verticalsigns.classify import ( | 18 | from 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, |
| 303 | 303 | ||
| 304 | 304 | ||
| 305 | def test_config_accepts_every_documented_key(tmp_path, section_values) -> None: | 305 | def 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"] = True | 308 | 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"] |
| 1 | """Schema guards for the flat :class:`DetectorConfig` and the packaged JSON. | ||
| 2 | |||
| 3 | The detector reads a FLAT config while the packaged | ||
| 4 | ``verticalsigns.default.json`` is grouped into sections, and each flat field | ||
| 5 | declares the section and key it is loaded from (``_model_base.section_field``). | ||
| 6 | Three 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 | |||
| 14 | import json | ||
| 15 | from pathlib import Path | ||
| 16 | |||
| 17 | import pydantic | ||
| 18 | import pytest | ||
| 19 | from iolabs.common import config_loader | ||
| 20 | |||
| 21 | from iolabs_point_cloud_detection_verticalsigns import _config, _model_base | ||
| 22 | from 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 | |||
| 31 | PACKAGED_JSON = ( | ||
| 32 | Path(__file__).resolve().parents[1] | ||
| 33 | / "src/iolabs_point_cloud_detection_verticalsigns/verticalsigns.default.json" | ||
| 34 | ) | ||
| 35 | |||
| 36 | |||
| 37 | def _packaged() -> dict: | ||
| 38 | return json.loads(PACKAGED_JSON.read_text(encoding="utf-8")) | ||
| 39 | |||
| 40 | |||
| 41 | def _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 | |||
| 50 | def _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 | |||
| 66 | def _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 | |||
| 85 | def test_model_defaults_match_packaged_json() -> None: | ||
| 86 | assert DetectorConfig().to_document() == _packaged() | ||
| 87 | |||
| 88 | |||
| 89 | def 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 | |||
| 98 | def test_error_class_is_config_error() -> None: | ||
| 99 | assert issubclass(VerticalSignsConfigError, config_loader.ConfigError) | ||
| 100 | assert issubclass(VerticalSignsConfigError, ValueError) | ||
| 101 | |||
| 102 | |||
| 103 | def 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 | |||
| 108 | def 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 | |||
| 113 | def 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 | |||
| 120 | def 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 | |||
| 135 | def 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 | |||
| 144 | def 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 | |||
| 150 | def 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 | |||
| 157 | def 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 | |||
| 162 | def 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 | |||
| 169 | def 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 | |||
| 175 | def 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 | |||
| 181 | def 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 | |||
| 192 | def 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 |
| 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 | ||
| 6 | things 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 | |||
| 13 | import json | ||
| 14 | import re | ||
| 15 | from pathlib import Path | ||
| 16 | |||
| 17 | from iolabs.common import config_loader | ||
| 18 | |||
| 19 | from iolabs_point_cloud_detection_verticalsigns import _config_model | ||
| 20 | from iolabs_point_cloud_detection_verticalsigns._config import load_default_config | ||
| 21 | from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig | ||
| 22 | |||
| 23 | CONFIG_PY = ( | ||
| 24 | Path(__file__).resolve().parents[1] | ||
| 25 | / "src/iolabs_point_cloud_detection_verticalsigns/config.py" | ||
| 26 | ) | ||
| 27 | |||
| 28 | |||
| 29 | def _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 | |||
| 42 | def _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 | |||
| 68 | def 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 | |||
| 75 | def test_absent_sections_fall_back_to_the_model_defaults() -> None: | ||
| 76 | assert DetectorConfig.from_mapping({}) == DetectorConfig() | ||
| 77 | |||
| 78 | |||
| 79 | def 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 | |||
| 86 | def 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 | |||
| 97 | def 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 | |||
| 103 | def 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 | |||
| 115 | def 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 | |||
| 130 | def 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 |
| 17 | 17 | ||
| 18 | import numpy as np | 18 | import numpy as np |
| 19 | import pytest | 19 | import pytest |
| 20 | 20 | ||
| 21 | from iolabs_point_cloud_detection_verticalsigns import _config, _model_tree | 21 | from iolabs_point_cloud_detection_verticalsigns import _config |
| 22 | from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig | 22 | from iolabs_point_cloud_detection_verticalsigns.config import DetectorConfig |
| 23 | from iolabs_point_cloud_detection_verticalsigns.tree_instances import ( | 23 | from iolabs_point_cloud_detection_verticalsigns.tree_instances import ( |
| 24 | ABSTAIN_ASSIGNED, | 24 | ABSTAIN_ASSIGNED, |
| 25 | ABSTAIN_HEDGE, | 25 | ABSTAIN_HEDGE, |
| 1070 | _config.load_verticalsigns_config(path) | 1070 | _config.load_verticalsigns_config(path) |
| 1071 | 1071 | ||
| 1072 | 1072 | ||
| 1073 | def test_config_accepts_every_documented_key(tmp_path, section_values) -> None: | 1073 | def 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"] = True | 1075 | 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"] |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.