Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:41:44+02:00
Commit #59 · 29 snippets
README.md | 45 +++--- guardrails/_config_fields.py | 312 ------------------------------------- guardrails/_config_fields_posts.py | 204 ------------------------ guardrails/_config_model.py | 32 ++++ guardrails/_model_core.py | 205 ++++++++++++++++++++++++ guardrails/_model_posts.py | 210 +++++++++++++++++++++++++ guardrails/_model_wall.py | 126 +++++++++++++++ guardrails/config.py | 100 +++++------- tests/test_config.py | 34 ++-- tests/test_support_class.py | 11 +- 10 files changed, 660 insertions(+), 619 deletions(-)
| 1 | """Ground, corridor, candidate, cluster, fit and memory field declarations. | ||
| 2 | |||
| 3 | One schema slice of the guardrails detector config; the slices are recombined | ||
| 4 | into the model by :mod:`guardrails._config_model`. The split exists only to | ||
| 5 | keep every module under 500 lines — these classes carry no behaviour, and the | ||
| 6 | config schema is still the flat key set of ``guardrails.default.json``. | ||
| 7 | |||
| 8 | Adding a config key means adding the field here and the same key to | ||
| 9 | ``guardrails.default.json`` — nothing else. Unknown keys are rejected. | ||
| 10 | """ | ||
| 11 | |||
| 12 | import pydantic | ||
| 13 | from iolabs.common import config_loader | ||
| 14 | |||
| 15 | |||
| 16 | class DetectorCoreConfig(config_loader.ConfigModel): | ||
| 17 | """Ground, corridor, candidate, cluster, fit and memory levers.""" | ||
| 18 | |||
| 19 | # Ground model | ||
| 20 | ground_cell_m: float = pydantic.Field(0.75, ge=0.0) | ||
| 21 | ground_percentile: float = pydantic.Field(8.0, ge=0.0, le=100.0) | ||
| 22 | |||
| 23 | # Corridor crop (station / offset frame) | ||
| 24 | corridor_offset_min_m: float = pydantic.Field(1.5, ge=0.0) | ||
| 25 | corridor_offset_max_m: float = pydantic.Field(10.0, ge=0.0) | ||
| 26 | corridor_include_median_zone: bool = True | ||
| 27 | median_corridor_offset_min_m: float = pydantic.Field(0.8, ge=0.0) | ||
| 28 | median_corridor_offset_max_m: float = pydantic.Field(3.8, ge=0.0) | ||
| 29 | corridor_max_height_m: float = pydantic.Field(2.0, ge=0.0) | ||
| 30 | station_window_m: float = pydantic.Field(5.0, ge=0.0) | ||
| 31 | median_side_max_offset_m: float = pydantic.Field(3.5, ge=0.0) | ||
| 32 | |||
| 33 | # Optional lane-XML carriageway / rail-zone scoping | ||
| 34 | lane_xml_zones_enabled: bool = True | ||
| 35 | lane_xml_path: str | None = None | ||
| 36 | rail_zone_margin_m: float = pydantic.Field(10.0, ge=0.0) | ||
| 37 | outer_rail_band_m: float = pydantic.Field(20.0, ge=0.0) | ||
| 38 | single_edge_rail_margin_m: float = pydantic.Field(15.0, ge=0.0) | ||
| 39 | max_carriageway_width_m: float = pydantic.Field(15.0, ge=0.0) | ||
| 40 | zone_bbox_margin_m: float = pydantic.Field(140.0, ge=0.0) | ||
| 41 | interior_rejection_depth_m: float = pydantic.Field(2.0, ge=0.0) | ||
| 42 | |||
| 43 | # Optional late edge gate: instance-level distance filters against the | ||
| 44 | # lane-XML edge lines (rules E1/E2), applied after the precision gate. | ||
| 45 | # edge_gate_max_rail_distance_m was calibrated on A1 segments 060/066/085: | ||
| 46 | # real rails measure <= 3.7 m from an XML edge, noise >= 5.4 m. | ||
| 47 | edge_gate_enabled: bool = True | ||
| 48 | edge_gate_max_rail_distance_m: float = pydantic.Field(5.0, ge=0.0) | ||
| 49 | edge_gate_interior_depth_m: float = pydantic.Field(0.5, ge=0.0) | ||
| 50 | edge_gate_interior_max_frac: float = pydantic.Field(0.5, ge=0.0, le=1.0) | ||
| 51 | edge_gate_apply_to_walls: bool = False | ||
| 52 | |||
| 53 | # Optional late precision gate over final rail/wall runs. | ||
| 54 | precision_gate_enabled: bool = True | ||
| 55 | precision_deep_interior_depth_m: float = pydantic.Field(2.0, ge=0.0) | ||
| 56 | precision_deep_interior_frac_min: float = pydantic.Field(0.50, ge=0.0, le=1.0) | ||
| 57 | precision_vehicle_max_length_m: float = pydantic.Field(15.0, ge=0.0) | ||
| 58 | precision_vehicle_min_density_per_m: float = pydantic.Field(750.0, ge=0.0) | ||
| 59 | precision_vehicle_min_mean_height_m: float = pydantic.Field(0.80, ge=0.0) | ||
| 60 | precision_low_max_mean_height_m: float = pydantic.Field(0.35, ge=0.0) | ||
| 61 | precision_sparse_max_density_per_m: float = pydantic.Field(300.0, ge=0.0) | ||
| 62 | precision_sparse_min_outboard_gap_m: float = pydantic.Field(6.0, ge=0.0) | ||
| 63 | precision_curve_min_line_rmse_m: float = pydantic.Field(0.010, ge=0.0) | ||
| 64 | precision_far_min_axis_dist_m: float = pydantic.Field(18.0, ge=0.0) | ||
| 65 | precision_long_low_min_length_m: float = pydantic.Field(25.0, ge=0.0) | ||
| 66 | precision_edge_beyond_frac_min: float = pydantic.Field(0.25, ge=0.0, le=1.0) | ||
| 67 | precision_dense_low_min_density_per_m: float = pydantic.Field(2500.0, ge=0.0) | ||
| 68 | precision_parallel_min_inboard_gap_m: float = pydantic.Field(3.0, ge=0.0) | ||
| 69 | precision_parallel_min_overlap_frac: float = pydantic.Field(0.75, ge=0.0, le=1.0) | ||
| 70 | precision_unknown_far_min_axis_dist_m: float = pydantic.Field(20.0, ge=0.0) | ||
| 71 | precision_very_far_min_outboard_gap_m: float = pydantic.Field(12.0, ge=0.0) | ||
| 72 | precision_very_far_min_axis_dist_m: float = pydantic.Field(25.0, ge=0.0) | ||
| 73 | precision_edge_abeam_window_m: float = pydantic.Field(15.0, ge=0.0) | ||
| 74 | precision_edge_outboard_epsilon_m: float = pydantic.Field(0.30, ge=0.0) | ||
| 75 | |||
| 76 | # Occupancy grid for candidate cells | ||
| 77 | occupancy_cell_m: float = pydantic.Field(0.10, ge=0.0) | ||
| 78 | |||
| 79 | # Height band for initial point candidates (also drives candidates overlay) | ||
| 80 | min_height_m: float = pydantic.Field(0.20, ge=0.0) | ||
| 81 | max_height_m: float = pydantic.Field(1.30, ge=0.0) | ||
| 82 | |||
| 83 | # Per-cell rail-band fraction and mean-height gates | ||
| 84 | rail_band_min_m: float = pydantic.Field(0.35, ge=0.0) | ||
| 85 | rail_band_max_m: float = pydantic.Field(0.85, ge=0.0) | ||
| 86 | min_cell_points: int = pydantic.Field(3, ge=1) | ||
| 87 | min_rail_points: int = pydantic.Field(2, ge=1) | ||
| 88 | min_rail_fraction: float = pydantic.Field(0.40, ge=0.0, le=1.0) | ||
| 89 | min_mean_height_m: float = pydantic.Field(0.42, ge=0.0) | ||
| 90 | max_mean_height_m: float = pydantic.Field(0.78, ge=0.0) | ||
| 91 | |||
| 92 | # Optional tablecloth-residue candidate lever | ||
| 93 | tablecloth_masks_dir: str | None = None | ||
| 94 | residue_union_enabled: bool = True | ||
| 95 | residue_cell_frac: float = pydantic.Field(0.8, ge=0.0, le=1.0) | ||
| 96 | residue_lever_band_m: tuple[float, float] = (0.30, 1.20) | ||
| 97 | |||
| 98 | # Vegetation rejection: compact height-above-ground spread within a cell | ||
| 99 | max_cell_height_spread_m: float = pydantic.Field(0.50, ge=0.0) | ||
| 100 | |||
| 101 | # Tall-object fraction per cell (trees, poles) | ||
| 102 | tall_min_m: float = pydantic.Field(1.30, ge=0.0) | ||
| 103 | tall_max_m: float = pydantic.Field(4.50, ge=0.0) | ||
| 104 | max_tall_fraction: float = pydantic.Field(0.12, ge=0.0, le=1.0) | ||
| 105 | |||
| 106 | # Local covariance / eigenvector candidate filter (cell-level) | ||
| 107 | eigen_neighborhood_radius_m: float = pydantic.Field(0.40, ge=0.0) | ||
| 108 | eigen_min_neighbors: int = pydantic.Field(5, ge=1) | ||
| 109 | min_linearity: float = pydantic.Field(0.30, ge=0.0, le=1.0) | ||
| 110 | min_verticality: float = pydantic.Field(0.15, ge=0.0, le=1.0) | ||
| 111 | use_eigen_cell_filter: bool = False | ||
| 112 | |||
| 113 | # DBSCAN clustering on selected occupancy cells | ||
| 114 | cluster_eps_m: float = pydantic.Field(0.20, ge=0.0) | ||
| 115 | cluster_min_samples: int = pydantic.Field(3, ge=1) | ||
| 116 | |||
| 117 | # Post-cluster merge of collinear fragments | ||
| 118 | merge_gap_m: float = pydantic.Field(4.5, ge=0.0) | ||
| 119 | merge_angle_deg: float = pydantic.Field(15.0, ge=0.0) | ||
| 120 | merge_lateral_max_m: float = pydantic.Field(0.50, ge=0.0) | ||
| 121 | |||
| 122 | # Occlusion bridging: join collinear fragments across a parked-vehicle / | ||
| 123 | # occlusion shadow when heading and offset stay continuous (defect 4). The | ||
| 124 | # bridged station interval is recorded in ``gap_spans`` (never interpolated | ||
| 125 | # silently). | ||
| 126 | # Default is conservative (8 m) so bridging never fuses two distinct | ||
| 127 | # barriers into one instance; raise via --set occlusion_bridge_max_m=15 for | ||
| 128 | # datasets with longer occlusion shadows. | ||
| 129 | occlusion_bridge_max_m: float = pydantic.Field(8.0, ge=0.0) | ||
| 130 | occlusion_bridge_max_angle_deg: float = pydantic.Field(4.0, ge=0.0) | ||
| 131 | occlusion_bridge_max_lateral_m: float = pydantic.Field(0.40, ge=0.0) | ||
| 132 | |||
| 133 | # Parallel-face deduplication (two faces of one physical rail). | ||
| 134 | # ``dedupe_*`` are retained for backward compatibility; the active policy is | ||
| 135 | # driven by ``merge_face_*`` (see README "Face / barrier merge policy"). | ||
| 136 | dedupe_face_max_sep_m: float = pydantic.Field(1.0, ge=0.0) | ||
| 137 | dedupe_max_angle_deg: float = pydantic.Field(12.0, ge=0.0) | ||
| 138 | merge_face_max_spacing_m: float = pydantic.Field(1.3, ge=0.0) | ||
| 139 | merge_face_max_heading_deg: float = pydantic.Field(5.0, ge=0.0) | ||
| 140 | merge_face_min_station_overlap: float = pydantic.Field(0.5, ge=0.0) | ||
| 141 | merge_face_max_faces: int = pydantic.Field(2, ge=1) | ||
| 142 | |||
| 143 | # Instance acceptance (applied after merge) | ||
| 144 | min_length_m: float = pydantic.Field(12.0, ge=0.0) | ||
| 145 | max_local_width_m: float = pydantic.Field(0.75, ge=0.0) | ||
| 146 | min_longitudinal_coverage: float = pydantic.Field(0.35, ge=0.0, le=1.0) | ||
| 147 | |||
| 148 | # Ordered-walk polyline construction | ||
| 149 | polyline_bin_m: float = pydantic.Field(1.0, ge=0.0) | ||
| 150 | polyline_smooth_window: int = pydantic.Field(5, ge=1) | ||
| 151 | walk_max_step_m: float = pydantic.Field(0.30, ge=0.0) | ||
| 152 | |||
| 153 | # Gap recording along station | ||
| 154 | gap_min_span_m: float = pydantic.Field(2.0, ge=0.0) | ||
| 155 | |||
| 156 | # Vehicle / occlusion-shadow rejection on cluster height distribution | ||
| 157 | max_cluster_height_spread_m: float = pydantic.Field(0.80, ge=0.0) | ||
| 158 | max_cluster_p95_height_m: float = pydantic.Field(1.15, ge=0.0) | ||
| 159 | |||
| 160 | # Straightness check along sliding window (short clusters only) | ||
| 161 | straightness_window_m: float = pydantic.Field(10.0, ge=0.0) | ||
| 162 | max_straightness_deviation_m: float = pydantic.Field(0.50, ge=0.0) | ||
| 163 | straightness_max_length_m: float = pydantic.Field(25.0, ge=0.0) | ||
| 164 | |||
| 165 | # Heuristic type classification thresholds | ||
| 166 | w_beam_min_height_m: float = pydantic.Field(0.40, ge=0.0) | ||
| 167 | w_beam_max_height_m: float = pydantic.Field(0.90, ge=0.0) | ||
| 168 | w_beam_max_height_spread_m: float = pydantic.Field(0.55, ge=0.0) | ||
| 169 | concrete_min_height_m: float = pydantic.Field(0.80, ge=0.0) | ||
| 170 | concrete_max_height_spread_m: float = pydantic.Field(0.45, ge=0.0) | ||
| 171 | cable_suspect_max_spread_m: float = pydantic.Field(0.25, ge=0.0) | ||
| 172 | |||
| 173 | # Per-run confidence heuristic (0-1); see README "Run confidence". | ||
| 174 | # confidence = 0.35*support + 0.25*continuity + 0.25*extent + 0.15*height | ||
| 175 | confidence_density_norm_pts_per_m: float = pydantic.Field(500.0, ge=0.0) | ||
| 176 | confidence_full_extent_m: float = pydantic.Field(40.0, ge=0.0) | ||
| 177 | confidence_max_height_std_m: float = pydantic.Field(0.2, ge=0.0, le=1.0) | ||
| 178 | |||
| 179 | # Memory hardening (deployment target is a 32 GB RAM Azure node). | ||
| 180 | memory_budget_gb: float = pydantic.Field(10.0, ge=0.0) | ||
| 181 | station_process_window_m: float = pydantic.Field(5.0, ge=0.0) | ||
| 182 | decimation_enabled: bool = False | ||
| 183 | decimation_voxel_m: float = pydantic.Field(0.05, ge=0.0) | ||
| 184 | decimation_density_cap: int = pydantic.Field(400000, ge=1) | ||
| 185 | # Records larger than this stream through the corridor crop in chunks of | ||
| 186 | # this many points instead of being materialized whole (byte-identical | ||
| 187 | # results for records at or below the threshold, which use the old path). | ||
| 188 | record_chunk_points: int = pydantic.Field(4000000, ge=1) | ||
| 189 | # Exclusion clustering guard: DBSCAN memory scales with the number of | ||
| 190 | # eps-neighbour pairs. When a cheap grid estimate of that count exceeds | ||
| 191 | # this cap the exclusion candidates are voxel-decimated first (auto-trigger | ||
| 192 | # only; sparse segments are untouched). segment_134's dense record | ||
| 193 | # estimated 4.0e9 pairs (25 GB RSS); curated segments peak at 6.3e8. | ||
| 194 | exclusion_pair_estimate_max: float = pydantic.Field(1000000000.0, ge=0.0) | ||
| 195 | exclusion_decimation_cell_m: float = pydantic.Field(0.10, ge=0.0) | ||
| 196 | # After the density trigger decimates, the residual DBSCAN runs under the | ||
| 197 | # shared iolabs.common.memory_guard watchdog (subprocess + psutil RSS | ||
| 198 | # monitor, hard kill above the limit) as a second line of defense. Mirrors | ||
| 199 | # the subcluster_dbscan_memory_guard wiring in | ||
| 200 | # iolabs_point_cloud_modelling_lines / iolabs_geometry_geometry.fit_spline. | ||
| 201 | exclusion_use_shared_watchdog: bool = True | ||
| 202 | exclusion_dbscan_mem_limit_gb: float = pydantic.Field(6.0, ge=0.0) | ||
| 203 | exclusion_dbscan_timeout_s: float = pydantic.Field(120.0, ge=0.0) | ||
| 204 | |||
| 205 | |||
| 0 |
| 1 | """Post cadence, beam-underside and top-member field declarations. | ||
| 2 | |||
| 3 | One schema slice of the guardrails detector config; the slices are recombined | ||
| 4 | into the model by :mod:`guardrails._config_model`. The split exists only to | ||
| 5 | keep every module under 500 lines — these classes carry no behaviour, and the | ||
| 6 | config schema is still the flat key set of ``guardrails.default.json``. | ||
| 7 | |||
| 8 | Adding a config key means adding the field here and the same key to | ||
| 9 | ``guardrails.default.json`` — nothing else. Unknown keys are rejected. | ||
| 10 | """ | ||
| 11 | |||
| 12 | from typing import Literal, TypeAlias | ||
| 13 | |||
| 14 | import pydantic | ||
| 15 | from iolabs.common import config_loader | ||
| 16 | |||
| 17 | #: Where the measured top member's rows are emitted (also an argparse choice). | ||
| 18 | TopMemberType: TypeAlias = Literal["guardrail_top_rail", "guardrail_support", "w_beam"] | ||
| 19 | |||
| 20 | |||
| 21 | class DetectorPostConfig(config_loader.ConfigModel): | ||
| 22 | """Post cadence, beam-underside and top-member levers.""" | ||
| 23 | |||
| 24 | # Guardrail rail-vs-support decomposition (post cadence + support class). | ||
| 25 | # Height cut lines are literature-derived (Swiss/German hardware: rail band | ||
| 26 | # top edge ~0.75 m, Sigma-100 post 100x55 mm, ASTRA 11005 post spacings | ||
| 27 | # 1.33 / 2.00 m and DDSP 4.00 m), not yet tuned on our clouds; keep in | ||
| 28 | # config. All three feature flags default true; setting them false restores | ||
| 29 | # the pre-feature behavior exactly. | ||
| 30 | enable_post_cadence: bool = True | ||
| 31 | enable_support_class: bool = True | ||
| 32 | enable_component_masks: bool = True | ||
| 33 | post_low_band_min_m: float = pydantic.Field(0.10, ge=0.0) | ||
| 34 | post_low_band_max_m: float = pydantic.Field(0.35, ge=0.0) | ||
| 35 | post_station_bin_m: float = pydantic.Field(0.10, ge=0.0) | ||
| 36 | post_lateral_halfwidth_m: float = pydantic.Field(0.60, ge=0.0) | ||
| 37 | post_catalog_spacings_m: tuple[float, ...] = (1.33, 2.0, 4.0) | ||
| 38 | post_spacing_snap_rel_tol: float = pydantic.Field(0.12, ge=0.0, le=1.0) | ||
| 39 | post_min_period_m: float = pydantic.Field(0.8, ge=0.0) | ||
| 40 | post_max_period_m: float = pydantic.Field(6.0, ge=0.0) | ||
| 41 | post_min_confidence: float = pydantic.Field(0.35, ge=0.0, le=1.0) | ||
| 42 | post_slot_min_points: int = pydantic.Field(3, ge=1) | ||
| 43 | # Per-post peak detection (``posts.detect_run_posts``). The run-level comb | ||
| 44 | # (``post_min_confidence``) is only a scoring prior now: on a long rail the | ||
| 45 | # low band also carries continuous grass/plinth clutter, which drowns the | ||
| 46 | # comb contrast, so posts are accepted individually against a ROLLING local | ||
| 47 | # background instead of all-or-nothing against the run mean. | ||
| 48 | post_peak_smooth_m: float = pydantic.Field(0.3, ge=0.0) | ||
| 49 | post_peak_background_window_m: float = pydantic.Field(5.0, ge=0.0) | ||
| 50 | post_peak_min_prominence: float = pydantic.Field(3.0, ge=0.0) | ||
| 51 | # A dense low band is also a NOISY one: at b points per smoothing window the | ||
| 52 | # Poisson swing is sqrt(b), so a fixed point floor would fabricate posts out | ||
| 53 | # of grass on exactly the cluttered runs this feature exists for. The | ||
| 54 | # effective floor is max(post_peak_min_prominence, sigmas * sqrt(background)). | ||
| 55 | post_peak_noise_sigmas: float = pydantic.Field(3.0, ge=0.0) | ||
| 56 | post_peak_min_confidence: float = pydantic.Field(0.25, ge=0.0, le=1.0) | ||
| 57 | # Wider above-background blobs are plinths / kerbs / parked clutter, not a | ||
| 58 | # 0.10 m post footprint. Measured at half prominence (see detect_run_posts). | ||
| 59 | post_max_station_extent_m: float = pydantic.Field(0.45, ge=0.0) | ||
| 60 | # Measured post top is clamped to [rail band bottom, beam bottom + margin]. | ||
| 61 | post_top_margin_m: float = pydantic.Field(0.10, ge=0.0) | ||
| 62 | # Behind-beam shaft claim: a post-footprint point this far outboard of the | ||
| 63 | # rail's LOCAL centerline (not of its run-mean offset — a 50 m polyline | ||
| 64 | # wanders further off its own mean than this threshold, which made the | ||
| 65 | # first cut of this rule inert on every curved run) sits on the far side of | ||
| 66 | # the beam from the road, so it is post shaft, not beam, and may be claimed | ||
| 67 | # up to the rail top. The threshold is the larger of | ||
| 68 | # ``post_behind_beam_offset_m`` (half a w-beam depth plus a margin: the | ||
| 69 | # floor, and what a rail with no measured width gets) and half the rail's | ||
| 70 | # ``width_m`` plus ``post_behind_beam_margin_m`` (what a wide rail needs). | ||
| 71 | post_claim_behind_beam: bool = True | ||
| 72 | post_behind_beam_offset_m: float = pydantic.Field(0.22, ge=0.0) | ||
| 73 | post_behind_beam_margin_m: float = pydantic.Field(0.05, ge=0.0) | ||
| 74 | # ... and once the post line itself is MEASURED (``_measured_post_side``), | ||
| 75 | # the threshold moves off that generic floor onto the hardware: the post's | ||
| 76 | # front face is ``|post_lat| - post_behind_beam_front_margin_m`` (an | ||
| 77 | # IPE-100 flange at 0.05 m plus the spacer that holds the plank off it), | ||
| 78 | # never nearer than the beam's own edge. The floor costs the A4/5 105 | ||
| 79 | # median rails half their shaft: post line at 0.24-0.25 m against a 0.22 m | ||
| 80 | # threshold leaves the spacer and the post's road-side half to the rail. | ||
| 81 | post_behind_beam_front_margin_m: float = pydantic.Field(0.10, ge=0.0) | ||
| 82 | # Where a measured post is PUT: the parent polyline at that post's station, | ||
| 83 | # displaced by ``post.offset_m`` minus the polyline's OWN spine offset there | ||
| 84 | # (round 7). With this off the displacement is measured against the run's | ||
| 85 | # constant ``mean_offset_m`` instead -- the round-6 behaviour, kept only so | ||
| 86 | # the flags-off byte-identity replay has something to compare against. On a | ||
| 87 | # run that wanders (A4/5 105 rail 1: 0.69 m end to end) the mean form walks | ||
| 88 | # the published post train diagonally across its own rail. | ||
| 89 | post_xy_local_offset_enabled: bool = True | ||
| 90 | # Measured per-rail beam underside (``posts.measure_beam_bottom``). The | ||
| 91 | # evidence pass folds a HEIGHT histogram over [post_low_band_min_m, | ||
| 92 | # beam_bottom_hist_max_m] alongside the station histogram, scoped to a | ||
| 93 | # tighter lateral halfwidth than the post band (the beam sits on the run's | ||
| 94 | # mean offset; kerb / soil returns further out only blur the onset). | ||
| 95 | # ``beam_bottom_hist_bin_m`` divides the distance from | ||
| 96 | # ``post_low_band_min_m`` to 0.35 / 0.75 / 0.85 exactly, so the rail band | ||
| 97 | # floor and the plausibility cap fall on bin edges rather than inside a bin. | ||
| 98 | beam_bottom_hist_bin_m: float = pydantic.Field(0.025, ge=0.0) | ||
| 99 | # Ceiling of that histogram. 1.30 m (= ``max_height_m``, 48 bins from the | ||
| 100 | # 0.10 m floor) rather than the 1.00 m of rounds 3-6: the beam TOP walk-up | ||
| 101 | # and ``detect_top_member`` both need headroom ABOVE the structure to tell | ||
| 102 | # a bounded member (a Kastenprofil tube: mass ends at 0.98 m and there is | ||
| 103 | # nothing over it) from an unbounded one (a noise wall / hedge / parapet, | ||
| 104 | # which keeps going). At 1.00 m every A4/5 median tube reported | ||
| 105 | # ``truncated`` against what was really the knob, not the cloud. | ||
| 106 | # ``measure_beam_bottom`` is provably unchanged by the raise: its window is | ||
| 107 | # ``component_rail_band_m`` = [0.35, 0.85) and its walk is downward only, | ||
| 108 | # so bins added above cannot move the scale, the dense groups or the | ||
| 109 | # underside. | ||
| 110 | beam_bottom_hist_max_m: float = pydantic.Field(1.30, ge=0.0) | ||
| 111 | beam_bottom_lateral_halfwidth_m: float = pydantic.Field(0.40, ge=0.0) | ||
| 112 | # A candidate beam band is a contiguous group of bins carrying at least this | ||
| 113 | # fraction of the tallest bin in the rail band. Candidates are tried lowest | ||
| 114 | # first (a stacked double w-beam has two, and the upper one is often the | ||
| 115 | # taller), but only TRIED: the low band's own tail can clear this floor and | ||
| 116 | # group up below the beam, and on A4/5 066 rail 5 it does. | ||
| 117 | beam_bottom_band_fraction: float = pydantic.Field(0.15, ge=0.0, le=1.0) | ||
| 118 | # Walking down from a candidate's peak, the underside is where the count | ||
| 119 | # first drops below this fraction of the peak bin. | ||
| 120 | beam_bottom_onset_fraction: float = pydantic.Field(0.20, ge=0.0, le=1.0) | ||
| 121 | # ... and the drop has to be a STEP, not a drift across that threshold. A | ||
| 122 | # continuous barrier mistyped w_beam (A4/5 066 rail 2) has no underside at | ||
| 123 | # all, only a smooth ramp, and any walk-down threshold stops somewhere | ||
| 124 | # arbitrary in it. The knob sits in the gap the A4/5 rails measure out | ||
| 125 | # between two populations: the nine rails that do carry a beam step | ||
| 126 | # 2.00-54x at their onset (the 2.00 is 132 rail 0), while on the seven that | ||
| 127 | # do not, the strongest single-bin rise ANYWHERE in the rail band is 1.67x | ||
| 128 | # — and that is already a harder test than this guard, which only ever | ||
| 129 | # looks at the bin the walk stopped on. | ||
| 130 | beam_bottom_min_onset_ratio: float = pydantic.Field(1.8, ge=0.0) | ||
| 131 | beam_bottom_min_peak_points: int = pydantic.Field(50, ge=1) | ||
| 132 | # Round 7: the same walk, upwards, giving the beam TOP -- and with it the | ||
| 133 | # shaft cap the claim should always have used. Gates the MEASUREMENT (the | ||
| 134 | # walk in ``_band_underside``, hence ``detect_top_member``'s precondition | ||
| 135 | # and the shaft cap's preference) as well as the PUBLICATION | ||
| 136 | # (``beam_bottom.top_height_m`` / ``top_measured`` / ``reason_top`` and | ||
| 137 | # ``polyline_beam_top_z_m``), so with it off guardrails.json is | ||
| 138 | # byte-identical to the round-6 one and no member can be detected. | ||
| 139 | post_beam_top_enabled: bool = True | ||
| 140 | # Plausibility window for the result: below ``component_rail_band_m[0]`` it | ||
| 141 | # is not beam (no rail evidence is counted there), above this it is a | ||
| 142 | # gantry / sign / noise wall, not a w-beam underside. | ||
| 143 | beam_bottom_max_m: float = pydantic.Field(0.75, ge=0.0) | ||
| 144 | # Beam band [bottom, top] above the road, used as the fallback when a rail | ||
| 145 | # instance carries no measured ``polyline_bottom_z_m`` / ``polyline_top_z_m``. | ||
| 146 | component_rail_band_m: tuple[float, float] = (0.35, 0.85) | ||
| 147 | component_support_max_height_m: float = pydantic.Field(0.50, ge=0.0) | ||
| 148 | component_support_station_tol_m: float = pydantic.Field(0.20, ge=0.0) | ||
| 149 | component_support_footprint_m: float = pydantic.Field(0.25, ge=0.0) | ||
| 150 | |||
| 151 | # --- Round 7: the top member (the Kastenprofil box tube on the A4/5 | ||
| 152 | # median rails). ``detect_top_member`` measures the band ABOVE the beam | ||
| 153 | # top, and the two load-bearing gates are the mass fraction and the | ||
| 154 | # STATION COVERAGE: mass alone accepts a 27 m stub of vegetation behind a | ||
| 155 | # rail (A4/5 066 rail 1, mass fraction 0.44), and only "is this band there | ||
| 156 | # at every station of the run" rejects it (coverage 0.57 against 1.00 on | ||
| 157 | # all four real tubes). | ||
| 158 | post_top_member_enabled: bool = True | ||
| 159 | # Where the tube's rows go. "guardrail_top_rail" (default) emits the | ||
| 160 | # companion instance and LAS 74; "guardrail_support" folds them into the | ||
| 161 | # parent's support instance (LAS 72); "w_beam" leaves them on the parent | ||
| 162 | # rail (LAS 66). The last two emit no companion instance, so the fusion | ||
| 163 | # JSON paint has nothing to read and only the mask sidecar carries them. | ||
| 164 | post_top_member_type: TopMemberType = "guardrail_top_rail" | ||
| 165 | # Mass above the measured beam top, over the mass in the rail window. | ||
| 166 | # Measured 0.49-0.53 on the four A4/5 tubes; 0.002-0.066 on nine of the | ||
| 167 | # twelve rails without one, 0.39-0.44 on the two 066 outliers coverage | ||
| 168 | # rejects. | ||
| 169 | post_top_member_min_mass_fraction: float = pydantic.Field(0.15, ge=0.0, le=1.0) | ||
| 170 | # A member is "the thing above the post line", so there has to be a post | ||
| 171 | # line: below this many measured posts the run reports ``no_posts``. | ||
| 172 | post_top_member_min_posts: int = pydantic.Field(2, ge=1) | ||
| 173 | # A bin is part of the band when it carries this fraction of the tallest | ||
| 174 | # bin above the beam top; the band is the contiguous dense group with the | ||
| 175 | # largest MASS (not the topmost one -- with the 1.30 m ceiling that picks | ||
| 176 | # a blob 0.30 m over the beam on A4/5 105 rail 3). | ||
| 177 | post_top_member_band_fraction: float = pydantic.Field(0.15, ge=0.0, le=1.0) | ||
| 178 | # Reported, not enforced (a thin band that is present at every station is | ||
| 179 | # still a member; the real discriminators are mass and coverage). | ||
| 180 | post_top_member_min_thickness_m: float = pydantic.Field(0.075, ge=0.0) | ||
| 181 | # A station bin counts as covered when the band carries this many points | ||
| 182 | # in it, over the station bins that carry any point of the run's slab. | ||
| 183 | post_top_member_min_bin_points: int = pydantic.Field(3, ge=1) | ||
| 184 | post_top_member_min_coverage: float = pydantic.Field(0.90, ge=0.0, le=1.0) | ||
| 185 | # Colocation with the measured post line, and the band's own lateral | ||
| 186 | # spread. Both are REPORTED on every rail; the gate is off by default | ||
| 187 | # (mass + coverage already separate the two populations by 0.33 of | ||
| 188 | # coverage, and three rails without a tube pass the lateral test anyway). | ||
| 189 | post_top_member_lateral_gate_enabled: bool = False | ||
| 190 | # ``post_top_member_max_lateral_offset_m`` is enforced whatever that flag | ||
| 191 | # says in ONE place: the prism's axis. A post median that disagrees with | ||
| 192 | # the band's own measured lateral by more than this is not the line the | ||
| 193 | # member runs along, and sweeping a full-length 0.25 m prism down it would | ||
| 194 | # paint whatever stands behind the rail (see ``_top_rail_geometry``). | ||
| 195 | post_top_member_max_lateral_offset_m: float = pydantic.Field(0.12, ge=0.0) | ||
| 196 | post_top_member_max_lateral_spread_m: float = pydantic.Field(0.15, ge=0.0) | ||
| 197 | # Halfwidth of the swept prism that claims the tube, about the robust post | ||
| 198 | # line. The measured 2-98 percentile lateral extent of the four A4/5 tubes | ||
| 199 | # about that line is within [-0.20, +0.17] m. | ||
| 200 | post_top_member_halfwidth_m: float = pydantic.Field(0.25, ge=0.0) | ||
| 201 | # --- Round 7: the behind-beam outward sign, from the MEASURED post side. | ||
| 202 | # ``sign(mean_offset_m)`` assumes the posts are always further from the | ||
| 203 | # spine than the beam; on the A4/5 median rails that is true on only half | ||
| 204 | # of them, and the shaft claim is completely dead on the other half. The | ||
| 205 | # three guards are what keep every rail whose posts sit ON the line (the | ||
| 206 | # outer rails: |side| 0.004-0.079) on the old sign, bit for bit. | ||
| 207 | post_behind_beam_use_measured_side: bool = True | ||
| 208 | post_behind_beam_min_post_offset_m: float = pydantic.Field(0.10, ge=0.0) | ||
| 209 | post_behind_beam_min_posts: int = pydantic.Field(4, ge=1) | ||
| 210 | post_behind_beam_min_side_agreement: float = pydantic.Field(0.70, ge=0.0, le=1.0) | ||
| 0 |
| 1 | """Noise-wall and overlay field declarations for :class:`DetectorConfig`. | ||
| 2 | |||
| 3 | One schema slice of the guardrails detector config; the slices are recombined | ||
| 4 | into the model by :mod:`guardrails._config_model`. The split exists only to | ||
| 5 | keep every module under 500 lines — these classes carry no behaviour, and the | ||
| 6 | config schema is still the flat key set of ``guardrails.default.json``. | ||
| 7 | |||
| 8 | Adding a config key means adding the field here and the same key to | ||
| 9 | ``guardrails.default.json`` — nothing else. Unknown keys are rejected. | ||
| 10 | """ | ||
| 11 | |||
| 12 | import pydantic | ||
| 13 | from iolabs.common import config_loader | ||
| 14 | |||
| 15 | |||
| 16 | class DetectorWallConfig(config_loader.ConfigModel): | ||
| 17 | """Noise-wall detection, wall-view fit overrides and wall-only gates.""" | ||
| 18 | |||
| 19 | # Wall detection: independent evidence/fitting channel (see README "Noise | ||
| 20 | # walls"). ``wall_detection_enabled=False`` is a process-level kill switch; | ||
| 21 | # it emits ``"walls": []`` and allocates no wall grids. | ||
| 22 | wall_detection_enabled: bool = True | ||
| 23 | wall_cell_m: float = pydantic.Field(0.25, ge=0.0) | ||
| 24 | wall_height_bin_m: float = pydantic.Field(0.25, ge=0.0) | ||
| 25 | wall_min_height_m: float = pydantic.Field(0.30, ge=0.0) | ||
| 26 | wall_max_height_m: float = pydantic.Field(8.00, ge=0.0) | ||
| 27 | wall_offset_min_m: float = pydantic.Field(1.50, ge=0.0) | ||
| 28 | # Dataset ground truth (segments 133-137; segment_135 confirmed walls near | ||
| 29 | # offset ~23 m) puts walls at spine offsets 21-25 m; 20.0 would miss them. | ||
| 30 | wall_offset_max_m: float = pydantic.Field(26.00, ge=0.0) | ||
| 31 | wall_min_cell_points: int = pydantic.Field(6, ge=1) | ||
| 32 | wall_min_top_height_m: float = pydantic.Field(2.50, ge=0.0) | ||
| 33 | wall_max_top_height_m: float = pydantic.Field(8.00, ge=0.0) | ||
| 34 | # Grazing-angle MLS returns are banded, not continuous: production | ||
| 35 | # segment_135 wall cells measured occupied-bin fill p10=0.040/p50=0.071. | ||
| 36 | wall_min_vertical_fill: float = pydantic.Field(0.05, ge=0.0) | ||
| 37 | # Per-cell minimum distinct occupied height bins; rejects single-scanline | ||
| 38 | # artifacts. | ||
| 39 | wall_min_occupied_bins: int = pydantic.Field(2, ge=1) | ||
| 40 | # Per-cell occupied-bin span (last - first occupied bin, inclusive) in | ||
| 41 | # metres: separates vertical-sheet wall cells (bins spread over metres) | ||
| 42 | # from grazing-angle surface/embankment cells banded within ~0.5 m. | ||
| 43 | wall_min_cell_height_span_m: float = pydantic.Field(1.5, ge=0.0) | ||
| 44 | |||
| 45 | # Wall-view overrides of the shared clustering/merge/fit config (see | ||
| 46 | # ``wall_view_config()``). | ||
| 47 | wall_cluster_eps_m: float = pydantic.Field(0.40, ge=0.0) | ||
| 48 | wall_cluster_min_samples: int = pydantic.Field(3, ge=1) | ||
| 49 | wall_merge_gap_m: float = pydantic.Field(4.50, ge=0.0) | ||
| 50 | wall_merge_angle_deg: float = pydantic.Field(8.0, ge=0.0) | ||
| 51 | wall_merge_lateral_max_m: float = pydantic.Field(1.00, ge=0.0) | ||
| 52 | # Real occluded walls (segment_135) show raw-data voids up to ~13.8 m; | ||
| 53 | # 14.0 keeps that structure bridgeable while the 4deg/0.4 m collinearity | ||
| 54 | # guards below still block unrelated fragments from fusing. | ||
| 55 | wall_occlusion_bridge_max_m: float = pydantic.Field(14.00, ge=0.0) | ||
| 56 | wall_occlusion_bridge_max_angle_deg: float = pydantic.Field(4.0, ge=0.0) | ||
| 57 | wall_occlusion_bridge_max_lateral_m: float = pydantic.Field(0.40, ge=0.0) | ||
| 58 | # Staggered noise-wall rows fit as separate ~14 m instances after polyline | ||
| 59 | # smoothing (segment_135: 14.86 m / 13.92 m); vegetation rejection is | ||
| 60 | # carried by the width/straightness/planarity/crest gates, not length. | ||
| 61 | wall_min_length_m: float = pydantic.Field(13.0, ge=0.0) | ||
| 62 | wall_max_local_width_m: float = pydantic.Field(1.80, ge=0.0) | ||
| 63 | wall_min_longitudinal_coverage: float = pydantic.Field(0.60, ge=0.0, le=1.0) | ||
| 64 | wall_max_cluster_height_spread_m: float = pydantic.Field(12.0, ge=0.0) | ||
| 65 | wall_max_cluster_p95_height_m: float = pydantic.Field(12.0, ge=0.0) | ||
| 66 | wall_straightness_window_m: float = pydantic.Field(10.0, ge=0.0) | ||
| 67 | wall_max_straightness_deviation_m: float = pydantic.Field(0.35, ge=0.0) | ||
| 68 | wall_straightness_max_length_m: float = pydantic.Field(25.0, ge=0.0) | ||
| 69 | # Sparse/occluded tail regions leave the wall polyline fit on banded, | ||
| 70 | # far-range evidence that meanders (segment_135); a stronger lateral | ||
| 71 | # smoothing window than the guardrail default (5) is needed to tame it. | ||
| 72 | wall_polyline_smooth_window: int = pydantic.Field(9, ge=1) | ||
| 73 | |||
| 74 | # Post-fit wall-only gates (crest profile, truck rejection, mandatory 3D | ||
| 75 | # PCA plane checks); not part of ``wall_view_config()``. | ||
| 76 | wall_profile_bin_m: float = pydantic.Field(1.00, ge=0.0) | ||
| 77 | # Real crest profiles ramp at their ends; a genuine structure was rejected | ||
| 78 | # by 0.005 m in production. Truck rejection is handled separately by the | ||
| 79 | # truck double-gate below. | ||
| 80 | wall_max_top_profile_spread_m: float = pydantic.Field(1.50, ge=0.0) | ||
| 81 | wall_truck_max_top_m: float = pydantic.Field(4.20, ge=0.0) | ||
| 82 | # EU max articulated truck length is ~18.75 m; 20.0 keeps the truck | ||
| 83 | # double-gate effective (top <= wall_truck_max_top_m AND length < this) | ||
| 84 | # while remaining just above that bound. | ||
| 85 | wall_truck_min_length_m: float = pydantic.Field(20.0, ge=0.0) | ||
| 86 | wall_min_planarity: float = pydantic.Field(0.55, ge=0.0, le=1.0) | ||
| 87 | wall_max_plane_normal_z_abs: float = pydantic.Field(0.35, ge=0.0) | ||
| 88 | # Grazing-angle MLS returns are height-banded (segment_135 row B: | ||
| 89 | # planarity=0.368, normal_z_abs=0.005): a clearly-vertical cell can sit | ||
| 90 | # just under the mandatory planarity ratio. Moderate planarity is | ||
| 91 | # accepted when the normal is unambiguously vertical. | ||
| 92 | wall_min_planarity_vertical: float = pydantic.Field(0.25, ge=0.0, le=1.0) | ||
| 93 | # Banded returns can also collapse to a line-degenerate (not plane-like) | ||
| 94 | # moment shape, making the plane normal numerically arbitrary | ||
| 95 | # (segment_135 row A: planarity=0.020, normal_z_abs=1.000, yet the | ||
| 96 | # moments are unambiguously line-like). A high linearity ratio plus a | ||
| 97 | # thin fitted width certifies a genuine vertical sheet without relying on | ||
| 98 | # that ill-conditioned normal. | ||
| 99 | wall_line_bypass_min_linearity: float = pydantic.Field(0.75, ge=0.0, le=1.0) | ||
| 100 | wall_line_bypass_max_width_m: float = pydantic.Field(1.0, ge=0.0) | ||
| 101 | |||
| 102 | # Carriageway rejection gate: a wall candidate between the carriageway | ||
| 103 | # edge-line guardrails is a vehicle (or bridge-deck returns sharing its | ||
| 104 | # cells), not a genuine noise wall (see README "Carriageway rejection | ||
| 105 | # gate"; production segment_135 false positive at offset -4.544 m). | ||
| 106 | wall_reject_inside_carriageway: bool = True | ||
| 107 | # Fallback minimum |mean_offset_m| for a wall when no same-side guardrail | ||
| 108 | # exists to compare against. | ||
| 109 | wall_min_abs_offset_m: float = pydantic.Field(6.0, ge=0.0) | ||
| 110 | # A wall may interleave up to this much inside the outermost same-side | ||
| 111 | # guardrail before being treated as inside the carriageway. | ||
| 112 | wall_outside_rail_margin_m: float = pydantic.Field(0.5, ge=0.0) | ||
| 113 | |||
| 114 | # A ground-standing wall's first returns start near the ground; a bottom-height | ||
| 115 | # profile starting above this is an elevated bridge parapet/deck structure | ||
| 116 | # measured from the wrong base. | ||
| 117 | wall_max_bottom_height_m: float = pydantic.Field(2.0, ge=0.0) | ||
| 118 | |||
| 119 | |||
| 120 | class DetectorOverlayConfig(config_loader.ConfigModel): | ||
| 121 | """Overlay kill switches shared with the perspective CLI.""" | ||
| 122 | |||
| 123 | # Overlay kill switches (also mirrored in ``PerspectiveConfig`` so the | ||
| 124 | # independent perspective CLI shares the same rollback behavior). | ||
| 125 | overlay_extent_enabled: bool = True | ||
| 126 | overlay_ground_model_diff_enabled: bool = False | ||
| 0 |
| 54 | "polyline_smooth_window": "wall_polyline_smooth_window", | 58 | "polyline_smooth_window": "wall_polyline_smooth_window", |
| 55 | } | 59 | } |
| 56 | 60 | ||
| 57 | 61 | ||
| 58 | class DetectorConfig( | ||
| 59 | _config_fields.CoreFields, | ||
| 60 | _config_fields.WallFields, | ||
| 61 | _config_fields_posts.PostFields, | ||
| 62 | _config_fields.OverlayFields, | ||
| 63 | ): | ||
| 64 | """Spatial and geometric thresholds, in metres unless stated otherwise. | ||
| 65 | |||
| 66 | The field set is declared by the mixins in | ||
| 67 | :mod:`guardrails._config_fields` / :mod:`guardrails._config_fields_posts` | ||
| 68 | and mirrors ``guardrails.default.json`` key for key; this class only adds | ||
| 69 | the cross-value checks that a declared field type cannot express. | ||
| 70 | """ | ||
| 71 | |||
| 72 | @pydantic.field_validator("residue_lever_band_m") | ||
| 73 | @classmethod | ||
| 74 | def _check_residue_band(cls, value: list[float]) -> list[float]: | ||
| 75 | """Reject a residue lever band that is not a ``[min_m, max_m]`` pair.""" | ||
| 76 | if len(value) != 2: | ||
| 77 | raise ValueError( | ||
| 78 | "residue_lever_band_m must contain exactly 2 values: [min_m, max_m]" | ||
| 79 | ) | ||
| 80 | return value | ||
| 81 | |||
| 82 | |||
| 83 | class DetectorConfigError(config_loader.ConfigError): | 62 | class DetectorConfigError(config_loader.ConfigError): |
| 84 | """Raised for an unsupported key or an invalid value in the guardrails config.""" | 63 | """Raised when guardrails config contains unsupported keys or values.""" |
| 85 | 64 | ||
| 86 | 65 | ||
| 87 | def load_default_config_dict() -> dict[str, Any]: | 66 | def load_default_config_dict() -> dict[str, Any]: |
| 88 | """Return the package-owned default config as a plain dict. | 67 | """Return the package-owned default config as a plain dict. |
| 89 | 68 | ||
| 90 | Returns: | 69 | Returns: |
| 91 | The decoded ``guardrails.default.json`` object. | 70 | The decoded ``guardrails.default.json`` object. |
| 92 | """ | 71 | """ |
| 93 | return config_loader.load_packaged_json( | 72 | return config_loader.load_packaged_json(_PACKAGE_NAME, _DEFAULT_FILENAME) |
| 94 | __package__ or _PACKAGE_NAME, _DEFAULT_CONFIG_NAME | ||
| 95 | ) | ||
| 96 | 73 | ||
| 97 | 74 | ||
| 98 | def config_from_dict(raw: dict[str, Any]) -> DetectorConfig: | 75 | def config_from_dict(raw: dict[str, Any]) -> DetectorConfig: |
| 99 | """Build a validated :class:`DetectorConfig` from a raw mapping. | 76 | """Build a validated :class:`DetectorConfig` from a raw mapping. |
| 100 | 77 | ||
| 101 | Unknown keys and values that do not fit their declared field type are | 78 | Unknown keys, values that do not fit their declared field type and values |
| 102 | rejected by the shared pydantic layer; the band-length rule on | 79 | outside a field's declared range are rejected by the shared pydantic layer. |
| 103 | ``residue_lever_band_m`` is the one guardrails-specific check that the | ||
| 104 | declared type ``list[float]`` cannot express. | ||
| 105 | 80 | ||
| 106 | Args: | 81 | Args: |
| 107 | raw: Merged config mapping (packaged defaults plus overrides). | 82 | raw: Merged config mapping (packaged defaults plus overrides). |
| 108 | 83 | ||
| 109 | Returns: | 84 | Returns: |
| 110 | The validated config. | 85 | The validated config. |
| 111 | 86 | ||
| 112 | Raises: | 87 | Raises: |
| 113 | DetectorConfigError: ``raw`` holds an unknown key, a value that is not | 88 | DetectorConfigError: ``raw`` holds an unknown key or a value that is |
| 114 | valid for its declared field type, or a ``residue_lever_band_m`` | 89 | not valid for its declared field type or range. |
| 115 | that is not a ``[min_m, max_m]`` pair. | ||
| 116 | """ | 90 | """ |
| 117 | return config_loader.validate_config( | 91 | return config_loader.validate_config( |
| 118 | DetectorConfig, | 92 | DetectorConfig, |
| 119 | raw, | 93 | raw, |
| 120 | context="guardrails config", | 94 | context=_CONTEXT, |
| 121 | error_cls=DetectorConfigError, | 95 | error_cls=DetectorConfigError, |
| 122 | ) | 96 | ) |
| 123 | 97 | ||
| 124 | 98 |
| 1 | """Field declarations for :class:`guardrails.config.DetectorConfig` (part 1). | ||
| 2 | |||
| 3 | Split out of ``config.py`` only to keep both modules under the 500-line limit: | ||
| 4 | the mixins here carry no behaviour, and the config schema is still the flat | ||
| 5 | key set of ``guardrails.default.json``. Part 2 (the post / beam / top-member | ||
| 6 | levers) lives in :mod:`guardrails._config_fields_posts`. | ||
| 7 | """ | ||
| 8 | |||
| 9 | from iolabs.common import config_loader | ||
| 10 | |||
| 11 | |||
| 12 | class CoreFields(config_loader.ConfigModel): | ||
| 13 | """Ground, corridor, candidate, cluster, fit and memory levers.""" | ||
| 14 | |||
| 15 | # Ground model | ||
| 16 | ground_cell_m: float = 0.75 | ||
| 17 | ground_percentile: float = 8.0 | ||
| 18 | |||
| 19 | # Corridor crop (station / offset frame) | ||
| 20 | corridor_offset_min_m: float = 1.5 | ||
| 21 | corridor_offset_max_m: float = 10.0 | ||
| 22 | corridor_include_median_zone: bool = True | ||
| 23 | median_corridor_offset_min_m: float = 0.8 | ||
| 24 | median_corridor_offset_max_m: float = 3.8 | ||
| 25 | corridor_max_height_m: float = 2.0 | ||
| 26 | station_window_m: float = 5.0 | ||
| 27 | median_side_max_offset_m: float = 3.5 | ||
| 28 | |||
| 29 | # Optional lane-XML carriageway / rail-zone scoping | ||
| 30 | lane_xml_zones_enabled: bool = True | ||
| 31 | lane_xml_path: str | None = None | ||
| 32 | rail_zone_margin_m: float = 10.0 | ||
| 33 | outer_rail_band_m: float = 20.0 | ||
| 34 | single_edge_rail_margin_m: float = 15.0 | ||
| 35 | max_carriageway_width_m: float = 15.0 | ||
| 36 | zone_bbox_margin_m: float = 140.0 | ||
| 37 | interior_rejection_depth_m: float = 2.0 | ||
| 38 | |||
| 39 | # Optional late edge gate: instance-level distance filters against the | ||
| 40 | # lane-XML edge lines (rules E1/E2), applied after the precision gate. | ||
| 41 | # edge_gate_max_rail_distance_m was calibrated on A1 segments 060/066/085: | ||
| 42 | # real rails measure <= 3.7 m from an XML edge, noise >= 5.4 m. | ||
| 43 | edge_gate_enabled: bool = True | ||
| 44 | edge_gate_max_rail_distance_m: float = 5.0 | ||
| 45 | edge_gate_interior_depth_m: float = 0.5 | ||
| 46 | edge_gate_interior_max_frac: float = 0.5 | ||
| 47 | edge_gate_apply_to_walls: bool = False | ||
| 48 | |||
| 49 | # Optional late precision gate over final rail/wall runs. | ||
| 50 | precision_gate_enabled: bool = True | ||
| 51 | precision_deep_interior_depth_m: float = 2.0 | ||
| 52 | precision_deep_interior_frac_min: float = 0.50 | ||
| 53 | precision_vehicle_max_length_m: float = 15.0 | ||
| 54 | precision_vehicle_min_density_per_m: float = 750.0 | ||
| 55 | precision_vehicle_min_mean_height_m: float = 0.80 | ||
| 56 | precision_low_max_mean_height_m: float = 0.35 | ||
| 57 | precision_sparse_max_density_per_m: float = 300.0 | ||
| 58 | precision_sparse_min_outboard_gap_m: float = 6.0 | ||
| 59 | precision_curve_min_line_rmse_m: float = 0.010 | ||
| 60 | precision_far_min_axis_dist_m: float = 18.0 | ||
| 61 | precision_long_low_min_length_m: float = 25.0 | ||
| 62 | precision_edge_beyond_frac_min: float = 0.25 | ||
| 63 | precision_dense_low_min_density_per_m: float = 2500.0 | ||
| 64 | precision_parallel_min_inboard_gap_m: float = 3.0 | ||
| 65 | precision_parallel_min_overlap_frac: float = 0.75 | ||
| 66 | precision_unknown_far_min_axis_dist_m: float = 20.0 | ||
| 67 | precision_very_far_min_outboard_gap_m: float = 12.0 | ||
| 68 | precision_very_far_min_axis_dist_m: float = 25.0 | ||
| 69 | precision_edge_abeam_window_m: float = 15.0 | ||
| 70 | precision_edge_outboard_epsilon_m: float = 0.30 | ||
| 71 | |||
| 72 | # Occupancy grid for candidate cells | ||
| 73 | occupancy_cell_m: float = 0.10 | ||
| 74 | |||
| 75 | # Height band for initial point candidates (also drives candidates overlay) | ||
| 76 | min_height_m: float = 0.20 | ||
| 77 | max_height_m: float = 1.30 | ||
| 78 | |||
| 79 | # Per-cell rail-band fraction and mean-height gates | ||
| 80 | rail_band_min_m: float = 0.35 | ||
| 81 | rail_band_max_m: float = 0.85 | ||
| 82 | min_cell_points: int = 3 | ||
| 83 | min_rail_points: int = 2 | ||
| 84 | min_rail_fraction: float = 0.40 | ||
| 85 | min_mean_height_m: float = 0.42 | ||
| 86 | max_mean_height_m: float = 0.78 | ||
| 87 | |||
| 88 | # Optional tablecloth-residue candidate lever | ||
| 89 | tablecloth_masks_dir: str | None = None | ||
| 90 | residue_union_enabled: bool = True | ||
| 91 | residue_cell_frac: float = 0.8 | ||
| 92 | residue_lever_band_m: list[float] = [0.30, 1.20] | ||
| 93 | |||
| 94 | # Vegetation rejection: compact height-above-ground spread within a cell | ||
| 95 | max_cell_height_spread_m: float = 0.50 | ||
| 96 | |||
| 97 | # Tall-object fraction per cell (trees, poles) | ||
| 98 | tall_min_m: float = 1.30 | ||
| 99 | tall_max_m: float = 4.50 | ||
| 100 | max_tall_fraction: float = 0.12 | ||
| 101 | |||
| 102 | # Local covariance / eigenvector candidate filter (cell-level) | ||
| 103 | eigen_neighborhood_radius_m: float = 0.40 | ||
| 104 | eigen_min_neighbors: int = 5 | ||
| 105 | min_linearity: float = 0.30 | ||
| 106 | min_verticality: float = 0.15 | ||
| 107 | use_eigen_cell_filter: bool = False | ||
| 108 | |||
| 109 | # DBSCAN clustering on selected occupancy cells | ||
| 110 | cluster_eps_m: float = 0.20 | ||
| 111 | cluster_min_samples: int = 3 | ||
| 112 | |||
| 113 | # Post-cluster merge of collinear fragments | ||
| 114 | merge_gap_m: float = 4.5 | ||
| 115 | merge_angle_deg: float = 15.0 | ||
| 116 | merge_lateral_max_m: float = 0.50 | ||
| 117 | |||
| 118 | # Occlusion bridging: join collinear fragments across a parked-vehicle / | ||
| 119 | # occlusion shadow when heading and offset stay continuous (defect 4). The | ||
| 120 | # bridged station interval is recorded in ``gap_spans`` (never interpolated | ||
| 121 | # silently). | ||
| 122 | # Default is conservative (8 m) so bridging never fuses two distinct | ||
| 123 | # barriers into one instance; raise via --set occlusion_bridge_max_m=15 for | ||
| 124 | # datasets with longer occlusion shadows. | ||
| 125 | occlusion_bridge_max_m: float = 8.0 | ||
| 126 | occlusion_bridge_max_angle_deg: float = 4.0 | ||
| 127 | occlusion_bridge_max_lateral_m: float = 0.40 | ||
| 128 | |||
| 129 | # Parallel-face deduplication (two faces of one physical rail). | ||
| 130 | # ``dedupe_*`` are retained for backward compatibility; the active policy is | ||
| 131 | # driven by ``merge_face_*`` (see README "Face / barrier merge policy"). | ||
| 132 | dedupe_face_max_sep_m: float = 1.0 | ||
| 133 | dedupe_max_angle_deg: float = 12.0 | ||
| 134 | merge_face_max_spacing_m: float = 1.3 | ||
| 135 | merge_face_max_heading_deg: float = 5.0 | ||
| 136 | merge_face_min_station_overlap: float = 0.5 | ||
| 137 | merge_face_max_faces: int = 2 | ||
| 138 | |||
| 139 | # Instance acceptance (applied after merge) | ||
| 140 | min_length_m: float = 12.0 | ||
| 141 | max_local_width_m: float = 0.75 | ||
| 142 | min_longitudinal_coverage: float = 0.35 | ||
| 143 | |||
| 144 | # Ordered-walk polyline construction | ||
| 145 | polyline_bin_m: float = 1.0 | ||
| 146 | polyline_smooth_window: int = 5 | ||
| 147 | walk_max_step_m: float = 0.30 | ||
| 148 | |||
| 149 | # Gap recording along station | ||
| 150 | gap_min_span_m: float = 2.0 | ||
| 151 | |||
| 152 | # Vehicle / occlusion-shadow rejection on cluster height distribution | ||
| 153 | max_cluster_height_spread_m: float = 0.80 | ||
| 154 | max_cluster_p95_height_m: float = 1.15 | ||
| 155 | |||
| 156 | # Straightness check along sliding window (short clusters only) | ||
| 157 | straightness_window_m: float = 10.0 | ||
| 158 | max_straightness_deviation_m: float = 0.50 | ||
| 159 | straightness_max_length_m: float = 25.0 | ||
| 160 | |||
| 161 | # Heuristic type classification thresholds | ||
| 162 | w_beam_min_height_m: float = 0.40 | ||
| 163 | w_beam_max_height_m: float = 0.90 | ||
| 164 | w_beam_max_height_spread_m: float = 0.55 | ||
| 165 | concrete_min_height_m: float = 0.80 | ||
| 166 | concrete_max_height_spread_m: float = 0.45 | ||
| 167 | cable_suspect_max_spread_m: float = 0.25 | ||
| 168 | |||
| 169 | # Per-run confidence heuristic (0-1); see README "Run confidence". | ||
| 170 | # confidence = 0.35*support + 0.25*continuity + 0.25*extent + 0.15*height | ||
| 171 | confidence_density_norm_pts_per_m: float = 500.0 | ||
| 172 | confidence_full_extent_m: float = 40.0 | ||
| 173 | confidence_max_height_std_m: float = 0.2 | ||
| 174 | |||
| 175 | # Memory hardening (deployment target is a 32 GB RAM Azure node). | ||
| 176 | memory_budget_gb: float = 10.0 | ||
| 177 | station_process_window_m: float = 5.0 | ||
| 178 | decimation_enabled: bool = False | ||
| 179 | decimation_voxel_m: float = 0.05 | ||
| 180 | decimation_density_cap: int = 400000 | ||
| 181 | # Records larger than this stream through the corridor crop in chunks of | ||
| 182 | # this many points instead of being materialized whole (byte-identical | ||
| 183 | # results for records at or below the threshold, which use the old path). | ||
| 184 | record_chunk_points: int = 4000000 | ||
| 185 | # Exclusion clustering guard: DBSCAN memory scales with the number of | ||
| 186 | # eps-neighbour pairs. When a cheap grid estimate of that count exceeds | ||
| 187 | # this cap the exclusion candidates are voxel-decimated first (auto-trigger | ||
| 188 | # only; sparse segments are untouched). segment_134's dense record | ||
| 189 | # estimated 4.0e9 pairs (25 GB RSS); curated segments peak at 6.3e8. | ||
| 190 | exclusion_pair_estimate_max: float = 1000000000.0 | ||
| 191 | exclusion_decimation_cell_m: float = 0.10 | ||
| 192 | # After the density trigger decimates, the residual DBSCAN runs under the | ||
| 193 | # shared iolabs.common.memory_guard watchdog (subprocess + psutil RSS | ||
| 194 | # monitor, hard kill above the limit) as a second line of defense. Mirrors | ||
| 195 | # the subcluster_dbscan_memory_guard wiring in | ||
| 196 | # iolabs_point_cloud_modelling_lines / iolabs_geometry_geometry.fit_spline. | ||
| 197 | exclusion_use_shared_watchdog: bool = True | ||
| 198 | exclusion_dbscan_mem_limit_gb: float = 6.0 | ||
| 199 | exclusion_dbscan_timeout_s: float = 120.0 | ||
| 200 | |||
| 201 | |||
| 202 | class WallFields(config_loader.ConfigModel): | ||
| 203 | """Noise-wall detection, wall-view fit overrides and wall-only gates.""" | ||
| 204 | |||
| 205 | # Wall detection: independent evidence/fitting channel (see README "Noise | ||
| 206 | # walls"). ``wall_detection_enabled=False`` is a process-level kill switch; | ||
| 207 | # it emits ``"walls": []`` and allocates no wall grids. | ||
| 208 | wall_detection_enabled: bool = True | ||
| 209 | wall_cell_m: float = 0.25 | ||
| 210 | wall_height_bin_m: float = 0.25 | ||
| 211 | wall_min_height_m: float = 0.30 | ||
| 212 | wall_max_height_m: float = 8.00 | ||
| 213 | wall_offset_min_m: float = 1.50 | ||
| 214 | # Dataset ground truth (segments 133-137; segment_135 confirmed walls near | ||
| 215 | # offset ~23 m) puts walls at spine offsets 21-25 m; 20.0 would miss them. | ||
| 216 | wall_offset_max_m: float = 26.00 | ||
| 217 | wall_min_cell_points: int = 6 | ||
| 218 | wall_min_top_height_m: float = 2.50 | ||
| 219 | wall_max_top_height_m: float = 8.00 | ||
| 220 | # Grazing-angle MLS returns are banded, not continuous: production | ||
| 221 | # segment_135 wall cells measured occupied-bin fill p10=0.040/p50=0.071. | ||
| 222 | wall_min_vertical_fill: float = 0.05 | ||
| 223 | # Per-cell minimum distinct occupied height bins; rejects single-scanline | ||
| 224 | # artifacts. | ||
| 225 | wall_min_occupied_bins: int = 2 | ||
| 226 | # Per-cell occupied-bin span (last - first occupied bin, inclusive) in | ||
| 227 | # metres: separates vertical-sheet wall cells (bins spread over metres) | ||
| 228 | # from grazing-angle surface/embankment cells banded within ~0.5 m. | ||
| 229 | wall_min_cell_height_span_m: float = 1.5 | ||
| 230 | |||
| 231 | # Wall-view overrides of the shared clustering/merge/fit config (see | ||
| 232 | # ``wall_view_config()``). | ||
| 233 | wall_cluster_eps_m: float = 0.40 | ||
| 234 | wall_cluster_min_samples: int = 3 | ||
| 235 | wall_merge_gap_m: float = 4.50 | ||
| 236 | wall_merge_angle_deg: float = 8.0 | ||
| 237 | wall_merge_lateral_max_m: float = 1.00 | ||
| 238 | # Real occluded walls (segment_135) show raw-data voids up to ~13.8 m; | ||
| 239 | # 14.0 keeps that structure bridgeable while the 4deg/0.4 m collinearity | ||
| 240 | # guards below still block unrelated fragments from fusing. | ||
| 241 | wall_occlusion_bridge_max_m: float = 14.00 | ||
| 242 | wall_occlusion_bridge_max_angle_deg: float = 4.0 | ||
| 243 | wall_occlusion_bridge_max_lateral_m: float = 0.40 | ||
| 244 | # Staggered noise-wall rows fit as separate ~14 m instances after polyline | ||
| 245 | # smoothing (segment_135: 14.86 m / 13.92 m); vegetation rejection is | ||
| 246 | # carried by the width/straightness/planarity/crest gates, not length. | ||
| 247 | wall_min_length_m: float = 13.0 | ||
| 248 | wall_max_local_width_m: float = 1.80 | ||
| 249 | wall_min_longitudinal_coverage: float = 0.60 | ||
| 250 | wall_max_cluster_height_spread_m: float = 12.0 | ||
| 251 | wall_max_cluster_p95_height_m: float = 12.0 | ||
| 252 | wall_straightness_window_m: float = 10.0 | ||
| 253 | wall_max_straightness_deviation_m: float = 0.35 | ||
| 254 | wall_straightness_max_length_m: float = 25.0 | ||
| 255 | # Sparse/occluded tail regions leave the wall polyline fit on banded, | ||
| 256 | # far-range evidence that meanders (segment_135); a stronger lateral | ||
| 257 | # smoothing window than the guardrail default (5) is needed to tame it. | ||
| 258 | wall_polyline_smooth_window: int = 9 | ||
| 259 | |||
| 260 | # Post-fit wall-only gates (crest profile, truck rejection, mandatory 3D | ||
| 261 | # PCA plane checks); not part of ``wall_view_config()``. | ||
| 262 | wall_profile_bin_m: float = 1.00 | ||
| 263 | # Real crest profiles ramp at their ends; a genuine structure was rejected | ||
| 264 | # by 0.005 m in production. Truck rejection is handled separately by the | ||
| 265 | # truck double-gate below. | ||
| 266 | wall_max_top_profile_spread_m: float = 1.50 | ||
| 267 | wall_truck_max_top_m: float = 4.20 | ||
| 268 | # EU max articulated truck length is ~18.75 m; 20.0 keeps the truck | ||
| 269 | # double-gate effective (top <= wall_truck_max_top_m AND length < this) | ||
| 270 | # while remaining just above that bound. | ||
| 271 | wall_truck_min_length_m: float = 20.0 | ||
| 272 | wall_min_planarity: float = 0.55 | ||
| 273 | wall_max_plane_normal_z_abs: float = 0.35 | ||
| 274 | # Grazing-angle MLS returns are height-banded (segment_135 row B: | ||
| 275 | # planarity=0.368, normal_z_abs=0.005): a clearly-vertical cell can sit | ||
| 276 | # just under the mandatory planarity ratio. Moderate planarity is | ||
| 277 | # accepted when the normal is unambiguously vertical. | ||
| 278 | wall_min_planarity_vertical: float = 0.25 | ||
| 279 | # Banded returns can also collapse to a line-degenerate (not plane-like) | ||
| 280 | # moment shape, making the plane normal numerically arbitrary | ||
| 281 | # (segment_135 row A: planarity=0.020, normal_z_abs=1.000, yet the | ||
| 282 | # moments are unambiguously line-like). A high linearity ratio plus a | ||
| 283 | # thin fitted width certifies a genuine vertical sheet without relying on | ||
| 284 | # that ill-conditioned normal. | ||
| 285 | wall_line_bypass_min_linearity: float = 0.75 | ||
| 286 | wall_line_bypass_max_width_m: float = 1.0 | ||
| 287 | |||
| 288 | # Carriageway rejection gate: a wall candidate between the carriageway | ||
| 289 | # edge-line guardrails is a vehicle (or bridge-deck returns sharing its | ||
| 290 | # cells), not a genuine noise wall (see README "Carriageway rejection | ||
| 291 | # gate"; production segment_135 false positive at offset -4.544 m). | ||
| 292 | wall_reject_inside_carriageway: bool = True | ||
| 293 | # Fallback minimum |mean_offset_m| for a wall when no same-side guardrail | ||
| 294 | # exists to compare against. | ||
| 295 | wall_min_abs_offset_m: float = 6.0 | ||
| 296 | # A wall may interleave up to this much inside the outermost same-side | ||
| 297 | # guardrail before being treated as inside the carriageway. | ||
| 298 | wall_outside_rail_margin_m: float = 0.5 | ||
| 299 | |||
| 300 | # A ground-standing wall's first returns start near the ground; a bottom-height | ||
| 301 | # profile starting above this is an elevated bridge parapet/deck structure | ||
| 302 | # measured from the wrong base. | ||
| 303 | wall_max_bottom_height_m: float = 2.0 | ||
| 304 | |||
| 305 | |||
| 306 | class OverlayFields(config_loader.ConfigModel): | ||
| 307 | """Overlay kill switches shared with the perspective CLI.""" | ||
| 308 | |||
| 309 | # Overlay kill switches (also mirrored in ``PerspectiveConfig`` so the | ||
| 310 | # independent perspective CLI shares the same rollback behavior). | ||
| 311 | overlay_extent_enabled: bool = True | ||
| 312 | overlay_ground_model_diff_enabled: bool = False | ||
| 0 |
| 1 | """Schema of the guardrails detector config. | ||
| 2 | |||
| 3 | The schema is `DetectorConfig` (a `config_loader.ConfigModel`), mirroring | ||
| 4 | `guardrails.default.json` key for key. It is assembled from the field slices | ||
| 5 | in :mod:`guardrails._model_core`, :mod:`guardrails._model_wall` and | ||
| 6 | :mod:`guardrails._model_posts`, split out only to keep every module under 500 | ||
| 7 | lines; loading, merging and ``--set`` handling live in :mod:`guardrails.config`. | ||
| 8 | |||
| 9 | Adding a config key means adding the field to the matching slice and the same | ||
| 10 | key to `guardrails.default.json` — nothing else. Unknown keys are rejected. | ||
| 11 | """ | ||
| 12 | |||
| 13 | import logging | ||
| 14 | |||
| 15 | from . import _model_core, _model_posts, _model_wall | ||
| 16 | |||
| 17 | logger = logging.getLogger(__name__) | ||
| 18 | |||
| 19 | |||
| 20 | class DetectorConfig( | ||
| 21 | _model_core.DetectorCoreConfig, | ||
| 22 | _model_wall.DetectorWallConfig, | ||
| 23 | _model_posts.DetectorPostConfig, | ||
| 24 | _model_wall.DetectorOverlayConfig, | ||
| 25 | ): | ||
| 26 | """Spatial and geometric thresholds, in metres unless stated otherwise. | ||
| 27 | |||
| 28 | The field set is declared by the slices in :mod:`guardrails._model_core`, | ||
| 29 | :mod:`guardrails._model_wall` and :mod:`guardrails._model_posts`, and | ||
| 30 | mirrors ``guardrails.default.json`` key for key; ranges and choices are | ||
| 31 | declared on the fields themselves. | ||
| 32 | """ | ||
| 0 |
| 1 | """Detector configuration. | 1 | """Loading, merging and ``--set`` handling for the guardrails detector config. |
| 2 | 2 | ||
| 3 | Mirrors the config convention used by the iolabs point-cloud packages | 3 | The schema is `DetectorConfig` (a `config_loader.ConfigModel`, assembled in |
| 4 | (``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a | 4 | :mod:`guardrails._config_model`), mirroring `guardrails.default.json` key for |
| 5 | ``guardrails.default.json`` algorithm config, and a typed params object | 5 | key: unknown keys are rejected and raw JSON / ``--set`` values are coerced to |
| 6 | (:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are | 6 | the declared field types by the shared layer. Runtime overrides come from |
| 7 | applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON. | 7 | repeatable ``--set PATH=VALUE`` flags, never repo-local JSON. |
| 8 | 8 | ||
| 9 | The schema is the pydantic model :class:`DetectorConfig`, derived from | 9 | Adding a config key means adding the field to the model (in the matching |
| 10 | :class:`iolabs.common.config_loader.ConfigModel`: unknown keys are rejected and | 10 | `guardrails._model_*` slice) and the same key to `guardrails.default.json` — |
| 11 | raw JSON / ``--set`` values are coerced to the declared field types by the | 11 | nothing else. Unknown keys are rejected. |
| 12 | shared layer. Every field default is kept identical to | ||
| 13 | ``guardrails.default.json`` (guarded by a unit test), so ``DetectorConfig()`` | ||
| 14 | and :func:`load_config` agree. Adding a config key means adding the field (in | ||
| 15 | :mod:`guardrails._config_fields` or :mod:`guardrails._config_fields_posts`) and | ||
| 16 | the matching entry in ``guardrails.default.json`` — nothing else. | ||
| 17 | """ | 12 | """ |
| 18 | 13 | ||
| 19 | import logging | 14 | import logging |
| 20 | from typing import Any | 15 | from typing import Any |
| 21 | 16 | ||
| 22 | import pydantic | ||
| 23 | from iolabs.common import config_loader | 17 | from iolabs.common import config_loader |
| 24 | 18 | ||
| 25 | from . import _config_fields, _config_fields_posts | 19 | from ._config_model import DetectorConfig |
| 26 | 20 | ||
| 27 | logger = logging.getLogger(__name__) | 21 | logger = logging.getLogger(__name__) |
| 28 | 22 | ||
| 29 | #: Import package holding the packaged default JSON, used when ``__package__`` | ||
| 30 | #: is unset because ``config.py`` was executed as a loose script. | ||
| 31 | _PACKAGE_NAME = "guardrails" | 23 | _PACKAGE_NAME = "guardrails" |
| 32 | _DEFAULT_CONFIG_NAME = "guardrails.default.json" | 24 | _DEFAULT_FILENAME = "guardrails.default.json" |
| 25 | _CONTEXT = "guardrails config" | ||
| 26 | |||
| 27 | __all__ = [ | ||
| 28 | "DetectorConfig", | ||
| 29 | "DetectorConfigError", | ||
| 30 | "config_from_dict", | ||
| 31 | "load_config", | ||
| 32 | "load_default_config_dict", | ||
| 33 | "parse_set_overrides", | ||
| 34 | "wall_view_config", | ||
| 35 | "with_overrides", | ||
| 36 | ] | ||
| 33 | 37 | ||
| 34 | #: Guardrail-named target field -> ``wall_*`` source field, applied by | 38 | #: Guardrail-named target field -> ``wall_*`` source field, applied by |
| 35 | #: :func:`wall_view_config`. | 39 | #: :func:`wall_view_config`. |
| 36 | _WALL_VIEW_MAP: dict[str, str] = { | 40 | _WALL_VIEW_MAP: dict[str, str] = { |
| 138 | that is not valid for its declared field type. | 112 | that is not valid for its declared field type. |
| 139 | """ | 113 | """ |
| 140 | config = config_loader.load_config( | 114 | config = config_loader.load_config( |
| 141 | DetectorConfig, | 115 | DetectorConfig, |
| 142 | package=__package__ or _PACKAGE_NAME, | 116 | package=_PACKAGE_NAME, |
| 143 | filename=_DEFAULT_CONFIG_NAME, | 117 | filename=_DEFAULT_FILENAME, |
| 144 | overrides=overrides, | 118 | overrides=overrides, |
| 145 | context="guardrails config", | 119 | context=_CONTEXT, |
| 146 | error_cls=DetectorConfigError, | 120 | error_cls=DetectorConfigError, |
| 147 | ) | 121 | ) |
| 148 | if overrides: | 122 | if overrides: |
| 149 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | 123 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) |
| 154 | """Return a re-validated copy of *config* with *updates* applied. | 128 | """Return a re-validated copy of *config* with *updates* applied. |
| 155 | 129 | ||
| 156 | Unlike ``model_copy(update=...)``, which writes the raw values straight | 130 | Unlike ``model_copy(update=...)``, which writes the raw values straight |
| 157 | into the copy, this rebuilds the model, so an unknown key, a value of the | 131 | into the copy, this rebuilds the model, so an unknown key, a value of the |
| 158 | wrong type and a failing cross-value check are all rejected exactly as they | 132 | wrong type and an out-of-range value are all rejected exactly as they are |
| 159 | are on load. Every in-package config derivation goes through here. | 133 | on load. Every in-package config derivation goes through here. |
| 160 | 134 | ||
| 161 | Args: | 135 | Args: |
| 162 | config: The config to derive from; never mutated (frozen model). | 136 | config: The config to derive from; never mutated (frozen model). |
| 163 | updates: Field name to new value; values go through the same coercion | 137 | updates: Field name to new value; values go through the same coercion |
| 1 | import argparse | 1 | import argparse |
| 2 | from pathlib import Path | 2 | from pathlib import Path |
| 3 | 3 | ||
| 4 | import pytest | 4 | import pytest |
| 5 | from iolabs.common import config_loader | ||
| 5 | 6 | ||
| 6 | from guardrails.config import ( | 7 | from guardrails.config import ( |
| 7 | DetectorConfig, | 8 | DetectorConfig, |
| 8 | DetectorConfigError, | 9 | DetectorConfigError, |
| 37 | "polyline_smooth_window": "wall_polyline_smooth_window", | 38 | "polyline_smooth_window": "wall_polyline_smooth_window", |
| 38 | } | 39 | } |
| 39 | 40 | ||
| 40 | 41 | ||
| 41 | def test_default_json_matches_model_defaults() -> None: | 42 | def test_model_defaults_match_packaged_json() -> None: |
| 42 | """guardrails.default.json is the schema source of truth; keep it in sync.""" | 43 | """guardrails.default.json is the schema source of truth; keep it in sync.""" |
| 43 | defaults = DetectorConfig().model_dump() | 44 | assert DetectorConfig().model_dump(mode="json") == load_default_config_dict() |
| 44 | json_config = load_default_config_dict() | ||
| 45 | assert set(json_config) == set(defaults) | ||
| 46 | for key, value in defaults.items(): | ||
| 47 | assert json_config[key] == value, key | ||
| 48 | 45 | ||
| 49 | 46 | ||
| 50 | def test_load_config_without_overrides_equals_defaults() -> None: | 47 | def test_load_config_returns_packaged_defaults() -> None: |
| 51 | assert load_config() == DetectorConfig() | 48 | assert load_config() == DetectorConfig() |
| 52 | 49 | ||
| 53 | 50 | ||
| 51 | def test_error_class_is_config_error() -> None: | ||
| 52 | """The package error class is the shared one, so callers can catch either.""" | ||
| 53 | assert issubclass(DetectorConfigError, config_loader.ConfigError) | ||
| 54 | assert issubclass(DetectorConfigError, ValueError) | ||
| 55 | |||
| 56 | |||
| 54 | def test_parse_set_overrides_json_decodes_values() -> None: | 57 | def test_parse_set_overrides_json_decodes_values() -> None: |
| 55 | parsed = parse_set_overrides( | 58 | parsed = parse_set_overrides( |
| 56 | ["merge_face_max_spacing_m=1.5", "decimation_enabled=true", "memory_budget_gb=8"] | 59 | ["merge_face_max_spacing_m=1.5", "decimation_enabled=true", "memory_budget_gb=8"] |
| 57 | ) | 60 | ) |
| 84 | def test_load_config_applies_overrides_with_type_coercion() -> None: | 87 | def test_load_config_applies_overrides_with_type_coercion() -> None: |
| 85 | config = load_config({"decimation_enabled": "true", "merge_face_max_faces": 3}) | 88 | config = load_config({"decimation_enabled": "true", "merge_face_max_faces": 3}) |
| 86 | assert config.decimation_enabled is True | 89 | assert config.decimation_enabled is True |
| 87 | assert config.merge_face_max_faces == 3 | 90 | assert config.merge_face_max_faces == 3 |
| 88 | with pytest.raises( | 91 | with pytest.raises(DetectorConfigError, match="residue_lever_band_m"): |
| 89 | DetectorConfigError, | ||
| 90 | match="residue_lever_band_m must contain exactly 2 values", | ||
| 91 | ): | ||
| 92 | load_config({"residue_lever_band_m": [0.3]}) | 92 | load_config({"residue_lever_band_m": [0.3]}) |
| 93 | 93 | ||
| 94 | 94 | ||
| 95 | def test_out_of_range_value_rejected() -> None: | ||
| 96 | """Declared field ranges are enforced on load, not at the point of use.""" | ||
| 97 | with pytest.raises(DetectorConfigError, match="min_length_m"): | ||
| 98 | load_config({"min_length_m": -1.0}) | ||
| 99 | with pytest.raises(DetectorConfigError, match="min_cell_points"): | ||
| 100 | load_config({"min_cell_points": 0}) | ||
| 101 | |||
| 102 | |||
| 95 | def test_bool_override_typo_rejected() -> None: | 103 | def test_bool_override_typo_rejected() -> None: |
| 96 | """A misspelled boolean token raises instead of silently meaning False.""" | 104 | """A misspelled boolean token raises instead of silently meaning False.""" |
| 97 | with pytest.raises(DetectorConfigError): | 105 | with pytest.raises(DetectorConfigError): |
| 98 | load_config({"decimation_enabled": "flase"}) | 106 | load_config({"decimation_enabled": "flase"}) |
| 103 | with pytest.raises(DetectorConfigError): | 111 | with pytest.raises(DetectorConfigError): |
| 104 | load_config({"merge_face_max_faces": 3.7}) | 112 | load_config({"merge_face_max_faces": 3.7}) |
| 105 | 113 | ||
| 106 | 114 | ||
| 107 | def test_unknown_key_rejected() -> None: | 115 | def test_unknown_top_level_key_is_rejected() -> None: |
| 108 | with pytest.raises(DetectorConfigError): | 116 | with pytest.raises(DetectorConfigError, match="not_a_key"): |
| 109 | config_from_dict({**load_default_config_dict(), "not_a_key": 1}) | 117 | config_from_dict({**load_default_config_dict(), "not_a_key": 1}) |
| 110 | 118 | ||
| 111 | 119 | ||
| 112 | def test_invalid_override_string_rejected() -> None: | 120 | def test_invalid_override_string_rejected() -> None: |
| 747 | # 5. Config surface | 747 | # 5. Config surface |
| 748 | # -------------------------------------------------------------------------- | 748 | # -------------------------------------------------------------------------- |
| 749 | 749 | ||
| 750 | 750 | ||
| 751 | def test_config_list_fields_are_not_shared_between_instances() -> None: | 751 | def test_config_sequence_fields_are_immutable_tuples() -> None: |
| 752 | """List defaults must come from ``field(default_factory=...)``.""" | 752 | """Sequence fields are tuples: freezing the model is shallow.""" |
| 753 | a, b = DetectorConfig(), DetectorConfig() | 753 | config = DetectorConfig() |
| 754 | assert a.post_catalog_spacings_m is not b.post_catalog_spacings_m | 754 | assert isinstance(config.post_catalog_spacings_m, tuple) |
| 755 | assert a.component_rail_band_m is not b.component_rail_band_m | 755 | assert isinstance(config.component_rail_band_m, tuple) |
| 756 | assert isinstance(config.residue_lever_band_m, tuple) | ||
| 756 | 757 | ||
| 757 | 758 | ||
| 758 | # -------------------------------------------------------------------------- | 759 | # -------------------------------------------------------------------------- |
| 759 | # 5. Station-quantization regression (the real-segment 1.0 m artifact) | 760 | # 5. Station-quantization regression (the real-segment 1.0 m artifact) |
| 19 | `walls`), RGB and intensity overlays, and a red candidate-mask diagnostic. | 19 | `walls`), RGB and intensity overlays, and a red candidate-mask diagnostic. |
| 20 | `out/run_summary.json` records per-segment timings, alignment checks and peak | 20 | `out/run_summary.json` records per-segment timings, alignment checks and peak |
| 21 | RSS. | 21 | RSS. |
| 22 | 22 | ||
| 23 | ## Configuration (iolabs convention) | 23 | ## Configuration |
| 24 | 24 | ||
| 25 | Following the other iolabs point-cloud packages | 25 | Defaults live in `guardrails/guardrails.default.json`. The schema is |
| 26 | (`iolabs_point_cloud_segmentation_trajectory` etc.), the package owns an | 26 | `DetectorConfig` in `guardrails/_config_model.py` (a |
| 27 | algorithm config `guardrails/guardrails.default.json`. `guardrails/config.py` is | 27 | `iolabs.common.config_loader.ConfigModel`: it rejects unknown keys, coerces raw |
| 28 | the loader/schema: `DetectorConfig` is a frozen pydantic model derived from | 28 | JSON / `--set` values to the declared field types and enforces the declared |
| 29 | `iolabs.common.config_loader.ConfigModel` (the fleet SSOT: it rejects unknown | 29 | field ranges); its field declarations are split across the slices |
| 30 | keys and coerces raw JSON / `--set` values to the declared field types), and its | 30 | `guardrails/_model_core.py`, `guardrails/_model_wall.py` and |
| 31 | field set is the schema. The field declarations live in the two mixins | 31 | `guardrails/_model_posts.py` only to keep every module under 500 lines. |
| 32 | `guardrails/_config_fields.py` and `guardrails/_config_fields_posts.py` (split | 32 | `guardrails/config.py` owns loading, merging and `--set` handling. **To add a |
| 33 | only to keep every module under 500 lines). Every model default is kept | 33 | config key: add the field (with its type, default and any `Field` range) to the |
| 34 | identical to the JSON (asserted by `tests/test_config.py`). | 34 | matching model slice and the same key with the same default to the JSON — |
| 35 | 35 | nothing else.** `load_config()`, `config_from_dict()`, `with_overrides()` and | |
| 36 | **Adding a config key:** declare the field on the matching mixin and add the | 36 | `wall_view_config()` return the frozen `DetectorConfig`; `with_overrides()` is |
| 37 | same key/default to `guardrails.default.json`. Nothing else — there is no | 37 | the only sanctioned config derivation (it re-validates, unlike |
| 38 | allowed-key list and no coercion helper to update. | 38 | `model_copy(update=...)`). Every model default is kept identical to the JSON |
| 39 | 39 | (asserted by `tests/test_config.py`). | |
| 40 | Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are | 40 | |
| 41 | JSON-decoded), never repo-local JSON files: | 41 | Runtime overrides come from the repeatable `--set KEY=VALUE` CLI flag (values |
| 42 | are JSON-decoded), never repo-local JSON files: | ||
| 42 | 43 | ||
| 43 | ```bash | 44 | ```bash |
| 44 | --set decimation_enabled=true --set memory_budget_gb=8 --set occlusion_bridge_max_m=15 | 45 | --set decimation_enabled=true --set memory_budget_gb=8 --set occlusion_bridge_max_m=15 |
| 45 | ``` | 46 | ``` |
| 46 | 47 | ||
| 47 | Logging mirrors those packages: `logging.getLogger(__name__)` with INFO progress | 48 | Logging mirrors the other iolabs packages: `logging.getLogger(__name__)` with |
| 48 | per stage (ground DEM, per-record corridor candidates, occupancy clustering, XML | 49 | INFO progress per stage (ground DEM, per-record corridor candidates, occupancy |
| 49 | export, per-segment completion with peak RSS). | 50 | clustering, XML export, per-segment completion with peak RSS). |
| 50 | 51 | ||
| 51 | ## Face / barrier merge policy | 52 | ## Face / barrier merge policy |
| 52 | 53 | ||
| 53 | A single physical rail (e.g. a W-beam) presents up to two near-parallel faces | 54 | A single physical rail (e.g. a W-beam) presents up to two near-parallel faces |
| 1 | """Field declarations for :class:`guardrails.config.DetectorConfig` (part 1). | ||
| 2 | |||
| 3 | Split out of ``config.py`` only to keep both modules under the 500-line limit: | ||
| 4 | the mixins here carry no behaviour, and the config schema is still the flat | ||
| 5 | key set of ``guardrails.default.json``. Part 2 (the post / beam / top-member | ||
| 6 | levers) lives in :mod:`guardrails._config_fields_posts`. | ||
| 7 | """ | ||
| 8 | |||
| 9 | from iolabs.common import config_loader | ||
| 10 | |||
| 11 | |||
| 12 | class CoreFields(config_loader.ConfigModel): | ||
| 13 | """Ground, corridor, candidate, cluster, fit and memory levers.""" | ||
| 14 | |||
| 15 | # Ground model | ||
| 16 | ground_cell_m: float = 0.75 | ||
| 17 | ground_percentile: float = 8.0 | ||
| 18 | |||
| 19 | # Corridor crop (station / offset frame) | ||
| 20 | corridor_offset_min_m: float = 1.5 | ||
| 21 | corridor_offset_max_m: float = 10.0 | ||
| 22 | corridor_include_median_zone: bool = True | ||
| 23 | median_corridor_offset_min_m: float = 0.8 | ||
| 24 | median_corridor_offset_max_m: float = 3.8 | ||
| 25 | corridor_max_height_m: float = 2.0 | ||
| 26 | station_window_m: float = 5.0 | ||
| 27 | median_side_max_offset_m: float = 3.5 | ||
| 28 | |||
| 29 | # Optional lane-XML carriageway / rail-zone scoping | ||
| 30 | lane_xml_zones_enabled: bool = True | ||
| 31 | lane_xml_path: str | None = None | ||
| 32 | rail_zone_margin_m: float = 10.0 | ||
| 33 | outer_rail_band_m: float = 20.0 | ||
| 34 | single_edge_rail_margin_m: float = 15.0 | ||
| 35 | max_carriageway_width_m: float = 15.0 | ||
| 36 | zone_bbox_margin_m: float = 140.0 | ||
| 37 | interior_rejection_depth_m: float = 2.0 | ||
| 38 | |||
| 39 | # Optional late edge gate: instance-level distance filters against the | ||
| 40 | # lane-XML edge lines (rules E1/E2), applied after the precision gate. | ||
| 41 | # edge_gate_max_rail_distance_m was calibrated on A1 segments 060/066/085: | ||
| 42 | # real rails measure <= 3.7 m from an XML edge, noise >= 5.4 m. | ||
| 43 | edge_gate_enabled: bool = True | ||
| 44 | edge_gate_max_rail_distance_m: float = 5.0 | ||
| 45 | edge_gate_interior_depth_m: float = 0.5 | ||
| 46 | edge_gate_interior_max_frac: float = 0.5 | ||
| 47 | edge_gate_apply_to_walls: bool = False | ||
| 48 | |||
| 49 | # Optional late precision gate over final rail/wall runs. | ||
| 50 | precision_gate_enabled: bool = True | ||
| 51 | precision_deep_interior_depth_m: float = 2.0 | ||
| 52 | precision_deep_interior_frac_min: float = 0.50 | ||
| 53 | precision_vehicle_max_length_m: float = 15.0 | ||
| 54 | precision_vehicle_min_density_per_m: float = 750.0 | ||
| 55 | precision_vehicle_min_mean_height_m: float = 0.80 | ||
| 56 | precision_low_max_mean_height_m: float = 0.35 | ||
| 57 | precision_sparse_max_density_per_m: float = 300.0 | ||
| 58 | precision_sparse_min_outboard_gap_m: float = 6.0 | ||
| 59 | precision_curve_min_line_rmse_m: float = 0.010 | ||
| 60 | precision_far_min_axis_dist_m: float = 18.0 | ||
| 61 | precision_long_low_min_length_m: float = 25.0 | ||
| 62 | precision_edge_beyond_frac_min: float = 0.25 | ||
| 63 | precision_dense_low_min_density_per_m: float = 2500.0 | ||
| 64 | precision_parallel_min_inboard_gap_m: float = 3.0 | ||
| 65 | precision_parallel_min_overlap_frac: float = 0.75 | ||
| 66 | precision_unknown_far_min_axis_dist_m: float = 20.0 | ||
| 67 | precision_very_far_min_outboard_gap_m: float = 12.0 | ||
| 68 | precision_very_far_min_axis_dist_m: float = 25.0 | ||
| 69 | precision_edge_abeam_window_m: float = 15.0 | ||
| 70 | precision_edge_outboard_epsilon_m: float = 0.30 | ||
| 71 | |||
| 72 | # Occupancy grid for candidate cells | ||
| 73 | occupancy_cell_m: float = 0.10 | ||
| 74 | |||
| 75 | # Height band for initial point candidates (also drives candidates overlay) | ||
| 76 | min_height_m: float = 0.20 | ||
| 77 | max_height_m: float = 1.30 | ||
| 78 | |||
| 79 | # Per-cell rail-band fraction and mean-height gates | ||
| 80 | rail_band_min_m: float = 0.35 | ||
| 81 | rail_band_max_m: float = 0.85 | ||
| 82 | min_cell_points: int = 3 | ||
| 83 | min_rail_points: int = 2 | ||
| 84 | min_rail_fraction: float = 0.40 | ||
| 85 | min_mean_height_m: float = 0.42 | ||
| 86 | max_mean_height_m: float = 0.78 | ||
| 87 | |||
| 88 | # Optional tablecloth-residue candidate lever | ||
| 89 | tablecloth_masks_dir: str | None = None | ||
| 90 | residue_union_enabled: bool = True | ||
| 91 | residue_cell_frac: float = 0.8 | ||
| 92 | residue_lever_band_m: list[float] = [0.30, 1.20] | ||
| 93 | |||
| 94 | # Vegetation rejection: compact height-above-ground spread within a cell | ||
| 95 | max_cell_height_spread_m: float = 0.50 | ||
| 96 | |||
| 97 | # Tall-object fraction per cell (trees, poles) | ||
| 98 | tall_min_m: float = 1.30 | ||
| 99 | tall_max_m: float = 4.50 | ||
| 100 | max_tall_fraction: float = 0.12 | ||
| 101 | |||
| 102 | # Local covariance / eigenvector candidate filter (cell-level) | ||
| 103 | eigen_neighborhood_radius_m: float = 0.40 | ||
| 104 | eigen_min_neighbors: int = 5 | ||
| 105 | min_linearity: float = 0.30 | ||
| 106 | min_verticality: float = 0.15 | ||
| 107 | use_eigen_cell_filter: bool = False | ||
| 108 | |||
| 109 | # DBSCAN clustering on selected occupancy cells | ||
| 110 | cluster_eps_m: float = 0.20 | ||
| 111 | cluster_min_samples: int = 3 | ||
| 112 | |||
| 113 | # Post-cluster merge of collinear fragments | ||
| 114 | merge_gap_m: float = 4.5 | ||
| 115 | merge_angle_deg: float = 15.0 | ||
| 116 | merge_lateral_max_m: float = 0.50 | ||
| 117 | |||
| 118 | # Occlusion bridging: join collinear fragments across a parked-vehicle / | ||
| 119 | # occlusion shadow when heading and offset stay continuous (defect 4). The | ||
| 120 | # bridged station interval is recorded in ``gap_spans`` (never interpolated | ||
| 121 | # silently). | ||
| 122 | # Default is conservative (8 m) so bridging never fuses two distinct | ||
| 123 | # barriers into one instance; raise via --set occlusion_bridge_max_m=15 for | ||
| 124 | # datasets with longer occlusion shadows. | ||
| 125 | occlusion_bridge_max_m: float = 8.0 | ||
| 126 | occlusion_bridge_max_angle_deg: float = 4.0 | ||
| 127 | occlusion_bridge_max_lateral_m: float = 0.40 | ||
| 128 | |||
| 129 | # Parallel-face deduplication (two faces of one physical rail). | ||
| 130 | # ``dedupe_*`` are retained for backward compatibility; the active policy is | ||
| 131 | # driven by ``merge_face_*`` (see README "Face / barrier merge policy"). | ||
| 132 | dedupe_face_max_sep_m: float = 1.0 | ||
| 133 | dedupe_max_angle_deg: float = 12.0 | ||
| 134 | merge_face_max_spacing_m: float = 1.3 | ||
| 135 | merge_face_max_heading_deg: float = 5.0 | ||
| 136 | merge_face_min_station_overlap: float = 0.5 | ||
| 137 | merge_face_max_faces: int = 2 | ||
| 138 | |||
| 139 | # Instance acceptance (applied after merge) | ||
| 140 | min_length_m: float = 12.0 | ||
| 141 | max_local_width_m: float = 0.75 | ||
| 142 | min_longitudinal_coverage: float = 0.35 | ||
| 143 | |||
| 144 | # Ordered-walk polyline construction | ||
| 145 | polyline_bin_m: float = 1.0 | ||
| 146 | polyline_smooth_window: int = 5 | ||
| 147 | walk_max_step_m: float = 0.30 | ||
| 148 | |||
| 149 | # Gap recording along station | ||
| 150 | gap_min_span_m: float = 2.0 | ||
| 151 | |||
| 152 | # Vehicle / occlusion-shadow rejection on cluster height distribution | ||
| 153 | max_cluster_height_spread_m: float = 0.80 | ||
| 154 | max_cluster_p95_height_m: float = 1.15 | ||
| 155 | |||
| 156 | # Straightness check along sliding window (short clusters only) | ||
| 157 | straightness_window_m: float = 10.0 | ||
| 158 | max_straightness_deviation_m: float = 0.50 | ||
| 159 | straightness_max_length_m: float = 25.0 | ||
| 160 | |||
| 161 | # Heuristic type classification thresholds | ||
| 162 | w_beam_min_height_m: float = 0.40 | ||
| 163 | w_beam_max_height_m: float = 0.90 | ||
| 164 | w_beam_max_height_spread_m: float = 0.55 | ||
| 165 | concrete_min_height_m: float = 0.80 | ||
| 166 | concrete_max_height_spread_m: float = 0.45 | ||
| 167 | cable_suspect_max_spread_m: float = 0.25 | ||
| 168 | |||
| 169 | # Per-run confidence heuristic (0-1); see README "Run confidence". | ||
| 170 | # confidence = 0.35*support + 0.25*continuity + 0.25*extent + 0.15*height | ||
| 171 | confidence_density_norm_pts_per_m: float = 500.0 | ||
| 172 | confidence_full_extent_m: float = 40.0 | ||
| 173 | confidence_max_height_std_m: float = 0.2 | ||
| 174 | |||
| 175 | # Memory hardening (deployment target is a 32 GB RAM Azure node). | ||
| 176 | memory_budget_gb: float = 10.0 | ||
| 177 | station_process_window_m: float = 5.0 | ||
| 178 | decimation_enabled: bool = False | ||
| 179 | decimation_voxel_m: float = 0.05 | ||
| 180 | decimation_density_cap: int = 400000 | ||
| 181 | # Records larger than this stream through the corridor crop in chunks of | ||
| 182 | # this many points instead of being materialized whole (byte-identical | ||
| 183 | # results for records at or below the threshold, which use the old path). | ||
| 184 | record_chunk_points: int = 4000000 | ||
| 185 | # Exclusion clustering guard: DBSCAN memory scales with the number of | ||
| 186 | # eps-neighbour pairs. When a cheap grid estimate of that count exceeds | ||
| 187 | # this cap the exclusion candidates are voxel-decimated first (auto-trigger | ||
| 188 | # only; sparse segments are untouched). segment_134's dense record | ||
| 189 | # estimated 4.0e9 pairs (25 GB RSS); curated segments peak at 6.3e8. | ||
| 190 | exclusion_pair_estimate_max: float = 1000000000.0 | ||
| 191 | exclusion_decimation_cell_m: float = 0.10 | ||
| 192 | # After the density trigger decimates, the residual DBSCAN runs under the | ||
| 193 | # shared iolabs.common.memory_guard watchdog (subprocess + psutil RSS | ||
| 194 | # monitor, hard kill above the limit) as a second line of defense. Mirrors | ||
| 195 | # the subcluster_dbscan_memory_guard wiring in | ||
| 196 | # iolabs_point_cloud_modelling_lines / iolabs_geometry_geometry.fit_spline. | ||
| 197 | exclusion_use_shared_watchdog: bool = True | ||
| 198 | exclusion_dbscan_mem_limit_gb: float = 6.0 | ||
| 199 | exclusion_dbscan_timeout_s: float = 120.0 | ||
| 200 | |||
| 201 | |||
| 202 | class WallFields(config_loader.ConfigModel): | ||
| 203 | """Noise-wall detection, wall-view fit overrides and wall-only gates.""" | ||
| 204 | |||
| 205 | # Wall detection: independent evidence/fitting channel (see README "Noise | ||
| 206 | # walls"). ``wall_detection_enabled=False`` is a process-level kill switch; | ||
| 207 | # it emits ``"walls": []`` and allocates no wall grids. | ||
| 208 | wall_detection_enabled: bool = True | ||
| 209 | wall_cell_m: float = 0.25 | ||
| 210 | wall_height_bin_m: float = 0.25 | ||
| 211 | wall_min_height_m: float = 0.30 | ||
| 212 | wall_max_height_m: float = 8.00 | ||
| 213 | wall_offset_min_m: float = 1.50 | ||
| 214 | # Dataset ground truth (segments 133-137; segment_135 confirmed walls near | ||
| 215 | # offset ~23 m) puts walls at spine offsets 21-25 m; 20.0 would miss them. | ||
| 216 | wall_offset_max_m: float = 26.00 | ||
| 217 | wall_min_cell_points: int = 6 | ||
| 218 | wall_min_top_height_m: float = 2.50 | ||
| 219 | wall_max_top_height_m: float = 8.00 | ||
| 220 | # Grazing-angle MLS returns are banded, not continuous: production | ||
| 221 | # segment_135 wall cells measured occupied-bin fill p10=0.040/p50=0.071. | ||
| 222 | wall_min_vertical_fill: float = 0.05 | ||
| 223 | # Per-cell minimum distinct occupied height bins; rejects single-scanline | ||
| 224 | # artifacts. | ||
| 225 | wall_min_occupied_bins: int = 2 | ||
| 226 | # Per-cell occupied-bin span (last - first occupied bin, inclusive) in | ||
| 227 | # metres: separates vertical-sheet wall cells (bins spread over metres) | ||
| 228 | # from grazing-angle surface/embankment cells banded within ~0.5 m. | ||
| 229 | wall_min_cell_height_span_m: float = 1.5 | ||
| 230 | |||
| 231 | # Wall-view overrides of the shared clustering/merge/fit config (see | ||
| 232 | # ``wall_view_config()``). | ||
| 233 | wall_cluster_eps_m: float = 0.40 | ||
| 234 | wall_cluster_min_samples: int = 3 | ||
| 235 | wall_merge_gap_m: float = 4.50 | ||
| 236 | wall_merge_angle_deg: float = 8.0 | ||
| 237 | wall_merge_lateral_max_m: float = 1.00 | ||
| 238 | # Real occluded walls (segment_135) show raw-data voids up to ~13.8 m; | ||
| 239 | # 14.0 keeps that structure bridgeable while the 4deg/0.4 m collinearity | ||
| 240 | # guards below still block unrelated fragments from fusing. | ||
| 241 | wall_occlusion_bridge_max_m: float = 14.00 | ||
| 242 | wall_occlusion_bridge_max_angle_deg: float = 4.0 | ||
| 243 | wall_occlusion_bridge_max_lateral_m: float = 0.40 | ||
| 244 | # Staggered noise-wall rows fit as separate ~14 m instances after polyline | ||
| 245 | # smoothing (segment_135: 14.86 m / 13.92 m); vegetation rejection is | ||
| 246 | # carried by the width/straightness/planarity/crest gates, not length. | ||
| 247 | wall_min_length_m: float = 13.0 | ||
| 248 | wall_max_local_width_m: float = 1.80 | ||
| 249 | wall_min_longitudinal_coverage: float = 0.60 | ||
| 250 | wall_max_cluster_height_spread_m: float = 12.0 | ||
| 251 | wall_max_cluster_p95_height_m: float = 12.0 | ||
| 252 | wall_straightness_window_m: float = 10.0 | ||
| 253 | wall_max_straightness_deviation_m: float = 0.35 | ||
| 254 | wall_straightness_max_length_m: float = 25.0 | ||
| 255 | # Sparse/occluded tail regions leave the wall polyline fit on banded, | ||
| 256 | # far-range evidence that meanders (segment_135); a stronger lateral | ||
| 257 | # smoothing window than the guardrail default (5) is needed to tame it. | ||
| 258 | wall_polyline_smooth_window: int = 9 | ||
| 259 | |||
| 260 | # Post-fit wall-only gates (crest profile, truck rejection, mandatory 3D | ||
| 261 | # PCA plane checks); not part of ``wall_view_config()``. | ||
| 262 | wall_profile_bin_m: float = 1.00 | ||
| 263 | # Real crest profiles ramp at their ends; a genuine structure was rejected | ||
| 264 | # by 0.005 m in production. Truck rejection is handled separately by the | ||
| 265 | # truck double-gate below. | ||
| 266 | wall_max_top_profile_spread_m: float = 1.50 | ||
| 267 | wall_truck_max_top_m: float = 4.20 | ||
| 268 | # EU max articulated truck length is ~18.75 m; 20.0 keeps the truck | ||
| 269 | # double-gate effective (top <= wall_truck_max_top_m AND length < this) | ||
| 270 | # while remaining just above that bound. | ||
| 271 | wall_truck_min_length_m: float = 20.0 | ||
| 272 | wall_min_planarity: float = 0.55 | ||
| 273 | wall_max_plane_normal_z_abs: float = 0.35 | ||
| 274 | # Grazing-angle MLS returns are height-banded (segment_135 row B: | ||
| 275 | # planarity=0.368, normal_z_abs=0.005): a clearly-vertical cell can sit | ||
| 276 | # just under the mandatory planarity ratio. Moderate planarity is | ||
| 277 | # accepted when the normal is unambiguously vertical. | ||
| 278 | wall_min_planarity_vertical: float = 0.25 | ||
| 279 | # Banded returns can also collapse to a line-degenerate (not plane-like) | ||
| 280 | # moment shape, making the plane normal numerically arbitrary | ||
| 281 | # (segment_135 row A: planarity=0.020, normal_z_abs=1.000, yet the | ||
| 282 | # moments are unambiguously line-like). A high linearity ratio plus a | ||
| 283 | # thin fitted width certifies a genuine vertical sheet without relying on | ||
| 284 | # that ill-conditioned normal. | ||
| 285 | wall_line_bypass_min_linearity: float = 0.75 | ||
| 286 | wall_line_bypass_max_width_m: float = 1.0 | ||
| 287 | |||
| 288 | # Carriageway rejection gate: a wall candidate between the carriageway | ||
| 289 | # edge-line guardrails is a vehicle (or bridge-deck returns sharing its | ||
| 290 | # cells), not a genuine noise wall (see README "Carriageway rejection | ||
| 291 | # gate"; production segment_135 false positive at offset -4.544 m). | ||
| 292 | wall_reject_inside_carriageway: bool = True | ||
| 293 | # Fallback minimum |mean_offset_m| for a wall when no same-side guardrail | ||
| 294 | # exists to compare against. | ||
| 295 | wall_min_abs_offset_m: float = 6.0 | ||
| 296 | # A wall may interleave up to this much inside the outermost same-side | ||
| 297 | # guardrail before being treated as inside the carriageway. | ||
| 298 | wall_outside_rail_margin_m: float = 0.5 | ||
| 299 | |||
| 300 | # A ground-standing wall's first returns start near the ground; a bottom-height | ||
| 301 | # profile starting above this is an elevated bridge parapet/deck structure | ||
| 302 | # measured from the wrong base. | ||
| 303 | wall_max_bottom_height_m: float = 2.0 | ||
| 304 | |||
| 305 | |||
| 306 | class OverlayFields(config_loader.ConfigModel): | ||
| 307 | """Overlay kill switches shared with the perspective CLI.""" | ||
| 308 | |||
| 309 | # Overlay kill switches (also mirrored in ``PerspectiveConfig`` so the | ||
| 310 | # independent perspective CLI shares the same rollback behavior). | ||
| 311 | overlay_extent_enabled: bool = True | ||
| 312 | overlay_ground_model_diff_enabled: bool = False | ||
| 0 |
| 1 | """Schema of the guardrails detector config. | ||
| 2 | |||
| 3 | The schema is `DetectorConfig` (a `config_loader.ConfigModel`), mirroring | ||
| 4 | `guardrails.default.json` key for key. It is assembled from the field slices | ||
| 5 | in :mod:`guardrails._model_core`, :mod:`guardrails._model_wall` and | ||
| 6 | :mod:`guardrails._model_posts`, split out only to keep every module under 500 | ||
| 7 | lines; loading, merging and ``--set`` handling live in :mod:`guardrails.config`. | ||
| 8 | |||
| 9 | Adding a config key means adding the field to the matching slice and the same | ||
| 10 | key to `guardrails.default.json` — nothing else. Unknown keys are rejected. | ||
| 11 | """ | ||
| 12 | |||
| 13 | import logging | ||
| 14 | |||
| 15 | from . import _model_core, _model_posts, _model_wall | ||
| 16 | |||
| 17 | logger = logging.getLogger(__name__) | ||
| 18 | |||
| 19 | |||
| 20 | class DetectorConfig( | ||
| 21 | _model_core.DetectorCoreConfig, | ||
| 22 | _model_wall.DetectorWallConfig, | ||
| 23 | _model_posts.DetectorPostConfig, | ||
| 24 | _model_wall.DetectorOverlayConfig, | ||
| 25 | ): | ||
| 26 | """Spatial and geometric thresholds, in metres unless stated otherwise. | ||
| 27 | |||
| 28 | The field set is declared by the slices in :mod:`guardrails._model_core`, | ||
| 29 | :mod:`guardrails._model_wall` and :mod:`guardrails._model_posts`, and | ||
| 30 | mirrors ``guardrails.default.json`` key for key; ranges and choices are | ||
| 31 | declared on the fields themselves. | ||
| 32 | """ | ||
| 0 |
| 1 | """Ground, corridor, candidate, cluster, fit and memory field declarations. | ||
| 2 | |||
| 3 | One schema slice of the guardrails detector config; the slices are recombined | ||
| 4 | into the model by :mod:`guardrails._config_model`. The split exists only to | ||
| 5 | keep every module under 500 lines — these classes carry no behaviour, and the | ||
| 6 | config schema is still the flat key set of ``guardrails.default.json``. | ||
| 7 | |||
| 8 | Adding a config key means adding the field here and the same key to | ||
| 9 | ``guardrails.default.json`` — nothing else. Unknown keys are rejected. | ||
| 10 | """ | ||
| 11 | |||
| 12 | import pydantic | ||
| 13 | from iolabs.common import config_loader | ||
| 14 | |||
| 15 | |||
| 16 | class DetectorCoreConfig(config_loader.ConfigModel): | ||
| 17 | """Ground, corridor, candidate, cluster, fit and memory levers.""" | ||
| 18 | |||
| 19 | # Ground model | ||
| 20 | ground_cell_m: float = pydantic.Field(0.75, ge=0.0) | ||
| 21 | ground_percentile: float = pydantic.Field(8.0, ge=0.0, le=100.0) | ||
| 22 | |||
| 23 | # Corridor crop (station / offset frame) | ||
| 24 | corridor_offset_min_m: float = pydantic.Field(1.5, ge=0.0) | ||
| 25 | corridor_offset_max_m: float = pydantic.Field(10.0, ge=0.0) | ||
| 26 | corridor_include_median_zone: bool = True | ||
| 27 | median_corridor_offset_min_m: float = pydantic.Field(0.8, ge=0.0) | ||
| 28 | median_corridor_offset_max_m: float = pydantic.Field(3.8, ge=0.0) | ||
| 29 | corridor_max_height_m: float = pydantic.Field(2.0, ge=0.0) | ||
| 30 | station_window_m: float = pydantic.Field(5.0, ge=0.0) | ||
| 31 | median_side_max_offset_m: float = pydantic.Field(3.5, ge=0.0) | ||
| 32 | |||
| 33 | # Optional lane-XML carriageway / rail-zone scoping | ||
| 34 | lane_xml_zones_enabled: bool = True | ||
| 35 | lane_xml_path: str | None = None | ||
| 36 | rail_zone_margin_m: float = pydantic.Field(10.0, ge=0.0) | ||
| 37 | outer_rail_band_m: float = pydantic.Field(20.0, ge=0.0) | ||
| 38 | single_edge_rail_margin_m: float = pydantic.Field(15.0, ge=0.0) | ||
| 39 | max_carriageway_width_m: float = pydantic.Field(15.0, ge=0.0) | ||
| 40 | zone_bbox_margin_m: float = pydantic.Field(140.0, ge=0.0) | ||
| 41 | interior_rejection_depth_m: float = pydantic.Field(2.0, ge=0.0) | ||
| 42 | |||
| 43 | # Optional late edge gate: instance-level distance filters against the | ||
| 44 | # lane-XML edge lines (rules E1/E2), applied after the precision gate. | ||
| 45 | # edge_gate_max_rail_distance_m was calibrated on A1 segments 060/066/085: | ||
| 46 | # real rails measure <= 3.7 m from an XML edge, noise >= 5.4 m. | ||
| 47 | edge_gate_enabled: bool = True | ||
| 48 | edge_gate_max_rail_distance_m: float = pydantic.Field(5.0, ge=0.0) | ||
| 49 | edge_gate_interior_depth_m: float = pydantic.Field(0.5, ge=0.0) | ||
| 50 | edge_gate_interior_max_frac: float = pydantic.Field(0.5, ge=0.0, le=1.0) | ||
| 51 | edge_gate_apply_to_walls: bool = False | ||
| 52 | |||
| 53 | # Optional late precision gate over final rail/wall runs. | ||
| 54 | precision_gate_enabled: bool = True | ||
| 55 | precision_deep_interior_depth_m: float = pydantic.Field(2.0, ge=0.0) | ||
| 56 | precision_deep_interior_frac_min: float = pydantic.Field(0.50, ge=0.0, le=1.0) | ||
| 57 | precision_vehicle_max_length_m: float = pydantic.Field(15.0, ge=0.0) | ||
| 58 | precision_vehicle_min_density_per_m: float = pydantic.Field(750.0, ge=0.0) | ||
| 59 | precision_vehicle_min_mean_height_m: float = pydantic.Field(0.80, ge=0.0) | ||
| 60 | precision_low_max_mean_height_m: float = pydantic.Field(0.35, ge=0.0) | ||
| 61 | precision_sparse_max_density_per_m: float = pydantic.Field(300.0, ge=0.0) | ||
| 62 | precision_sparse_min_outboard_gap_m: float = pydantic.Field(6.0, ge=0.0) | ||
| 63 | precision_curve_min_line_rmse_m: float = pydantic.Field(0.010, ge=0.0) | ||
| 64 | precision_far_min_axis_dist_m: float = pydantic.Field(18.0, ge=0.0) | ||
| 65 | precision_long_low_min_length_m: float = pydantic.Field(25.0, ge=0.0) | ||
| 66 | precision_edge_beyond_frac_min: float = pydantic.Field(0.25, ge=0.0, le=1.0) | ||
| 67 | precision_dense_low_min_density_per_m: float = pydantic.Field(2500.0, ge=0.0) | ||
| 68 | precision_parallel_min_inboard_gap_m: float = pydantic.Field(3.0, ge=0.0) | ||
| 69 | precision_parallel_min_overlap_frac: float = pydantic.Field(0.75, ge=0.0, le=1.0) | ||
| 70 | precision_unknown_far_min_axis_dist_m: float = pydantic.Field(20.0, ge=0.0) | ||
| 71 | precision_very_far_min_outboard_gap_m: float = pydantic.Field(12.0, ge=0.0) | ||
| 72 | precision_very_far_min_axis_dist_m: float = pydantic.Field(25.0, ge=0.0) | ||
| 73 | precision_edge_abeam_window_m: float = pydantic.Field(15.0, ge=0.0) | ||
| 74 | precision_edge_outboard_epsilon_m: float = pydantic.Field(0.30, ge=0.0) | ||
| 75 | |||
| 76 | # Occupancy grid for candidate cells | ||
| 77 | occupancy_cell_m: float = pydantic.Field(0.10, ge=0.0) | ||
| 78 | |||
| 79 | # Height band for initial point candidates (also drives candidates overlay) | ||
| 80 | min_height_m: float = pydantic.Field(0.20, ge=0.0) | ||
| 81 | max_height_m: float = pydantic.Field(1.30, ge=0.0) | ||
| 82 | |||
| 83 | # Per-cell rail-band fraction and mean-height gates | ||
| 84 | rail_band_min_m: float = pydantic.Field(0.35, ge=0.0) | ||
| 85 | rail_band_max_m: float = pydantic.Field(0.85, ge=0.0) | ||
| 86 | min_cell_points: int = pydantic.Field(3, ge=1) | ||
| 87 | min_rail_points: int = pydantic.Field(2, ge=1) | ||
| 88 | min_rail_fraction: float = pydantic.Field(0.40, ge=0.0, le=1.0) | ||
| 89 | min_mean_height_m: float = pydantic.Field(0.42, ge=0.0) | ||
| 90 | max_mean_height_m: float = pydantic.Field(0.78, ge=0.0) | ||
| 91 | |||
| 92 | # Optional tablecloth-residue candidate lever | ||
| 93 | tablecloth_masks_dir: str | None = None | ||
| 94 | residue_union_enabled: bool = True | ||
| 95 | residue_cell_frac: float = pydantic.Field(0.8, ge=0.0, le=1.0) | ||
| 96 | residue_lever_band_m: tuple[float, float] = (0.30, 1.20) | ||
| 97 | |||
| 98 | # Vegetation rejection: compact height-above-ground spread within a cell | ||
| 99 | max_cell_height_spread_m: float = pydantic.Field(0.50, ge=0.0) | ||
| 100 | |||
| 101 | # Tall-object fraction per cell (trees, poles) | ||
| 102 | tall_min_m: float = pydantic.Field(1.30, ge=0.0) | ||
| 103 | tall_max_m: float = pydantic.Field(4.50, ge=0.0) | ||
| 104 | max_tall_fraction: float = pydantic.Field(0.12, ge=0.0, le=1.0) | ||
| 105 | |||
| 106 | # Local covariance / eigenvector candidate filter (cell-level) | ||
| 107 | eigen_neighborhood_radius_m: float = pydantic.Field(0.40, ge=0.0) | ||
| 108 | eigen_min_neighbors: int = pydantic.Field(5, ge=1) | ||
| 109 | min_linearity: float = pydantic.Field(0.30, ge=0.0, le=1.0) | ||
| 110 | min_verticality: float = pydantic.Field(0.15, ge=0.0, le=1.0) | ||
| 111 | use_eigen_cell_filter: bool = False | ||
| 112 | |||
| 113 | # DBSCAN clustering on selected occupancy cells | ||
| 114 | cluster_eps_m: float = pydantic.Field(0.20, ge=0.0) | ||
| 115 | cluster_min_samples: int = pydantic.Field(3, ge=1) | ||
| 116 | |||
| 117 | # Post-cluster merge of collinear fragments | ||
| 118 | merge_gap_m: float = pydantic.Field(4.5, ge=0.0) | ||
| 119 | merge_angle_deg: float = pydantic.Field(15.0, ge=0.0) | ||
| 120 | merge_lateral_max_m: float = pydantic.Field(0.50, ge=0.0) | ||
| 121 | |||
| 122 | # Occlusion bridging: join collinear fragments across a parked-vehicle / | ||
| 123 | # occlusion shadow when heading and offset stay continuous (defect 4). The | ||
| 124 | # bridged station interval is recorded in ``gap_spans`` (never interpolated | ||
| 125 | # silently). | ||
| 126 | # Default is conservative (8 m) so bridging never fuses two distinct | ||
| 127 | # barriers into one instance; raise via --set occlusion_bridge_max_m=15 for | ||
| 128 | # datasets with longer occlusion shadows. | ||
| 129 | occlusion_bridge_max_m: float = pydantic.Field(8.0, ge=0.0) | ||
| 130 | occlusion_bridge_max_angle_deg: float = pydantic.Field(4.0, ge=0.0) | ||
| 131 | occlusion_bridge_max_lateral_m: float = pydantic.Field(0.40, ge=0.0) | ||
| 132 | |||
| 133 | # Parallel-face deduplication (two faces of one physical rail). | ||
| 134 | # ``dedupe_*`` are retained for backward compatibility; the active policy is | ||
| 135 | # driven by ``merge_face_*`` (see README "Face / barrier merge policy"). | ||
| 136 | dedupe_face_max_sep_m: float = pydantic.Field(1.0, ge=0.0) | ||
| 137 | dedupe_max_angle_deg: float = pydantic.Field(12.0, ge=0.0) | ||
| 138 | merge_face_max_spacing_m: float = pydantic.Field(1.3, ge=0.0) | ||
| 139 | merge_face_max_heading_deg: float = pydantic.Field(5.0, ge=0.0) | ||
| 140 | merge_face_min_station_overlap: float = pydantic.Field(0.5, ge=0.0) | ||
| 141 | merge_face_max_faces: int = pydantic.Field(2, ge=1) | ||
| 142 | |||
| 143 | # Instance acceptance (applied after merge) | ||
| 144 | min_length_m: float = pydantic.Field(12.0, ge=0.0) | ||
| 145 | max_local_width_m: float = pydantic.Field(0.75, ge=0.0) | ||
| 146 | min_longitudinal_coverage: float = pydantic.Field(0.35, ge=0.0, le=1.0) | ||
| 147 | |||
| 148 | # Ordered-walk polyline construction | ||
| 149 | polyline_bin_m: float = pydantic.Field(1.0, ge=0.0) | ||
| 150 | polyline_smooth_window: int = pydantic.Field(5, ge=1) | ||
| 151 | walk_max_step_m: float = pydantic.Field(0.30, ge=0.0) | ||
| 152 | |||
| 153 | # Gap recording along station | ||
| 154 | gap_min_span_m: float = pydantic.Field(2.0, ge=0.0) | ||
| 155 | |||
| 156 | # Vehicle / occlusion-shadow rejection on cluster height distribution | ||
| 157 | max_cluster_height_spread_m: float = pydantic.Field(0.80, ge=0.0) | ||
| 158 | max_cluster_p95_height_m: float = pydantic.Field(1.15, ge=0.0) | ||
| 159 | |||
| 160 | # Straightness check along sliding window (short clusters only) | ||
| 161 | straightness_window_m: float = pydantic.Field(10.0, ge=0.0) | ||
| 162 | max_straightness_deviation_m: float = pydantic.Field(0.50, ge=0.0) | ||
| 163 | straightness_max_length_m: float = pydantic.Field(25.0, ge=0.0) | ||
| 164 | |||
| 165 | # Heuristic type classification thresholds | ||
| 166 | w_beam_min_height_m: float = pydantic.Field(0.40, ge=0.0) | ||
| 167 | w_beam_max_height_m: float = pydantic.Field(0.90, ge=0.0) | ||
| 168 | w_beam_max_height_spread_m: float = pydantic.Field(0.55, ge=0.0) | ||
| 169 | concrete_min_height_m: float = pydantic.Field(0.80, ge=0.0) | ||
| 170 | concrete_max_height_spread_m: float = pydantic.Field(0.45, ge=0.0) | ||
| 171 | cable_suspect_max_spread_m: float = pydantic.Field(0.25, ge=0.0) | ||
| 172 | |||
| 173 | # Per-run confidence heuristic (0-1); see README "Run confidence". | ||
| 174 | # confidence = 0.35*support + 0.25*continuity + 0.25*extent + 0.15*height | ||
| 175 | confidence_density_norm_pts_per_m: float = pydantic.Field(500.0, ge=0.0) | ||
| 176 | confidence_full_extent_m: float = pydantic.Field(40.0, ge=0.0) | ||
| 177 | confidence_max_height_std_m: float = pydantic.Field(0.2, ge=0.0, le=1.0) | ||
| 178 | |||
| 179 | # Memory hardening (deployment target is a 32 GB RAM Azure node). | ||
| 180 | memory_budget_gb: float = pydantic.Field(10.0, ge=0.0) | ||
| 181 | station_process_window_m: float = pydantic.Field(5.0, ge=0.0) | ||
| 182 | decimation_enabled: bool = False | ||
| 183 | decimation_voxel_m: float = pydantic.Field(0.05, ge=0.0) | ||
| 184 | decimation_density_cap: int = pydantic.Field(400000, ge=1) | ||
| 185 | # Records larger than this stream through the corridor crop in chunks of | ||
| 186 | # this many points instead of being materialized whole (byte-identical | ||
| 187 | # results for records at or below the threshold, which use the old path). | ||
| 188 | record_chunk_points: int = pydantic.Field(4000000, ge=1) | ||
| 189 | # Exclusion clustering guard: DBSCAN memory scales with the number of | ||
| 190 | # eps-neighbour pairs. When a cheap grid estimate of that count exceeds | ||
| 191 | # this cap the exclusion candidates are voxel-decimated first (auto-trigger | ||
| 192 | # only; sparse segments are untouched). segment_134's dense record | ||
| 193 | # estimated 4.0e9 pairs (25 GB RSS); curated segments peak at 6.3e8. | ||
| 194 | exclusion_pair_estimate_max: float = pydantic.Field(1000000000.0, ge=0.0) | ||
| 195 | exclusion_decimation_cell_m: float = pydantic.Field(0.10, ge=0.0) | ||
| 196 | # After the density trigger decimates, the residual DBSCAN runs under the | ||
| 197 | # shared iolabs.common.memory_guard watchdog (subprocess + psutil RSS | ||
| 198 | # monitor, hard kill above the limit) as a second line of defense. Mirrors | ||
| 199 | # the subcluster_dbscan_memory_guard wiring in | ||
| 200 | # iolabs_point_cloud_modelling_lines / iolabs_geometry_geometry.fit_spline. | ||
| 201 | exclusion_use_shared_watchdog: bool = True | ||
| 202 | exclusion_dbscan_mem_limit_gb: float = pydantic.Field(6.0, ge=0.0) | ||
| 203 | exclusion_dbscan_timeout_s: float = pydantic.Field(120.0, ge=0.0) | ||
| 204 | |||
| 205 | |||
| 0 |
| 1 | """Post cadence, beam-underside and top-member field declarations. | ||
| 2 | |||
| 3 | One schema slice of the guardrails detector config; the slices are recombined | ||
| 4 | into the model by :mod:`guardrails._config_model`. The split exists only to | ||
| 5 | keep every module under 500 lines — these classes carry no behaviour, and the | ||
| 6 | config schema is still the flat key set of ``guardrails.default.json``. | ||
| 7 | |||
| 8 | Adding a config key means adding the field here and the same key to | ||
| 9 | ``guardrails.default.json`` — nothing else. Unknown keys are rejected. | ||
| 10 | """ | ||
| 11 | |||
| 12 | from typing import Literal, TypeAlias | ||
| 13 | |||
| 14 | import pydantic | ||
| 15 | from iolabs.common import config_loader | ||
| 16 | |||
| 17 | #: Where the measured top member's rows are emitted (also an argparse choice). | ||
| 18 | TopMemberType: TypeAlias = Literal["guardrail_top_rail", "guardrail_support", "w_beam"] | ||
| 19 | |||
| 20 | |||
| 21 | class DetectorPostConfig(config_loader.ConfigModel): | ||
| 22 | """Post cadence, beam-underside and top-member levers.""" | ||
| 23 | |||
| 24 | # Guardrail rail-vs-support decomposition (post cadence + support class). | ||
| 25 | # Height cut lines are literature-derived (Swiss/German hardware: rail band | ||
| 26 | # top edge ~0.75 m, Sigma-100 post 100x55 mm, ASTRA 11005 post spacings | ||
| 27 | # 1.33 / 2.00 m and DDSP 4.00 m), not yet tuned on our clouds; keep in | ||
| 28 | # config. All three feature flags default true; setting them false restores | ||
| 29 | # the pre-feature behavior exactly. | ||
| 30 | enable_post_cadence: bool = True | ||
| 31 | enable_support_class: bool = True | ||
| 32 | enable_component_masks: bool = True | ||
| 33 | post_low_band_min_m: float = pydantic.Field(0.10, ge=0.0) | ||
| 34 | post_low_band_max_m: float = pydantic.Field(0.35, ge=0.0) | ||
| 35 | post_station_bin_m: float = pydantic.Field(0.10, ge=0.0) | ||
| 36 | post_lateral_halfwidth_m: float = pydantic.Field(0.60, ge=0.0) | ||
| 37 | post_catalog_spacings_m: tuple[float, ...] = (1.33, 2.0, 4.0) | ||
| 38 | post_spacing_snap_rel_tol: float = pydantic.Field(0.12, ge=0.0, le=1.0) | ||
| 39 | post_min_period_m: float = pydantic.Field(0.8, ge=0.0) | ||
| 40 | post_max_period_m: float = pydantic.Field(6.0, ge=0.0) | ||
| 41 | post_min_confidence: float = pydantic.Field(0.35, ge=0.0, le=1.0) | ||
| 42 | post_slot_min_points: int = pydantic.Field(3, ge=1) | ||
| 43 | # Per-post peak detection (``posts.detect_run_posts``). The run-level comb | ||
| 44 | # (``post_min_confidence``) is only a scoring prior now: on a long rail the | ||
| 45 | # low band also carries continuous grass/plinth clutter, which drowns the | ||
| 46 | # comb contrast, so posts are accepted individually against a ROLLING local | ||
| 47 | # background instead of all-or-nothing against the run mean. | ||
| 48 | post_peak_smooth_m: float = pydantic.Field(0.3, ge=0.0) | ||
| 49 | post_peak_background_window_m: float = pydantic.Field(5.0, ge=0.0) | ||
| 50 | post_peak_min_prominence: float = pydantic.Field(3.0, ge=0.0) | ||
| 51 | # A dense low band is also a NOISY one: at b points per smoothing window the | ||
| 52 | # Poisson swing is sqrt(b), so a fixed point floor would fabricate posts out | ||
| 53 | # of grass on exactly the cluttered runs this feature exists for. The | ||
| 54 | # effective floor is max(post_peak_min_prominence, sigmas * sqrt(background)). | ||
| 55 | post_peak_noise_sigmas: float = pydantic.Field(3.0, ge=0.0) | ||
| 56 | post_peak_min_confidence: float = pydantic.Field(0.25, ge=0.0, le=1.0) | ||
| 57 | # Wider above-background blobs are plinths / kerbs / parked clutter, not a | ||
| 58 | # 0.10 m post footprint. Measured at half prominence (see detect_run_posts). | ||
| 59 | post_max_station_extent_m: float = pydantic.Field(0.45, ge=0.0) | ||
| 60 | # Measured post top is clamped to [rail band bottom, beam bottom + margin]. | ||
| 61 | post_top_margin_m: float = pydantic.Field(0.10, ge=0.0) | ||
| 62 | # Behind-beam shaft claim: a post-footprint point this far outboard of the | ||
| 63 | # rail's LOCAL centerline (not of its run-mean offset — a 50 m polyline | ||
| 64 | # wanders further off its own mean than this threshold, which made the | ||
| 65 | # first cut of this rule inert on every curved run) sits on the far side of | ||
| 66 | # the beam from the road, so it is post shaft, not beam, and may be claimed | ||
| 67 | # up to the rail top. The threshold is the larger of | ||
| 68 | # ``post_behind_beam_offset_m`` (half a w-beam depth plus a margin: the | ||
| 69 | # floor, and what a rail with no measured width gets) and half the rail's | ||
| 70 | # ``width_m`` plus ``post_behind_beam_margin_m`` (what a wide rail needs). | ||
| 71 | post_claim_behind_beam: bool = True | ||
| 72 | post_behind_beam_offset_m: float = pydantic.Field(0.22, ge=0.0) | ||
| 73 | post_behind_beam_margin_m: float = pydantic.Field(0.05, ge=0.0) | ||
| 74 | # ... and once the post line itself is MEASURED (``_measured_post_side``), | ||
| 75 | # the threshold moves off that generic floor onto the hardware: the post's | ||
| 76 | # front face is ``|post_lat| - post_behind_beam_front_margin_m`` (an | ||
| 77 | # IPE-100 flange at 0.05 m plus the spacer that holds the plank off it), | ||
| 78 | # never nearer than the beam's own edge. The floor costs the A4/5 105 | ||
| 79 | # median rails half their shaft: post line at 0.24-0.25 m against a 0.22 m | ||
| 80 | # threshold leaves the spacer and the post's road-side half to the rail. | ||
| 81 | post_behind_beam_front_margin_m: float = pydantic.Field(0.10, ge=0.0) | ||
| 82 | # Where a measured post is PUT: the parent polyline at that post's station, | ||
| 83 | # displaced by ``post.offset_m`` minus the polyline's OWN spine offset there | ||
| 84 | # (round 7). With this off the displacement is measured against the run's | ||
| 85 | # constant ``mean_offset_m`` instead -- the round-6 behaviour, kept only so | ||
| 86 | # the flags-off byte-identity replay has something to compare against. On a | ||
| 87 | # run that wanders (A4/5 105 rail 1: 0.69 m end to end) the mean form walks | ||
| 88 | # the published post train diagonally across its own rail. | ||
| 89 | post_xy_local_offset_enabled: bool = True | ||
| 90 | # Measured per-rail beam underside (``posts.measure_beam_bottom``). The | ||
| 91 | # evidence pass folds a HEIGHT histogram over [post_low_band_min_m, | ||
| 92 | # beam_bottom_hist_max_m] alongside the station histogram, scoped to a | ||
| 93 | # tighter lateral halfwidth than the post band (the beam sits on the run's | ||
| 94 | # mean offset; kerb / soil returns further out only blur the onset). | ||
| 95 | # ``beam_bottom_hist_bin_m`` divides the distance from | ||
| 96 | # ``post_low_band_min_m`` to 0.35 / 0.75 / 0.85 exactly, so the rail band | ||
| 97 | # floor and the plausibility cap fall on bin edges rather than inside a bin. | ||
| 98 | beam_bottom_hist_bin_m: float = pydantic.Field(0.025, ge=0.0) | ||
| 99 | # Ceiling of that histogram. 1.30 m (= ``max_height_m``, 48 bins from the | ||
| 100 | # 0.10 m floor) rather than the 1.00 m of rounds 3-6: the beam TOP walk-up | ||
| 101 | # and ``detect_top_member`` both need headroom ABOVE the structure to tell | ||
| 102 | # a bounded member (a Kastenprofil tube: mass ends at 0.98 m and there is | ||
| 103 | # nothing over it) from an unbounded one (a noise wall / hedge / parapet, | ||
| 104 | # which keeps going). At 1.00 m every A4/5 median tube reported | ||
| 105 | # ``truncated`` against what was really the knob, not the cloud. | ||
| 106 | # ``measure_beam_bottom`` is provably unchanged by the raise: its window is | ||
| 107 | # ``component_rail_band_m`` = [0.35, 0.85) and its walk is downward only, | ||
| 108 | # so bins added above cannot move the scale, the dense groups or the | ||
| 109 | # underside. | ||
| 110 | beam_bottom_hist_max_m: float = pydantic.Field(1.30, ge=0.0) | ||
| 111 | beam_bottom_lateral_halfwidth_m: float = pydantic.Field(0.40, ge=0.0) | ||
| 112 | # A candidate beam band is a contiguous group of bins carrying at least this | ||
| 113 | # fraction of the tallest bin in the rail band. Candidates are tried lowest | ||
| 114 | # first (a stacked double w-beam has two, and the upper one is often the | ||
| 115 | # taller), but only TRIED: the low band's own tail can clear this floor and | ||
| 116 | # group up below the beam, and on A4/5 066 rail 5 it does. | ||
| 117 | beam_bottom_band_fraction: float = pydantic.Field(0.15, ge=0.0, le=1.0) | ||
| 118 | # Walking down from a candidate's peak, the underside is where the count | ||
| 119 | # first drops below this fraction of the peak bin. | ||
| 120 | beam_bottom_onset_fraction: float = pydantic.Field(0.20, ge=0.0, le=1.0) | ||
| 121 | # ... and the drop has to be a STEP, not a drift across that threshold. A | ||
| 122 | # continuous barrier mistyped w_beam (A4/5 066 rail 2) has no underside at | ||
| 123 | # all, only a smooth ramp, and any walk-down threshold stops somewhere | ||
| 124 | # arbitrary in it. The knob sits in the gap the A4/5 rails measure out | ||
| 125 | # between two populations: the nine rails that do carry a beam step | ||
| 126 | # 2.00-54x at their onset (the 2.00 is 132 rail 0), while on the seven that | ||
| 127 | # do not, the strongest single-bin rise ANYWHERE in the rail band is 1.67x | ||
| 128 | # — and that is already a harder test than this guard, which only ever | ||
| 129 | # looks at the bin the walk stopped on. | ||
| 130 | beam_bottom_min_onset_ratio: float = pydantic.Field(1.8, ge=0.0) | ||
| 131 | beam_bottom_min_peak_points: int = pydantic.Field(50, ge=1) | ||
| 132 | # Round 7: the same walk, upwards, giving the beam TOP -- and with it the | ||
| 133 | # shaft cap the claim should always have used. Gates the MEASUREMENT (the | ||
| 134 | # walk in ``_band_underside``, hence ``detect_top_member``'s precondition | ||
| 135 | # and the shaft cap's preference) as well as the PUBLICATION | ||
| 136 | # (``beam_bottom.top_height_m`` / ``top_measured`` / ``reason_top`` and | ||
| 137 | # ``polyline_beam_top_z_m``), so with it off guardrails.json is | ||
| 138 | # byte-identical to the round-6 one and no member can be detected. | ||
| 139 | post_beam_top_enabled: bool = True | ||
| 140 | # Plausibility window for the result: below ``component_rail_band_m[0]`` it | ||
| 141 | # is not beam (no rail evidence is counted there), above this it is a | ||
| 142 | # gantry / sign / noise wall, not a w-beam underside. | ||
| 143 | beam_bottom_max_m: float = pydantic.Field(0.75, ge=0.0) | ||
| 144 | # Beam band [bottom, top] above the road, used as the fallback when a rail | ||
| 145 | # instance carries no measured ``polyline_bottom_z_m`` / ``polyline_top_z_m``. | ||
| 146 | component_rail_band_m: tuple[float, float] = (0.35, 0.85) | ||
| 147 | component_support_max_height_m: float = pydantic.Field(0.50, ge=0.0) | ||
| 148 | component_support_station_tol_m: float = pydantic.Field(0.20, ge=0.0) | ||
| 149 | component_support_footprint_m: float = pydantic.Field(0.25, ge=0.0) | ||
| 150 | |||
| 151 | # --- Round 7: the top member (the Kastenprofil box tube on the A4/5 | ||
| 152 | # median rails). ``detect_top_member`` measures the band ABOVE the beam | ||
| 153 | # top, and the two load-bearing gates are the mass fraction and the | ||
| 154 | # STATION COVERAGE: mass alone accepts a 27 m stub of vegetation behind a | ||
| 155 | # rail (A4/5 066 rail 1, mass fraction 0.44), and only "is this band there | ||
| 156 | # at every station of the run" rejects it (coverage 0.57 against 1.00 on | ||
| 157 | # all four real tubes). | ||
| 158 | post_top_member_enabled: bool = True | ||
| 159 | # Where the tube's rows go. "guardrail_top_rail" (default) emits the | ||
| 160 | # companion instance and LAS 74; "guardrail_support" folds them into the | ||
| 161 | # parent's support instance (LAS 72); "w_beam" leaves them on the parent | ||
| 162 | # rail (LAS 66). The last two emit no companion instance, so the fusion | ||
| 163 | # JSON paint has nothing to read and only the mask sidecar carries them. | ||
| 164 | post_top_member_type: TopMemberType = "guardrail_top_rail" | ||
| 165 | # Mass above the measured beam top, over the mass in the rail window. | ||
| 166 | # Measured 0.49-0.53 on the four A4/5 tubes; 0.002-0.066 on nine of the | ||
| 167 | # twelve rails without one, 0.39-0.44 on the two 066 outliers coverage | ||
| 168 | # rejects. | ||
| 169 | post_top_member_min_mass_fraction: float = pydantic.Field(0.15, ge=0.0, le=1.0) | ||
| 170 | # A member is "the thing above the post line", so there has to be a post | ||
| 171 | # line: below this many measured posts the run reports ``no_posts``. | ||
| 172 | post_top_member_min_posts: int = pydantic.Field(2, ge=1) | ||
| 173 | # A bin is part of the band when it carries this fraction of the tallest | ||
| 174 | # bin above the beam top; the band is the contiguous dense group with the | ||
| 175 | # largest MASS (not the topmost one -- with the 1.30 m ceiling that picks | ||
| 176 | # a blob 0.30 m over the beam on A4/5 105 rail 3). | ||
| 177 | post_top_member_band_fraction: float = pydantic.Field(0.15, ge=0.0, le=1.0) | ||
| 178 | # Reported, not enforced (a thin band that is present at every station is | ||
| 179 | # still a member; the real discriminators are mass and coverage). | ||
| 180 | post_top_member_min_thickness_m: float = pydantic.Field(0.075, ge=0.0) | ||
| 181 | # A station bin counts as covered when the band carries this many points | ||
| 182 | # in it, over the station bins that carry any point of the run's slab. | ||
| 183 | post_top_member_min_bin_points: int = pydantic.Field(3, ge=1) | ||
| 184 | post_top_member_min_coverage: float = pydantic.Field(0.90, ge=0.0, le=1.0) | ||
| 185 | # Colocation with the measured post line, and the band's own lateral | ||
| 186 | # spread. Both are REPORTED on every rail; the gate is off by default | ||
| 187 | # (mass + coverage already separate the two populations by 0.33 of | ||
| 188 | # coverage, and three rails without a tube pass the lateral test anyway). | ||
| 189 | post_top_member_lateral_gate_enabled: bool = False | ||
| 190 | # ``post_top_member_max_lateral_offset_m`` is enforced whatever that flag | ||
| 191 | # says in ONE place: the prism's axis. A post median that disagrees with | ||
| 192 | # the band's own measured lateral by more than this is not the line the | ||
| 193 | # member runs along, and sweeping a full-length 0.25 m prism down it would | ||
| 194 | # paint whatever stands behind the rail (see ``_top_rail_geometry``). | ||
| 195 | post_top_member_max_lateral_offset_m: float = pydantic.Field(0.12, ge=0.0) | ||
| 196 | post_top_member_max_lateral_spread_m: float = pydantic.Field(0.15, ge=0.0) | ||
| 197 | # Halfwidth of the swept prism that claims the tube, about the robust post | ||
| 198 | # line. The measured 2-98 percentile lateral extent of the four A4/5 tubes | ||
| 199 | # about that line is within [-0.20, +0.17] m. | ||
| 200 | post_top_member_halfwidth_m: float = pydantic.Field(0.25, ge=0.0) | ||
| 201 | # --- Round 7: the behind-beam outward sign, from the MEASURED post side. | ||
| 202 | # ``sign(mean_offset_m)`` assumes the posts are always further from the | ||
| 203 | # spine than the beam; on the A4/5 median rails that is true on only half | ||
| 204 | # of them, and the shaft claim is completely dead on the other half. The | ||
| 205 | # three guards are what keep every rail whose posts sit ON the line (the | ||
| 206 | # outer rails: |side| 0.004-0.079) on the old sign, bit for bit. | ||
| 207 | post_behind_beam_use_measured_side: bool = True | ||
| 208 | post_behind_beam_min_post_offset_m: float = pydantic.Field(0.10, ge=0.0) | ||
| 209 | post_behind_beam_min_posts: int = pydantic.Field(4, ge=1) | ||
| 210 | post_behind_beam_min_side_agreement: float = pydantic.Field(0.70, ge=0.0, le=1.0) | ||
| 0 |
| 1 | """Noise-wall and overlay field declarations for :class:`DetectorConfig`. | ||
| 2 | |||
| 3 | One schema slice of the guardrails detector config; the slices are recombined | ||
| 4 | into the model by :mod:`guardrails._config_model`. The split exists only to | ||
| 5 | keep every module under 500 lines — these classes carry no behaviour, and the | ||
| 6 | config schema is still the flat key set of ``guardrails.default.json``. | ||
| 7 | |||
| 8 | Adding a config key means adding the field here and the same key to | ||
| 9 | ``guardrails.default.json`` — nothing else. Unknown keys are rejected. | ||
| 10 | """ | ||
| 11 | |||
| 12 | import pydantic | ||
| 13 | from iolabs.common import config_loader | ||
| 14 | |||
| 15 | |||
| 16 | class DetectorWallConfig(config_loader.ConfigModel): | ||
| 17 | """Noise-wall detection, wall-view fit overrides and wall-only gates.""" | ||
| 18 | |||
| 19 | # Wall detection: independent evidence/fitting channel (see README "Noise | ||
| 20 | # walls"). ``wall_detection_enabled=False`` is a process-level kill switch; | ||
| 21 | # it emits ``"walls": []`` and allocates no wall grids. | ||
| 22 | wall_detection_enabled: bool = True | ||
| 23 | wall_cell_m: float = pydantic.Field(0.25, ge=0.0) | ||
| 24 | wall_height_bin_m: float = pydantic.Field(0.25, ge=0.0) | ||
| 25 | wall_min_height_m: float = pydantic.Field(0.30, ge=0.0) | ||
| 26 | wall_max_height_m: float = pydantic.Field(8.00, ge=0.0) | ||
| 27 | wall_offset_min_m: float = pydantic.Field(1.50, ge=0.0) | ||
| 28 | # Dataset ground truth (segments 133-137; segment_135 confirmed walls near | ||
| 29 | # offset ~23 m) puts walls at spine offsets 21-25 m; 20.0 would miss them. | ||
| 30 | wall_offset_max_m: float = pydantic.Field(26.00, ge=0.0) | ||
| 31 | wall_min_cell_points: int = pydantic.Field(6, ge=1) | ||
| 32 | wall_min_top_height_m: float = pydantic.Field(2.50, ge=0.0) | ||
| 33 | wall_max_top_height_m: float = pydantic.Field(8.00, ge=0.0) | ||
| 34 | # Grazing-angle MLS returns are banded, not continuous: production | ||
| 35 | # segment_135 wall cells measured occupied-bin fill p10=0.040/p50=0.071. | ||
| 36 | wall_min_vertical_fill: float = pydantic.Field(0.05, ge=0.0) | ||
| 37 | # Per-cell minimum distinct occupied height bins; rejects single-scanline | ||
| 38 | # artifacts. | ||
| 39 | wall_min_occupied_bins: int = pydantic.Field(2, ge=1) | ||
| 40 | # Per-cell occupied-bin span (last - first occupied bin, inclusive) in | ||
| 41 | # metres: separates vertical-sheet wall cells (bins spread over metres) | ||
| 42 | # from grazing-angle surface/embankment cells banded within ~0.5 m. | ||
| 43 | wall_min_cell_height_span_m: float = pydantic.Field(1.5, ge=0.0) | ||
| 44 | |||
| 45 | # Wall-view overrides of the shared clustering/merge/fit config (see | ||
| 46 | # ``wall_view_config()``). | ||
| 47 | wall_cluster_eps_m: float = pydantic.Field(0.40, ge=0.0) | ||
| 48 | wall_cluster_min_samples: int = pydantic.Field(3, ge=1) | ||
| 49 | wall_merge_gap_m: float = pydantic.Field(4.50, ge=0.0) | ||
| 50 | wall_merge_angle_deg: float = pydantic.Field(8.0, ge=0.0) | ||
| 51 | wall_merge_lateral_max_m: float = pydantic.Field(1.00, ge=0.0) | ||
| 52 | # Real occluded walls (segment_135) show raw-data voids up to ~13.8 m; | ||
| 53 | # 14.0 keeps that structure bridgeable while the 4deg/0.4 m collinearity | ||
| 54 | # guards below still block unrelated fragments from fusing. | ||
| 55 | wall_occlusion_bridge_max_m: float = pydantic.Field(14.00, ge=0.0) | ||
| 56 | wall_occlusion_bridge_max_angle_deg: float = pydantic.Field(4.0, ge=0.0) | ||
| 57 | wall_occlusion_bridge_max_lateral_m: float = pydantic.Field(0.40, ge=0.0) | ||
| 58 | # Staggered noise-wall rows fit as separate ~14 m instances after polyline | ||
| 59 | # smoothing (segment_135: 14.86 m / 13.92 m); vegetation rejection is | ||
| 60 | # carried by the width/straightness/planarity/crest gates, not length. | ||
| 61 | wall_min_length_m: float = pydantic.Field(13.0, ge=0.0) | ||
| 62 | wall_max_local_width_m: float = pydantic.Field(1.80, ge=0.0) | ||
| 63 | wall_min_longitudinal_coverage: float = pydantic.Field(0.60, ge=0.0, le=1.0) | ||
| 64 | wall_max_cluster_height_spread_m: float = pydantic.Field(12.0, ge=0.0) | ||
| 65 | wall_max_cluster_p95_height_m: float = pydantic.Field(12.0, ge=0.0) | ||
| 66 | wall_straightness_window_m: float = pydantic.Field(10.0, ge=0.0) | ||
| 67 | wall_max_straightness_deviation_m: float = pydantic.Field(0.35, ge=0.0) | ||
| 68 | wall_straightness_max_length_m: float = pydantic.Field(25.0, ge=0.0) | ||
| 69 | # Sparse/occluded tail regions leave the wall polyline fit on banded, | ||
| 70 | # far-range evidence that meanders (segment_135); a stronger lateral | ||
| 71 | # smoothing window than the guardrail default (5) is needed to tame it. | ||
| 72 | wall_polyline_smooth_window: int = pydantic.Field(9, ge=1) | ||
| 73 | |||
| 74 | # Post-fit wall-only gates (crest profile, truck rejection, mandatory 3D | ||
| 75 | # PCA plane checks); not part of ``wall_view_config()``. | ||
| 76 | wall_profile_bin_m: float = pydantic.Field(1.00, ge=0.0) | ||
| 77 | # Real crest profiles ramp at their ends; a genuine structure was rejected | ||
| 78 | # by 0.005 m in production. Truck rejection is handled separately by the | ||
| 79 | # truck double-gate below. | ||
| 80 | wall_max_top_profile_spread_m: float = pydantic.Field(1.50, ge=0.0) | ||
| 81 | wall_truck_max_top_m: float = pydantic.Field(4.20, ge=0.0) | ||
| 82 | # EU max articulated truck length is ~18.75 m; 20.0 keeps the truck | ||
| 83 | # double-gate effective (top <= wall_truck_max_top_m AND length < this) | ||
| 84 | # while remaining just above that bound. | ||
| 85 | wall_truck_min_length_m: float = pydantic.Field(20.0, ge=0.0) | ||
| 86 | wall_min_planarity: float = pydantic.Field(0.55, ge=0.0, le=1.0) | ||
| 87 | wall_max_plane_normal_z_abs: float = pydantic.Field(0.35, ge=0.0) | ||
| 88 | # Grazing-angle MLS returns are height-banded (segment_135 row B: | ||
| 89 | # planarity=0.368, normal_z_abs=0.005): a clearly-vertical cell can sit | ||
| 90 | # just under the mandatory planarity ratio. Moderate planarity is | ||
| 91 | # accepted when the normal is unambiguously vertical. | ||
| 92 | wall_min_planarity_vertical: float = pydantic.Field(0.25, ge=0.0, le=1.0) | ||
| 93 | # Banded returns can also collapse to a line-degenerate (not plane-like) | ||
| 94 | # moment shape, making the plane normal numerically arbitrary | ||
| 95 | # (segment_135 row A: planarity=0.020, normal_z_abs=1.000, yet the | ||
| 96 | # moments are unambiguously line-like). A high linearity ratio plus a | ||
| 97 | # thin fitted width certifies a genuine vertical sheet without relying on | ||
| 98 | # that ill-conditioned normal. | ||
| 99 | wall_line_bypass_min_linearity: float = pydantic.Field(0.75, ge=0.0, le=1.0) | ||
| 100 | wall_line_bypass_max_width_m: float = pydantic.Field(1.0, ge=0.0) | ||
| 101 | |||
| 102 | # Carriageway rejection gate: a wall candidate between the carriageway | ||
| 103 | # edge-line guardrails is a vehicle (or bridge-deck returns sharing its | ||
| 104 | # cells), not a genuine noise wall (see README "Carriageway rejection | ||
| 105 | # gate"; production segment_135 false positive at offset -4.544 m). | ||
| 106 | wall_reject_inside_carriageway: bool = True | ||
| 107 | # Fallback minimum |mean_offset_m| for a wall when no same-side guardrail | ||
| 108 | # exists to compare against. | ||
| 109 | wall_min_abs_offset_m: float = pydantic.Field(6.0, ge=0.0) | ||
| 110 | # A wall may interleave up to this much inside the outermost same-side | ||
| 111 | # guardrail before being treated as inside the carriageway. | ||
| 112 | wall_outside_rail_margin_m: float = pydantic.Field(0.5, ge=0.0) | ||
| 113 | |||
| 114 | # A ground-standing wall's first returns start near the ground; a bottom-height | ||
| 115 | # profile starting above this is an elevated bridge parapet/deck structure | ||
| 116 | # measured from the wrong base. | ||
| 117 | wall_max_bottom_height_m: float = pydantic.Field(2.0, ge=0.0) | ||
| 118 | |||
| 119 | |||
| 120 | class DetectorOverlayConfig(config_loader.ConfigModel): | ||
| 121 | """Overlay kill switches shared with the perspective CLI.""" | ||
| 122 | |||
| 123 | # Overlay kill switches (also mirrored in ``PerspectiveConfig`` so the | ||
| 124 | # independent perspective CLI shares the same rollback behavior). | ||
| 125 | overlay_extent_enabled: bool = True | ||
| 126 | overlay_ground_model_diff_enabled: bool = False | ||
| 0 |
| 1 | """Detector configuration. | 1 | """Loading, merging and ``--set`` handling for the guardrails detector config. |
| 2 | 2 | ||
| 3 | Mirrors the config convention used by the iolabs point-cloud packages | 3 | The schema is `DetectorConfig` (a `config_loader.ConfigModel`, assembled in |
| 4 | (``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a | 4 | :mod:`guardrails._config_model`), mirroring `guardrails.default.json` key for |
| 5 | ``guardrails.default.json`` algorithm config, and a typed params object | 5 | key: unknown keys are rejected and raw JSON / ``--set`` values are coerced to |
| 6 | (:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are | 6 | the declared field types by the shared layer. Runtime overrides come from |
| 7 | applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON. | 7 | repeatable ``--set PATH=VALUE`` flags, never repo-local JSON. |
| 8 | 8 | ||
| 9 | The schema is the pydantic model :class:`DetectorConfig`, derived from | 9 | Adding a config key means adding the field to the model (in the matching |
| 10 | :class:`iolabs.common.config_loader.ConfigModel`: unknown keys are rejected and | 10 | `guardrails._model_*` slice) and the same key to `guardrails.default.json` — |
| 11 | raw JSON / ``--set`` values are coerced to the declared field types by the | 11 | nothing else. Unknown keys are rejected. |
| 12 | shared layer. Every field default is kept identical to | ||
| 13 | ``guardrails.default.json`` (guarded by a unit test), so ``DetectorConfig()`` | ||
| 14 | and :func:`load_config` agree. Adding a config key means adding the field (in | ||
| 15 | :mod:`guardrails._config_fields` or :mod:`guardrails._config_fields_posts`) and | ||
| 16 | the matching entry in ``guardrails.default.json`` — nothing else. | ||
| 17 | """ | 12 | """ |
| 18 | 13 | ||
| 19 | import logging | 14 | import logging |
| 20 | from typing import Any | 15 | from typing import Any |
| 21 | 16 | ||
| 22 | import pydantic | ||
| 23 | from iolabs.common import config_loader | 17 | from iolabs.common import config_loader |
| 24 | 18 | ||
| 25 | from . import _config_fields, _config_fields_posts | 19 | from ._config_model import DetectorConfig |
| 26 | 20 | ||
| 27 | logger = logging.getLogger(__name__) | 21 | logger = logging.getLogger(__name__) |
| 28 | 22 | ||
| 29 | #: Import package holding the packaged default JSON, used when ``__package__`` | ||
| 30 | #: is unset because ``config.py`` was executed as a loose script. | ||
| 31 | _PACKAGE_NAME = "guardrails" | 23 | _PACKAGE_NAME = "guardrails" |
| 32 | _DEFAULT_CONFIG_NAME = "guardrails.default.json" | 24 | _DEFAULT_FILENAME = "guardrails.default.json" |
| 25 | _CONTEXT = "guardrails config" | ||
| 26 | |||
| 27 | __all__ = [ | ||
| 28 | "DetectorConfig", | ||
| 29 | "DetectorConfigError", | ||
| 30 | "config_from_dict", | ||
| 31 | "load_config", | ||
| 32 | "load_default_config_dict", | ||
| 33 | "parse_set_overrides", | ||
| 34 | "wall_view_config", | ||
| 35 | "with_overrides", | ||
| 36 | ] | ||
| 33 | 37 | ||
| 34 | #: Guardrail-named target field -> ``wall_*`` source field, applied by | 38 | #: Guardrail-named target field -> ``wall_*`` source field, applied by |
| 35 | #: :func:`wall_view_config`. | 39 | #: :func:`wall_view_config`. |
| 36 | _WALL_VIEW_MAP: dict[str, str] = { | 40 | _WALL_VIEW_MAP: dict[str, str] = { |
| 54 | "polyline_smooth_window": "wall_polyline_smooth_window", | 58 | "polyline_smooth_window": "wall_polyline_smooth_window", |
| 55 | } | 59 | } |
| 56 | 60 | ||
| 57 | 61 | ||
| 58 | class DetectorConfig( | ||
| 59 | _config_fields.CoreFields, | ||
| 60 | _config_fields.WallFields, | ||
| 61 | _config_fields_posts.PostFields, | ||
| 62 | _config_fields.OverlayFields, | ||
| 63 | ): | ||
| 64 | """Spatial and geometric thresholds, in metres unless stated otherwise. | ||
| 65 | |||
| 66 | The field set is declared by the mixins in | ||
| 67 | :mod:`guardrails._config_fields` / :mod:`guardrails._config_fields_posts` | ||
| 68 | and mirrors ``guardrails.default.json`` key for key; this class only adds | ||
| 69 | the cross-value checks that a declared field type cannot express. | ||
| 70 | """ | ||
| 71 | |||
| 72 | @pydantic.field_validator("residue_lever_band_m") | ||
| 73 | @classmethod | ||
| 74 | def _check_residue_band(cls, value: list[float]) -> list[float]: | ||
| 75 | """Reject a residue lever band that is not a ``[min_m, max_m]`` pair.""" | ||
| 76 | if len(value) != 2: | ||
| 77 | raise ValueError( | ||
| 78 | "residue_lever_band_m must contain exactly 2 values: [min_m, max_m]" | ||
| 79 | ) | ||
| 80 | return value | ||
| 81 | |||
| 82 | |||
| 83 | class DetectorConfigError(config_loader.ConfigError): | 62 | class DetectorConfigError(config_loader.ConfigError): |
| 84 | """Raised for an unsupported key or an invalid value in the guardrails config.""" | 63 | """Raised when guardrails config contains unsupported keys or values.""" |
| 85 | 64 | ||
| 86 | 65 | ||
| 87 | def load_default_config_dict() -> dict[str, Any]: | 66 | def load_default_config_dict() -> dict[str, Any]: |
| 88 | """Return the package-owned default config as a plain dict. | 67 | """Return the package-owned default config as a plain dict. |
| 89 | 68 | ||
| 90 | Returns: | 69 | Returns: |
| 91 | The decoded ``guardrails.default.json`` object. | 70 | The decoded ``guardrails.default.json`` object. |
| 92 | """ | 71 | """ |
| 93 | return config_loader.load_packaged_json( | 72 | return config_loader.load_packaged_json(_PACKAGE_NAME, _DEFAULT_FILENAME) |
| 94 | __package__ or _PACKAGE_NAME, _DEFAULT_CONFIG_NAME | ||
| 95 | ) | ||
| 96 | 73 | ||
| 97 | 74 | ||
| 98 | def config_from_dict(raw: dict[str, Any]) -> DetectorConfig: | 75 | def config_from_dict(raw: dict[str, Any]) -> DetectorConfig: |
| 99 | """Build a validated :class:`DetectorConfig` from a raw mapping. | 76 | """Build a validated :class:`DetectorConfig` from a raw mapping. |
| 100 | 77 | ||
| 101 | Unknown keys and values that do not fit their declared field type are | 78 | Unknown keys, values that do not fit their declared field type and values |
| 102 | rejected by the shared pydantic layer; the band-length rule on | 79 | outside a field's declared range are rejected by the shared pydantic layer. |
| 103 | ``residue_lever_band_m`` is the one guardrails-specific check that the | ||
| 104 | declared type ``list[float]`` cannot express. | ||
| 105 | 80 | ||
| 106 | Args: | 81 | Args: |
| 107 | raw: Merged config mapping (packaged defaults plus overrides). | 82 | raw: Merged config mapping (packaged defaults plus overrides). |
| 108 | 83 | ||
| 109 | Returns: | 84 | Returns: |
| 110 | The validated config. | 85 | The validated config. |
| 111 | 86 | ||
| 112 | Raises: | 87 | Raises: |
| 113 | DetectorConfigError: ``raw`` holds an unknown key, a value that is not | 88 | DetectorConfigError: ``raw`` holds an unknown key or a value that is |
| 114 | valid for its declared field type, or a ``residue_lever_band_m`` | 89 | not valid for its declared field type or range. |
| 115 | that is not a ``[min_m, max_m]`` pair. | ||
| 116 | """ | 90 | """ |
| 117 | return config_loader.validate_config( | 91 | return config_loader.validate_config( |
| 118 | DetectorConfig, | 92 | DetectorConfig, |
| 119 | raw, | 93 | raw, |
| 120 | context="guardrails config", | 94 | context=_CONTEXT, |
| 121 | error_cls=DetectorConfigError, | 95 | error_cls=DetectorConfigError, |
| 122 | ) | 96 | ) |
| 123 | 97 | ||
| 124 | 98 |
| 138 | that is not valid for its declared field type. | 112 | that is not valid for its declared field type. |
| 139 | """ | 113 | """ |
| 140 | config = config_loader.load_config( | 114 | config = config_loader.load_config( |
| 141 | DetectorConfig, | 115 | DetectorConfig, |
| 142 | package=__package__ or _PACKAGE_NAME, | 116 | package=_PACKAGE_NAME, |
| 143 | filename=_DEFAULT_CONFIG_NAME, | 117 | filename=_DEFAULT_FILENAME, |
| 144 | overrides=overrides, | 118 | overrides=overrides, |
| 145 | context="guardrails config", | 119 | context=_CONTEXT, |
| 146 | error_cls=DetectorConfigError, | 120 | error_cls=DetectorConfigError, |
| 147 | ) | 121 | ) |
| 148 | if overrides: | 122 | if overrides: |
| 149 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | 123 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) |
| 154 | """Return a re-validated copy of *config* with *updates* applied. | 128 | """Return a re-validated copy of *config* with *updates* applied. |
| 155 | 129 | ||
| 156 | Unlike ``model_copy(update=...)``, which writes the raw values straight | 130 | Unlike ``model_copy(update=...)``, which writes the raw values straight |
| 157 | into the copy, this rebuilds the model, so an unknown key, a value of the | 131 | into the copy, this rebuilds the model, so an unknown key, a value of the |
| 158 | wrong type and a failing cross-value check are all rejected exactly as they | 132 | wrong type and an out-of-range value are all rejected exactly as they are |
| 159 | are on load. Every in-package config derivation goes through here. | 133 | on load. Every in-package config derivation goes through here. |
| 160 | 134 | ||
| 161 | Args: | 135 | Args: |
| 162 | config: The config to derive from; never mutated (frozen model). | 136 | config: The config to derive from; never mutated (frozen model). |
| 163 | updates: Field name to new value; values go through the same coercion | 137 | updates: Field name to new value; values go through the same coercion |
| 1 | import argparse | 1 | import argparse |
| 2 | from pathlib import Path | 2 | from pathlib import Path |
| 3 | 3 | ||
| 4 | import pytest | 4 | import pytest |
| 5 | from iolabs.common import config_loader | ||
| 5 | 6 | ||
| 6 | from guardrails.config import ( | 7 | from guardrails.config import ( |
| 7 | DetectorConfig, | 8 | DetectorConfig, |
| 8 | DetectorConfigError, | 9 | DetectorConfigError, |
| 37 | "polyline_smooth_window": "wall_polyline_smooth_window", | 38 | "polyline_smooth_window": "wall_polyline_smooth_window", |
| 38 | } | 39 | } |
| 39 | 40 | ||
| 40 | 41 | ||
| 41 | def test_default_json_matches_model_defaults() -> None: | 42 | def test_model_defaults_match_packaged_json() -> None: |
| 42 | """guardrails.default.json is the schema source of truth; keep it in sync.""" | 43 | """guardrails.default.json is the schema source of truth; keep it in sync.""" |
| 43 | defaults = DetectorConfig().model_dump() | 44 | assert DetectorConfig().model_dump(mode="json") == load_default_config_dict() |
| 44 | json_config = load_default_config_dict() | ||
| 45 | assert set(json_config) == set(defaults) | ||
| 46 | for key, value in defaults.items(): | ||
| 47 | assert json_config[key] == value, key | ||
| 48 | 45 | ||
| 49 | 46 | ||
| 50 | def test_load_config_without_overrides_equals_defaults() -> None: | 47 | def test_load_config_returns_packaged_defaults() -> None: |
| 51 | assert load_config() == DetectorConfig() | 48 | assert load_config() == DetectorConfig() |
| 52 | 49 | ||
| 53 | 50 | ||
| 51 | def test_error_class_is_config_error() -> None: | ||
| 52 | """The package error class is the shared one, so callers can catch either.""" | ||
| 53 | assert issubclass(DetectorConfigError, config_loader.ConfigError) | ||
| 54 | assert issubclass(DetectorConfigError, ValueError) | ||
| 55 | |||
| 56 | |||
| 54 | def test_parse_set_overrides_json_decodes_values() -> None: | 57 | def test_parse_set_overrides_json_decodes_values() -> None: |
| 55 | parsed = parse_set_overrides( | 58 | parsed = parse_set_overrides( |
| 56 | ["merge_face_max_spacing_m=1.5", "decimation_enabled=true", "memory_budget_gb=8"] | 59 | ["merge_face_max_spacing_m=1.5", "decimation_enabled=true", "memory_budget_gb=8"] |
| 57 | ) | 60 | ) |
| 84 | def test_load_config_applies_overrides_with_type_coercion() -> None: | 87 | def test_load_config_applies_overrides_with_type_coercion() -> None: |
| 85 | config = load_config({"decimation_enabled": "true", "merge_face_max_faces": 3}) | 88 | config = load_config({"decimation_enabled": "true", "merge_face_max_faces": 3}) |
| 86 | assert config.decimation_enabled is True | 89 | assert config.decimation_enabled is True |
| 87 | assert config.merge_face_max_faces == 3 | 90 | assert config.merge_face_max_faces == 3 |
| 88 | with pytest.raises( | 91 | with pytest.raises(DetectorConfigError, match="residue_lever_band_m"): |
| 89 | DetectorConfigError, | ||
| 90 | match="residue_lever_band_m must contain exactly 2 values", | ||
| 91 | ): | ||
| 92 | load_config({"residue_lever_band_m": [0.3]}) | 92 | load_config({"residue_lever_band_m": [0.3]}) |
| 93 | 93 | ||
| 94 | 94 | ||
| 95 | def test_out_of_range_value_rejected() -> None: | ||
| 96 | """Declared field ranges are enforced on load, not at the point of use.""" | ||
| 97 | with pytest.raises(DetectorConfigError, match="min_length_m"): | ||
| 98 | load_config({"min_length_m": -1.0}) | ||
| 99 | with pytest.raises(DetectorConfigError, match="min_cell_points"): | ||
| 100 | load_config({"min_cell_points": 0}) | ||
| 101 | |||
| 102 | |||
| 95 | def test_bool_override_typo_rejected() -> None: | 103 | def test_bool_override_typo_rejected() -> None: |
| 96 | """A misspelled boolean token raises instead of silently meaning False.""" | 104 | """A misspelled boolean token raises instead of silently meaning False.""" |
| 97 | with pytest.raises(DetectorConfigError): | 105 | with pytest.raises(DetectorConfigError): |
| 98 | load_config({"decimation_enabled": "flase"}) | 106 | load_config({"decimation_enabled": "flase"}) |
| 103 | with pytest.raises(DetectorConfigError): | 111 | with pytest.raises(DetectorConfigError): |
| 104 | load_config({"merge_face_max_faces": 3.7}) | 112 | load_config({"merge_face_max_faces": 3.7}) |
| 105 | 113 | ||
| 106 | 114 | ||
| 107 | def test_unknown_key_rejected() -> None: | 115 | def test_unknown_top_level_key_is_rejected() -> None: |
| 108 | with pytest.raises(DetectorConfigError): | 116 | with pytest.raises(DetectorConfigError, match="not_a_key"): |
| 109 | config_from_dict({**load_default_config_dict(), "not_a_key": 1}) | 117 | config_from_dict({**load_default_config_dict(), "not_a_key": 1}) |
| 110 | 118 | ||
| 111 | 119 | ||
| 112 | def test_invalid_override_string_rejected() -> None: | 120 | def test_invalid_override_string_rejected() -> None: |
| 747 | # 5. Config surface | 747 | # 5. Config surface |
| 748 | # -------------------------------------------------------------------------- | 748 | # -------------------------------------------------------------------------- |
| 749 | 749 | ||
| 750 | 750 | ||
| 751 | def test_config_list_fields_are_not_shared_between_instances() -> None: | 751 | def test_config_sequence_fields_are_immutable_tuples() -> None: |
| 752 | """List defaults must come from ``field(default_factory=...)``.""" | 752 | """Sequence fields are tuples: freezing the model is shallow.""" |
| 753 | a, b = DetectorConfig(), DetectorConfig() | 753 | config = DetectorConfig() |
| 754 | assert a.post_catalog_spacings_m is not b.post_catalog_spacings_m | 754 | assert isinstance(config.post_catalog_spacings_m, tuple) |
| 755 | assert a.component_rail_band_m is not b.component_rail_band_m | 755 | assert isinstance(config.component_rail_band_m, tuple) |
| 756 | assert isinstance(config.residue_lever_band_m, tuple) | ||
| 756 | 757 | ||
| 757 | 758 | ||
| 758 | # -------------------------------------------------------------------------- | 759 | # -------------------------------------------------------------------------- |
| 759 | # 5. Station-quantization regression (the real-segment 1.0 m artifact) | 760 | # 5. Station-quantization regression (the real-segment 1.0 m artifact) |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.