Back to report index

guardrails f56235e: AI3D-379 Align config module with fleet pattern

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(-)
Importance #1: guardrails/_model_core.py @@ -0,0 +1,205 @@
1"""Ground, corridor, candidate, cluster, fit and memory field declarations.
2
3One schema slice of the guardrails detector config; the slices are recombined
4into the model by :mod:`guardrails._config_model`. The split exists only to
5keep every module under 500 lines these classes carry no behaviour, and the
6config schema is still the flat key set of ``guardrails.default.json``.
7
8Adding 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
12import pydantic
13from iolabs.common import config_loader
14
15
16class 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
Importance #2: guardrails/_model_posts.py @@ -0,0 +1,210 @@
1"""Post cadence, beam-underside and top-member field declarations.
2
3One schema slice of the guardrails detector config; the slices are recombined
4into the model by :mod:`guardrails._config_model`. The split exists only to
5keep every module under 500 lines these classes carry no behaviour, and the
6config schema is still the flat key set of ``guardrails.default.json``.
7
8Adding 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
12from typing import Literal, TypeAlias
13
14import pydantic
15from iolabs.common import config_loader
16
17#: Where the measured top member's rows are emitted (also an argparse choice).
18TopMemberType: TypeAlias = Literal["guardrail_top_rail", "guardrail_support", "w_beam"]
19
20
21class 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
Importance #3: guardrails/_model_wall.py @@ -0,0 +1,126 @@
1"""Noise-wall and overlay field declarations for :class:`DetectorConfig`.
2
3One schema slice of the guardrails detector config; the slices are recombined
4into the model by :mod:`guardrails._config_model`. The split exists only to
5keep every module under 500 lines these classes carry no behaviour, and the
6config schema is still the flat key set of ``guardrails.default.json``.
7
8Adding 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
12import pydantic
13from iolabs.common import config_loader
14
15
16class 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
120class 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
Importance #4: guardrails/config.py @@ -54,71 +58,41 @@
54 "polyline_smooth_window": "wall_polyline_smooth_window",58 "polyline_smooth_window": "wall_polyline_smooth_window",
55}59}
5660
5761
58class 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
83class DetectorConfigError(config_loader.ConfigError):62class 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."""
8564
8665
87def load_default_config_dict() -> dict[str, Any]:66def 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.
8968
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 )
9673
9774
98def config_from_dict(raw: dict[str, Any]) -> DetectorConfig:75def 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.
10077
101 Unknown keys and values that do not fit their declared field type are78 Unknown keys, values that do not fit their declared field type and values
102 rejected by the shared pydantic layer; the band-length rule on79 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.
10580
106 Args:81 Args:
107 raw: Merged config mapping (packaged defaults plus overrides).82 raw: Merged config mapping (packaged defaults plus overrides).
10883
109 Returns:84 Returns:
110 The validated config.85 The validated config.
11186
112 Raises:87 Raises:
113 DetectorConfigError: ``raw`` holds an unknown key, a value that is not88 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 )
12397
12498
Importance #5: guardrails/_config_fields.py @@ -1,312 +0,0 @@
1"""Field declarations for :class:`guardrails.config.DetectorConfig` (part 1).
2
3Split out of ``config.py`` only to keep both modules under the 500-line limit:
4the mixins here carry no behaviour, and the config schema is still the flat
5key set of ``guardrails.default.json``. Part 2 (the post / beam / top-member
6levers) lives in :mod:`guardrails._config_fields_posts`.
7"""
8
9from iolabs.common import config_loader
10
11
12class 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
202class 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
306class 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
Importance #6: guardrails/_config_model.py @@ -0,0 +1,32 @@
1"""Schema of the guardrails detector config.
2
3The schema is `DetectorConfig` (a `config_loader.ConfigModel`), mirroring
4`guardrails.default.json` key for key. It is assembled from the field slices
5in :mod:`guardrails._model_core`, :mod:`guardrails._model_wall` and
6:mod:`guardrails._model_posts`, split out only to keep every module under 500
7lines; loading, merging and ``--set`` handling live in :mod:`guardrails.config`.
8
9Adding a config key means adding the field to the matching slice and the same
10key to `guardrails.default.json` nothing else. Unknown keys are rejected.
11"""
12
13import logging
14
15from . import _model_core, _model_posts, _model_wall
16
17logger = logging.getLogger(__name__)
18
19
20class 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
Importance #7: guardrails/config.py @@ -1,36 +1,40 @@
1"""Detector configuration.1"""Loading, merging and ``--set`` handling for the guardrails detector config.
22
3Mirrors the config convention used by the iolabs point-cloud packages3The schema is `DetectorConfig` (a `config_loader.ConfigModel`, assembled in
4(``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a4:mod:`guardrails._config_model`), mirroring `guardrails.default.json` key for
5``guardrails.default.json`` algorithm config, and a typed params object5key: unknown keys are rejected and raw JSON / ``--set`` values are coerced to
6(:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are6the declared field types by the shared layer. Runtime overrides come from
7applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON.7repeatable ``--set PATH=VALUE`` flags, never repo-local JSON.
88
9The schema is the pydantic model :class:`DetectorConfig`, derived from9Adding a config key means adding the field to the model (in the matching
10:class:`iolabs.common.config_loader.ConfigModel`: unknown keys are rejected and10`guardrails._model_*` slice) and the same key to `guardrails.default.json`
11raw JSON / ``--set`` values are coerced to the declared field types by the11nothing else. Unknown keys are rejected.
12shared layer. Every field default is kept identical to
13``guardrails.default.json`` (guarded by a unit test), so ``DetectorConfig()``
14and :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
16the matching entry in ``guardrails.default.json`` nothing else.
17"""12"""
1813
19import logging14import logging
20from typing import Any15from typing import Any
2116
22import pydantic
23from iolabs.common import config_loader17from iolabs.common import config_loader
2418
25from . import _config_fields, _config_fields_posts19from ._config_model import DetectorConfig
2620
27logger = logging.getLogger(__name__)21logger = logging.getLogger(__name__)
2822
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]
3337
34#: Guardrail-named target field -> ``wall_*`` source field, applied by38#: 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] = {
Importance #8: guardrails/config.py @@ -138,12 +112,12 @@
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)))
Importance #9: guardrails/config.py @@ -154,10 +128,10 @@
154 """Return a re-validated copy of *config* with *updates* applied.128 """Return a re-validated copy of *config* with *updates* applied.
155129
156 Unlike ``model_copy(update=...)``, which writes the raw values straight130 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 the131 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 they132 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.
160134
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 coercion137 updates: Field name to new value; values go through the same coercion
Importance #10: tests/test_config.py @@ -1,8 +1,9 @@
1import argparse1import argparse
2from pathlib import Path2from pathlib import Path
33
4import pytest4import pytest
5from iolabs.common import config_loader
56
6from guardrails.config import (7from guardrails.config import (
7 DetectorConfig,8 DetectorConfig,
8 DetectorConfigError,9 DetectorConfigError,
Importance #11: tests/test_config.py @@ -37,21 +38,23 @@
37 "polyline_smooth_window": "wall_polyline_smooth_window",38 "polyline_smooth_window": "wall_polyline_smooth_window",
38}39}
3940
4041
41def test_default_json_matches_model_defaults() -> None:42def 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
4845
4946
50def test_load_config_without_overrides_equals_defaults() -> None:47def test_load_config_returns_packaged_defaults() -> None:
51 assert load_config() == DetectorConfig()48 assert load_config() == DetectorConfig()
5249
5350
51def 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
54def test_parse_set_overrides_json_decodes_values() -> None:57def 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 )
Importance #12: tests/test_config.py @@ -84,15 +87,20 @@
84def test_load_config_applies_overrides_with_type_coercion() -> None:87def 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 True89 assert config.decimation_enabled is True
87 assert config.merge_face_max_faces == 390 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]})
9393
9494
95def 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
95def test_bool_override_typo_rejected() -> None:103def 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"})
Importance #13: tests/test_config.py @@ -103,10 +111,10 @@
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})
105113
106114
107def test_unknown_key_rejected() -> None:115def 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})
110118
111119
112def test_invalid_override_string_rejected() -> None:120def test_invalid_override_string_rejected() -> None:
Importance #14: tests/test_support_class.py @@ -747,13 +747,14 @@
747# 5. Config surface747# 5. Config surface
748# --------------------------------------------------------------------------748# --------------------------------------------------------------------------
749749
750750
751def test_config_list_fields_are_not_shared_between_instances() -> None:751def 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_m754 assert isinstance(config.post_catalog_spacings_m, tuple)
755 assert a.component_rail_band_m is not b.component_rail_band_m755 assert isinstance(config.component_rail_band_m, tuple)
756 assert isinstance(config.residue_lever_band_m, tuple)
756757
757758
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)
Importance #15: README.md @@ -19,35 +19,36 @@
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 peak20`out/run_summary.json` records per-segment timings, alignment checks and peak
21RSS.21RSS.
2222
23## Configuration (iolabs convention)23## Configuration
2424
25Following the other iolabs point-cloud packages25Defaults live in `guardrails/guardrails.default.json`. The schema is
26(`iolabs_point_cloud_segmentation_trajectory` etc.), the package owns an26`DetectorConfig` in `guardrails/_config_model.py` (a
27algorithm config `guardrails/guardrails.default.json`. `guardrails/config.py` is27`iolabs.common.config_loader.ConfigModel`: it rejects unknown keys, coerces raw
28the loader/schema: `DetectorConfig` is a frozen pydantic model derived from28JSON / `--set` values to the declared field types and enforces the declared
29`iolabs.common.config_loader.ConfigModel` (the fleet SSOT: it rejects unknown29field ranges); its field declarations are split across the slices
30keys and coerces raw JSON / `--set` values to the declared field types), and its30`guardrails/_model_core.py`, `guardrails/_model_wall.py` and
31field set is the schema. The field declarations live in the two mixins31`guardrails/_model_posts.py` only to keep every module under 500 lines.
32`guardrails/_config_fields.py` and `guardrails/_config_fields_posts.py` (split32`guardrails/config.py` owns loading, merging and `--set` handling. **To add a
33only to keep every module under 500 lines). Every model default is kept33config key: add the field (with its type, default and any `Field` range) to the
34identical to the JSON (asserted by `tests/test_config.py`).34matching model slice and the same key with the same default to the JSON —
3535nothing else.** `load_config()`, `config_from_dict()`, `with_overrides()` and
36**Adding a config key:** declare the field on the matching mixin and add the36`wall_view_config()` return the frozen `DetectorConfig`; `with_overrides()` is
37same key/default to `guardrails.default.json`. Nothing else — there is no37the only sanctioned config derivation (it re-validates, unlike
38allowed-key list and no coercion helper to update.38`model_copy(update=...)`). Every model default is kept identical to the JSON
3939(asserted by `tests/test_config.py`).
40Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are40
41JSON-decoded), never repo-local JSON files:41Runtime overrides come from the repeatable `--set KEY=VALUE` CLI flag (values
42are JSON-decoded), never repo-local JSON files:
4243
43```bash44```bash
44--set decimation_enabled=true --set memory_budget_gb=8 --set occlusion_bridge_max_m=1545--set decimation_enabled=true --set memory_budget_gb=8 --set occlusion_bridge_max_m=15
45```46```
4647
47Logging mirrors those packages: `logging.getLogger(__name__)` with INFO progress48Logging mirrors the other iolabs packages: `logging.getLogger(__name__)` with
48per stage (ground DEM, per-record corridor candidates, occupancy clustering, XML49INFO progress per stage (ground DEM, per-record corridor candidates, occupancy
49export, per-segment completion with peak RSS).50clustering, XML export, per-segment completion with peak RSS).
5051
51## Face / barrier merge policy52## Face / barrier merge policy
5253
53A single physical rail (e.g. a W-beam) presents up to two near-parallel faces54A single physical rail (e.g. a W-beam) presents up to two near-parallel faces
Importance #16: guardrails/_config_fields.py @@ -1,312 +0,0 @@
1"""Field declarations for :class:`guardrails.config.DetectorConfig` (part 1).
2
3Split out of ``config.py`` only to keep both modules under the 500-line limit:
4the mixins here carry no behaviour, and the config schema is still the flat
5key set of ``guardrails.default.json``. Part 2 (the post / beam / top-member
6levers) lives in :mod:`guardrails._config_fields_posts`.
7"""
8
9from iolabs.common import config_loader
10
11
12class 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
202class 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
306class 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
Importance #17: guardrails/_config_model.py @@ -0,0 +1,32 @@
1"""Schema of the guardrails detector config.
2
3The schema is `DetectorConfig` (a `config_loader.ConfigModel`), mirroring
4`guardrails.default.json` key for key. It is assembled from the field slices
5in :mod:`guardrails._model_core`, :mod:`guardrails._model_wall` and
6:mod:`guardrails._model_posts`, split out only to keep every module under 500
7lines; loading, merging and ``--set`` handling live in :mod:`guardrails.config`.
8
9Adding a config key means adding the field to the matching slice and the same
10key to `guardrails.default.json` nothing else. Unknown keys are rejected.
11"""
12
13import logging
14
15from . import _model_core, _model_posts, _model_wall
16
17logger = logging.getLogger(__name__)
18
19
20class 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
Importance #18: guardrails/_model_core.py @@ -0,0 +1,205 @@
1"""Ground, corridor, candidate, cluster, fit and memory field declarations.
2
3One schema slice of the guardrails detector config; the slices are recombined
4into the model by :mod:`guardrails._config_model`. The split exists only to
5keep every module under 500 lines these classes carry no behaviour, and the
6config schema is still the flat key set of ``guardrails.default.json``.
7
8Adding 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
12import pydantic
13from iolabs.common import config_loader
14
15
16class 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
Importance #19: guardrails/_model_posts.py @@ -0,0 +1,210 @@
1"""Post cadence, beam-underside and top-member field declarations.
2
3One schema slice of the guardrails detector config; the slices are recombined
4into the model by :mod:`guardrails._config_model`. The split exists only to
5keep every module under 500 lines these classes carry no behaviour, and the
6config schema is still the flat key set of ``guardrails.default.json``.
7
8Adding 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
12from typing import Literal, TypeAlias
13
14import pydantic
15from iolabs.common import config_loader
16
17#: Where the measured top member's rows are emitted (also an argparse choice).
18TopMemberType: TypeAlias = Literal["guardrail_top_rail", "guardrail_support", "w_beam"]
19
20
21class 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
Importance #20: guardrails/_model_wall.py @@ -0,0 +1,126 @@
1"""Noise-wall and overlay field declarations for :class:`DetectorConfig`.
2
3One schema slice of the guardrails detector config; the slices are recombined
4into the model by :mod:`guardrails._config_model`. The split exists only to
5keep every module under 500 lines these classes carry no behaviour, and the
6config schema is still the flat key set of ``guardrails.default.json``.
7
8Adding 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
12import pydantic
13from iolabs.common import config_loader
14
15
16class 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
120class 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
Importance #21: guardrails/config.py @@ -1,36 +1,40 @@
1"""Detector configuration.1"""Loading, merging and ``--set`` handling for the guardrails detector config.
22
3Mirrors the config convention used by the iolabs point-cloud packages3The schema is `DetectorConfig` (a `config_loader.ConfigModel`, assembled in
4(``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a4:mod:`guardrails._config_model`), mirroring `guardrails.default.json` key for
5``guardrails.default.json`` algorithm config, and a typed params object5key: unknown keys are rejected and raw JSON / ``--set`` values are coerced to
6(:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are6the declared field types by the shared layer. Runtime overrides come from
7applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON.7repeatable ``--set PATH=VALUE`` flags, never repo-local JSON.
88
9The schema is the pydantic model :class:`DetectorConfig`, derived from9Adding a config key means adding the field to the model (in the matching
10:class:`iolabs.common.config_loader.ConfigModel`: unknown keys are rejected and10`guardrails._model_*` slice) and the same key to `guardrails.default.json`
11raw JSON / ``--set`` values are coerced to the declared field types by the11nothing else. Unknown keys are rejected.
12shared layer. Every field default is kept identical to
13``guardrails.default.json`` (guarded by a unit test), so ``DetectorConfig()``
14and :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
16the matching entry in ``guardrails.default.json`` nothing else.
17"""12"""
1813
19import logging14import logging
20from typing import Any15from typing import Any
2116
22import pydantic
23from iolabs.common import config_loader17from iolabs.common import config_loader
2418
25from . import _config_fields, _config_fields_posts19from ._config_model import DetectorConfig
2620
27logger = logging.getLogger(__name__)21logger = logging.getLogger(__name__)
2822
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]
3337
34#: Guardrail-named target field -> ``wall_*`` source field, applied by38#: 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] = {
Importance #22: guardrails/config.py @@ -54,71 +58,41 @@
54 "polyline_smooth_window": "wall_polyline_smooth_window",58 "polyline_smooth_window": "wall_polyline_smooth_window",
55}59}
5660
5761
58class 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
83class DetectorConfigError(config_loader.ConfigError):62class 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."""
8564
8665
87def load_default_config_dict() -> dict[str, Any]:66def 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.
8968
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 )
9673
9774
98def config_from_dict(raw: dict[str, Any]) -> DetectorConfig:75def 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.
10077
101 Unknown keys and values that do not fit their declared field type are78 Unknown keys, values that do not fit their declared field type and values
102 rejected by the shared pydantic layer; the band-length rule on79 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.
10580
106 Args:81 Args:
107 raw: Merged config mapping (packaged defaults plus overrides).82 raw: Merged config mapping (packaged defaults plus overrides).
10883
109 Returns:84 Returns:
110 The validated config.85 The validated config.
11186
112 Raises:87 Raises:
113 DetectorConfigError: ``raw`` holds an unknown key, a value that is not88 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 )
12397
12498
Importance #23: guardrails/config.py @@ -138,12 +112,12 @@
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)))
Importance #24: guardrails/config.py @@ -154,10 +128,10 @@
154 """Return a re-validated copy of *config* with *updates* applied.128 """Return a re-validated copy of *config* with *updates* applied.
155129
156 Unlike ``model_copy(update=...)``, which writes the raw values straight130 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 the131 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 they132 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.
160134
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 coercion137 updates: Field name to new value; values go through the same coercion
Importance #25: tests/test_config.py @@ -1,8 +1,9 @@
1import argparse1import argparse
2from pathlib import Path2from pathlib import Path
33
4import pytest4import pytest
5from iolabs.common import config_loader
56
6from guardrails.config import (7from guardrails.config import (
7 DetectorConfig,8 DetectorConfig,
8 DetectorConfigError,9 DetectorConfigError,
Importance #26: tests/test_config.py @@ -37,21 +38,23 @@
37 "polyline_smooth_window": "wall_polyline_smooth_window",38 "polyline_smooth_window": "wall_polyline_smooth_window",
38}39}
3940
4041
41def test_default_json_matches_model_defaults() -> None:42def 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
4845
4946
50def test_load_config_without_overrides_equals_defaults() -> None:47def test_load_config_returns_packaged_defaults() -> None:
51 assert load_config() == DetectorConfig()48 assert load_config() == DetectorConfig()
5249
5350
51def 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
54def test_parse_set_overrides_json_decodes_values() -> None:57def 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 )
Importance #27: tests/test_config.py @@ -84,15 +87,20 @@
84def test_load_config_applies_overrides_with_type_coercion() -> None:87def 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 True89 assert config.decimation_enabled is True
87 assert config.merge_face_max_faces == 390 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]})
9393
9494
95def 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
95def test_bool_override_typo_rejected() -> None:103def 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"})
Importance #28: tests/test_config.py @@ -103,10 +111,10 @@
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})
105113
106114
107def test_unknown_key_rejected() -> None:115def 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})
110118
111119
112def test_invalid_override_string_rejected() -> None:120def test_invalid_override_string_rejected() -> None:
Importance #29: tests/test_support_class.py @@ -747,13 +747,14 @@
747# 5. Config surface747# 5. Config surface
748# --------------------------------------------------------------------------748# --------------------------------------------------------------------------
749749
750750
751def test_config_list_fields_are_not_shared_between_instances() -> None:751def 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_m754 assert isinstance(config.post_catalog_spacings_m, tuple)
755 assert a.component_rail_band_m is not b.component_rail_band_m755 assert isinstance(config.component_rail_band_m, tuple)
756 assert isinstance(config.residue_lever_band_m, tuple)
756757
757758
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)