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