Back to report index

guardrails 14aca88: AI3D-379 Pydantic config models via iolabs-common ConfigModel

Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:38:21+02:00

Commit #57 ยท 113 snippets

 README.md                             |  12 +-
 guardrails/_config_fields.py          | 312 ++++++++++++++++
 guardrails/_config_fields_posts.py    | 204 +++++++++++
 guardrails/config.py                  | 647 ++++++----------------------------
 guardrails/lane_xml.py                |   5 +-
 guardrails/outputs.py                 |   3 +-
 pyproject.toml                        |   5 +-
 tests/test_config.py                  |  15 +-
 tests/test_detect_wall_integration.py |  29 +-
 tests/test_edge_gate.py               |   9 +-
 tests/test_posts.py                   |   3 +-
 tests/test_precision_gate.py          |  11 +-
 tests/test_support_class.py           |  26 +-
 tests/test_top_member.py              |  22 +-
 tests/test_wall_geometry.py           |   8 +-
 15 files changed, 699 insertions(+), 612 deletions(-)
Importance #1: guardrails/config.py @@ -549,30 +113,39 @@
549 DetectorConfigError: ``raw`` holds an unknown key, a value that is not113 DetectorConfigError: ``raw`` holds an unknown key, a value that is not
550 valid for its declared field type, or a ``residue_lever_band_m``114 valid for its declared field type, or a ``residue_lever_band_m``
551 that is not a ``[min_m, max_m]`` pair.115 that is not a ``[min_m, max_m]`` pair.
552 """116 """
553 config = dataclass_from_mapping(117 return config_loader.validate_config(
554 DetectorConfig,118 DetectorConfig,
555 raw,119 raw,
556 context="guardrails config",120 context="guardrails config",
557 error_cls=DetectorConfigError,121 error_cls=DetectorConfigError,
558 )122 )
559 if len(config.residue_lever_band_m) != 2:
560 raise DetectorConfigError(
561 "residue_lever_band_m must contain exactly 2 values: [min_m, max_m]"
562 )
563 return config
564123
565124
566def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig:125def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig:
567 """Load the default config and apply flat ``PATH=VALUE`` overrides.126 """Load the default config and apply flat ``PATH=VALUE`` overrides.
568127
569 Overrides come from the CLI ``--set`` flag (already parsed into a dict).128 Overrides come from the CLI ``--set`` flag (already parsed into a dict).
129
130 Args:
131 overrides: Flat mapping of config key to value, or ``None``.
132
133 Returns:
134 The validated config.
135
136 Raises:
137 DetectorConfigError: An override names an unknown key or holds a value
138 that is not valid for its declared field type.
570 """139 """
571 merged = copy.deepcopy(load_default_config_dict())140 config = config_loader.load_config(
572 for key, value in (overrides or {}).items():141 DetectorConfig,
573 merged[key] = value142 package=__package__ or _PACKAGE_NAME,
574 config = config_from_dict(merged)143 filename=_DEFAULT_CONFIG_NAME,
144 overrides=overrides,
145 context="guardrails config",
146 error_cls=DetectorConfigError,
147 )
575 if overrides:148 if overrides:
576 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))149 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
577 return config150 return config
578151
Importance #2: guardrails/_config_fields.py @@ -0,0 +1,312 @@
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 #3: guardrails/_config_fields_posts.py @@ -0,0 +1,204 @@
1"""Field declarations for :class:`guardrails.config.DetectorConfig` (part 2).
2
3The guardrail rail-vs-support decomposition levers (post cadence, beam
4underside, top member). Split out of ``config.py`` for the 500-line limit; see
5:mod:`guardrails._config_fields` for the rest of the schema.
6"""
7
8from typing import Literal
9
10from iolabs.common import config_loader
11
12
13class PostFields(config_loader.ConfigModel):
14 """Post cadence, beam-underside and top-member levers."""
15
16 # Guardrail rail-vs-support decomposition (post cadence + support class).
17 # Height cut lines are literature-derived (Swiss/German hardware: rail band
18 # top edge ~0.75 m, Sigma-100 post 100x55 mm, ASTRA 11005 post spacings
19 # 1.33 / 2.00 m and DDSP 4.00 m), not yet tuned on our clouds; keep in
20 # config. All three feature flags default true; setting them false restores
21 # the pre-feature behavior exactly.
22 enable_post_cadence: bool = True
23 enable_support_class: bool = True
24 enable_component_masks: bool = True
25 post_low_band_min_m: float = 0.10
26 post_low_band_max_m: float = 0.35
27 post_station_bin_m: float = 0.10
28 post_lateral_halfwidth_m: float = 0.60
29 post_catalog_spacings_m: list[float] = [1.33, 2.0, 4.0]
30 post_spacing_snap_rel_tol: float = 0.12
31 post_min_period_m: float = 0.8
32 post_max_period_m: float = 6.0
33 post_min_confidence: float = 0.35
34 post_slot_min_points: int = 3
35 # Per-post peak detection (``posts.detect_run_posts``). The run-level comb
36 # (``post_min_confidence``) is only a scoring prior now: on a long rail the
37 # low band also carries continuous grass/plinth clutter, which drowns the
38 # comb contrast, so posts are accepted individually against a ROLLING local
39 # background instead of all-or-nothing against the run mean.
40 post_peak_smooth_m: float = 0.3
41 post_peak_background_window_m: float = 5.0
42 post_peak_min_prominence: float = 3.0
43 # A dense low band is also a NOISY one: at b points per smoothing window the
44 # Poisson swing is sqrt(b), so a fixed point floor would fabricate posts out
45 # of grass on exactly the cluttered runs this feature exists for. The
46 # effective floor is max(post_peak_min_prominence, sigmas * sqrt(background)).
47 post_peak_noise_sigmas: float = 3.0
48 post_peak_min_confidence: float = 0.25
49 # Wider above-background blobs are plinths / kerbs / parked clutter, not a
50 # 0.10 m post footprint. Measured at half prominence (see detect_run_posts).
51 post_max_station_extent_m: float = 0.45
52 # Measured post top is clamped to [rail band bottom, beam bottom + margin].
53 post_top_margin_m: float = 0.10
54 # Behind-beam shaft claim: a post-footprint point this far outboard of the
55 # rail's LOCAL centerline (not of its run-mean offset โ€” a 50 m polyline
56 # wanders further off its own mean than this threshold, which made the
57 # first cut of this rule inert on every curved run) sits on the far side of
58 # the beam from the road, so it is post shaft, not beam, and may be claimed
59 # up to the rail top. The threshold is the larger of
60 # ``post_behind_beam_offset_m`` (half a w-beam depth plus a margin: the
61 # floor, and what a rail with no measured width gets) and half the rail's
62 # ``width_m`` plus ``post_behind_beam_margin_m`` (what a wide rail needs).
63 post_claim_behind_beam: bool = True
64 post_behind_beam_offset_m: float = 0.22
65 post_behind_beam_margin_m: float = 0.05
66 # ... and once the post line itself is MEASURED (``_measured_post_side``),
67 # the threshold moves off that generic floor onto the hardware: the post's
68 # front face is ``|post_lat| - post_behind_beam_front_margin_m`` (an
69 # IPE-100 flange at 0.05 m plus the spacer that holds the plank off it),
70 # never nearer than the beam's own edge. The floor costs the A4/5 105
71 # median rails half their shaft: post line at 0.24-0.25 m against a 0.22 m
72 # threshold leaves the spacer and the post's road-side half to the rail.
73 post_behind_beam_front_margin_m: float = 0.10
74 # Where a measured post is PUT: the parent polyline at that post's station,
75 # displaced by ``post.offset_m`` minus the polyline's OWN spine offset there
76 # (round 7). With this off the displacement is measured against the run's
77 # constant ``mean_offset_m`` instead -- the round-6 behaviour, kept only so
78 # the flags-off byte-identity replay has something to compare against. On a
79 # run that wanders (A4/5 105 rail 1: 0.69 m end to end) the mean form walks
80 # the published post train diagonally across its own rail.
81 post_xy_local_offset_enabled: bool = True
82 # Measured per-rail beam underside (``posts.measure_beam_bottom``). The
83 # evidence pass folds a HEIGHT histogram over [post_low_band_min_m,
84 # beam_bottom_hist_max_m] alongside the station histogram, scoped to a
85 # tighter lateral halfwidth than the post band (the beam sits on the run's
86 # mean offset; kerb / soil returns further out only blur the onset).
87 # ``beam_bottom_hist_bin_m`` divides the distance from
88 # ``post_low_band_min_m`` to 0.35 / 0.75 / 0.85 exactly, so the rail band
89 # floor and the plausibility cap fall on bin edges rather than inside a bin.
90 beam_bottom_hist_bin_m: float = 0.025
91 # Ceiling of that histogram. 1.30 m (= ``max_height_m``, 48 bins from the
92 # 0.10 m floor) rather than the 1.00 m of rounds 3-6: the beam TOP walk-up
93 # and ``detect_top_member`` both need headroom ABOVE the structure to tell
94 # a bounded member (a Kastenprofil tube: mass ends at 0.98 m and there is
95 # nothing over it) from an unbounded one (a noise wall / hedge / parapet,
96 # which keeps going). At 1.00 m every A4/5 median tube reported
97 # ``truncated`` against what was really the knob, not the cloud.
98 # ``measure_beam_bottom`` is provably unchanged by the raise: its window is
99 # ``component_rail_band_m`` = [0.35, 0.85) and its walk is downward only,
100 # so bins added above cannot move the scale, the dense groups or the
101 # underside.
102 beam_bottom_hist_max_m: float = 1.30
103 beam_bottom_lateral_halfwidth_m: float = 0.40
104 # A candidate beam band is a contiguous group of bins carrying at least this
105 # fraction of the tallest bin in the rail band. Candidates are tried lowest
106 # first (a stacked double w-beam has two, and the upper one is often the
107 # taller), but only TRIED: the low band's own tail can clear this floor and
108 # group up below the beam, and on A4/5 066 rail 5 it does.
109 beam_bottom_band_fraction: float = 0.15
110 # Walking down from a candidate's peak, the underside is where the count
111 # first drops below this fraction of the peak bin.
112 beam_bottom_onset_fraction: float = 0.20
113 # ... and the drop has to be a STEP, not a drift across that threshold. A
114 # continuous barrier mistyped w_beam (A4/5 066 rail 2) has no underside at
115 # all, only a smooth ramp, and any walk-down threshold stops somewhere
116 # arbitrary in it. The knob sits in the gap the A4/5 rails measure out
117 # between two populations: the nine rails that do carry a beam step
118 # 2.00-54x at their onset (the 2.00 is 132 rail 0), while on the seven that
119 # do not, the strongest single-bin rise ANYWHERE in the rail band is 1.67x
120 # โ€” and that is already a harder test than this guard, which only ever
121 # looks at the bin the walk stopped on.
122 beam_bottom_min_onset_ratio: float = 1.8
123 beam_bottom_min_peak_points: int = 50
124 # Round 7: the same walk, upwards, giving the beam TOP -- and with it the
125 # shaft cap the claim should always have used. Gates the MEASUREMENT (the
126 # walk in ``_band_underside``, hence ``detect_top_member``'s precondition
127 # and the shaft cap's preference) as well as the PUBLICATION
128 # (``beam_bottom.top_height_m`` / ``top_measured`` / ``reason_top`` and
129 # ``polyline_beam_top_z_m``), so with it off guardrails.json is
130 # byte-identical to the round-6 one and no member can be detected.
131 post_beam_top_enabled: bool = True
132 # Plausibility window for the result: below ``component_rail_band_m[0]`` it
133 # is not beam (no rail evidence is counted there), above this it is a
134 # gantry / sign / noise wall, not a w-beam underside.
135 beam_bottom_max_m: float = 0.75
136 # Beam band [bottom, top] above the road, used as the fallback when a rail
137 # instance carries no measured ``polyline_bottom_z_m`` / ``polyline_top_z_m``.
138 component_rail_band_m: list[float] = [0.35, 0.85]
139 component_support_max_height_m: float = 0.50
140 component_support_station_tol_m: float = 0.20
141 component_support_footprint_m: float = 0.25
142
143 # --- Round 7: the top member (the Kastenprofil box tube on the A4/5
144 # median rails). ``detect_top_member`` measures the band ABOVE the beam
145 # top, and the two load-bearing gates are the mass fraction and the
146 # STATION COVERAGE: mass alone accepts a 27 m stub of vegetation behind a
147 # rail (A4/5 066 rail 1, mass fraction 0.44), and only "is this band there
148 # at every station of the run" rejects it (coverage 0.57 against 1.00 on
149 # all four real tubes).
150 post_top_member_enabled: bool = True
151 # Where the tube's rows go. "guardrail_top_rail" (default) emits the
152 # companion instance and LAS 74; "guardrail_support" folds them into the
153 # parent's support instance (LAS 72); "w_beam" leaves them on the parent
154 # rail (LAS 66). The last two emit no companion instance, so the fusion
155 # JSON paint has nothing to read and only the mask sidecar carries them.
156 post_top_member_type: Literal[
157 "guardrail_top_rail", "guardrail_support", "w_beam"
158 ] = "guardrail_top_rail"
159 # Mass above the measured beam top, over the mass in the rail window.
160 # Measured 0.49-0.53 on the four A4/5 tubes; 0.002-0.066 on nine of the
161 # twelve rails without one, 0.39-0.44 on the two 066 outliers coverage
162 # rejects.
163 post_top_member_min_mass_fraction: float = 0.15
164 # A member is "the thing above the post line", so there has to be a post
165 # line: below this many measured posts the run reports ``no_posts``.
166 post_top_member_min_posts: int = 2
167 # A bin is part of the band when it carries this fraction of the tallest
168 # bin above the beam top; the band is the contiguous dense group with the
169 # largest MASS (not the topmost one -- with the 1.30 m ceiling that picks
170 # a blob 0.30 m over the beam on A4/5 105 rail 3).
171 post_top_member_band_fraction: float = 0.15
172 # Reported, not enforced (a thin band that is present at every station is
173 # still a member; the real discriminators are mass and coverage).
174 post_top_member_min_thickness_m: float = 0.075
175 # A station bin counts as covered when the band carries this many points
176 # in it, over the station bins that carry any point of the run's slab.
177 post_top_member_min_bin_points: int = 3
178 post_top_member_min_coverage: float = 0.90
179 # Colocation with the measured post line, and the band's own lateral
180 # spread. Both are REPORTED on every rail; the gate is off by default
181 # (mass + coverage already separate the two populations by 0.33 of
182 # coverage, and three rails without a tube pass the lateral test anyway).
183 post_top_member_lateral_gate_enabled: bool = False
184 # ``post_top_member_max_lateral_offset_m`` is enforced whatever that flag
185 # says in ONE place: the prism's axis. A post median that disagrees with
186 # the band's own measured lateral by more than this is not the line the
187 # member runs along, and sweeping a full-length 0.25 m prism down it would
188 # paint whatever stands behind the rail (see ``_top_rail_geometry``).
189 post_top_member_max_lateral_offset_m: float = 0.12
190 post_top_member_max_lateral_spread_m: float = 0.15
191 # Halfwidth of the swept prism that claims the tube, about the robust post
192 # line. The measured 2-98 percentile lateral extent of the four A4/5 tubes
193 # about that line is within [-0.20, +0.17] m.
194 post_top_member_halfwidth_m: float = 0.25
195 # --- Round 7: the behind-beam outward sign, from the MEASURED post side.
196 # ``sign(mean_offset_m)`` assumes the posts are always further from the
197 # spine than the beam; on the A4/5 median rails that is true on only half
198 # of them, and the shaft claim is completely dead on the other half. The
199 # three guards are what keep every rail whose posts sit ON the line (the
200 # outer rails: |side| 0.004-0.079) on the old sign, bit for bit.
201 post_behind_beam_use_measured_side: bool = True
202 post_behind_beam_min_post_offset_m: float = 0.10
203 post_behind_beam_min_posts: int = 4
204 post_behind_beam_min_side_agreement: float = 0.70
0
Importance #4: guardrails/config.py @@ -4,540 +4,104 @@
4(``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a4(``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a
5``guardrails.default.json`` algorithm config, and a typed params object5``guardrails.default.json`` algorithm config, and a typed params object
6(:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are6(:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are
7applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON.7applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON.
8``config.py`` is the loader/schema: the dataclass field set is the schema and
9every field default is kept identical to ``guardrails.default.json`` (guarded by
10a unit test), so ``DetectorConfig()`` and ``load_config()`` agree.
11"""
128
9The schema is the pydantic model :class:`DetectorConfig`, derived from
10:class:`iolabs.common.config_loader.ConfigModel`: unknown keys are rejected and
11raw JSON / ``--set`` values are coerced to the declared field types by the
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"""
1318
14import copy
15import logging19import logging
16from dataclasses import dataclass, field, replace
17from typing import Any20from typing import Any
1821
19from iolabs.common.config_loader import (22import pydantic
20 ConfigError,23from iolabs.common import config_loader
21 dataclass_from_mapping,
22 load_packaged_json,
23)
24from iolabs.common.config_loader import parse_set_overrides as _parse_set_overrides
25
26logger = logging.getLogger(__name__)
27
2824
29@dataclass(frozen=True)25from . import _config_fields, _config_fields_posts
30class DetectorConfig:
31 """Spatial and geometric thresholds, in metres unless stated otherwise."""
32
33 # Ground model
34 ground_cell_m: float = 0.75
35 ground_percentile: float = 8.0
36
37 # Corridor crop (station / offset frame)
38 corridor_offset_min_m: float = 1.5
39 corridor_offset_max_m: float = 10.0
40 corridor_include_median_zone: bool = True
41 median_corridor_offset_min_m: float = 0.8
42 median_corridor_offset_max_m: float = 3.8
43 corridor_max_height_m: float = 2.0
44 station_window_m: float = 5.0
45 median_side_max_offset_m: float = 3.5
46
47 # Optional lane-XML carriageway / rail-zone scoping
48 lane_xml_zones_enabled: bool = True
49 lane_xml_path: str | None = None
50 rail_zone_margin_m: float = 10.0
51 outer_rail_band_m: float = 20.0
52 single_edge_rail_margin_m: float = 15.0
53 max_carriageway_width_m: float = 15.0
54 zone_bbox_margin_m: float = 140.0
55 interior_rejection_depth_m: float = 2.0
56
57 # Optional late edge gate: instance-level distance filters against the
58 # lane-XML edge lines (rules E1/E2), applied after the precision gate.
59 # edge_gate_max_rail_distance_m was calibrated on A1 segments 060/066/085:
60 # real rails measure <= 3.7 m from an XML edge, noise >= 5.4 m.
61 edge_gate_enabled: bool = True
62 edge_gate_max_rail_distance_m: float = 5.0
63 edge_gate_interior_depth_m: float = 0.5
64 edge_gate_interior_max_frac: float = 0.5
65 edge_gate_apply_to_walls: bool = False
66
67 # Optional late precision gate over final rail/wall runs.
68 precision_gate_enabled: bool = True
69 precision_deep_interior_depth_m: float = 2.0
70 precision_deep_interior_frac_min: float = 0.50
71 precision_vehicle_max_length_m: float = 15.0
72 precision_vehicle_min_density_per_m: float = 750.0
73 precision_vehicle_min_mean_height_m: float = 0.80
74 precision_low_max_mean_height_m: float = 0.35
75 precision_sparse_max_density_per_m: float = 300.0
76 precision_sparse_min_outboard_gap_m: float = 6.0
77 precision_curve_min_line_rmse_m: float = 0.010
78 precision_far_min_axis_dist_m: float = 18.0
79 precision_long_low_min_length_m: float = 25.0
80 precision_edge_beyond_frac_min: float = 0.25
81 precision_dense_low_min_density_per_m: float = 2500.0
82 precision_parallel_min_inboard_gap_m: float = 3.0
83 precision_parallel_min_overlap_frac: float = 0.75
84 precision_unknown_far_min_axis_dist_m: float = 20.0
85 precision_very_far_min_outboard_gap_m: float = 12.0
86 precision_very_far_min_axis_dist_m: float = 25.0
87 precision_edge_abeam_window_m: float = 15.0
88 precision_edge_outboard_epsilon_m: float = 0.30
89
90 # Occupancy grid for candidate cells
91 occupancy_cell_m: float = 0.10
92
93 # Height band for initial point candidates (also drives candidates overlay)
94 min_height_m: float = 0.20
95 max_height_m: float = 1.30
96
97 # Per-cell rail-band fraction and mean-height gates
98 rail_band_min_m: float = 0.35
99 rail_band_max_m: float = 0.85
100 min_cell_points: int = 3
101 min_rail_points: int = 2
102 min_rail_fraction: float = 0.40
103 min_mean_height_m: float = 0.42
104 max_mean_height_m: float = 0.78
105
106 # Optional tablecloth-residue candidate lever
107 tablecloth_masks_dir: str | None = None
108 residue_union_enabled: bool = True
109 residue_cell_frac: float = 0.8
110 residue_lever_band_m: list[float] = field(default_factory=lambda: [0.30, 1.20])
111
112 # Vegetation rejection: compact height-above-ground spread within a cell
113 max_cell_height_spread_m: float = 0.50
114
115 # Tall-object fraction per cell (trees, poles)
116 tall_min_m: float = 1.30
117 tall_max_m: float = 4.50
118 max_tall_fraction: float = 0.12
119
120 # Local covariance / eigenvector candidate filter (cell-level)
121 eigen_neighborhood_radius_m: float = 0.40
122 eigen_min_neighbors: int = 5
123 min_linearity: float = 0.30
124 min_verticality: float = 0.15
125 use_eigen_cell_filter: bool = False
126
127 # DBSCAN clustering on selected occupancy cells
128 cluster_eps_m: float = 0.20
129 cluster_min_samples: int = 3
130
131 # Post-cluster merge of collinear fragments
132 merge_gap_m: float = 4.5
133 merge_angle_deg: float = 15.0
134 merge_lateral_max_m: float = 0.50
135
136 # Occlusion bridging: join collinear fragments across a parked-vehicle /
137 # occlusion shadow when heading and offset stay continuous (defect 4). The
138 # bridged station interval is recorded in ``gap_spans`` (never interpolated
139 # silently).
140 # Default is conservative (8 m) so bridging never fuses two distinct
141 # barriers into one instance; raise via --set occlusion_bridge_max_m=15 for
142 # datasets with longer occlusion shadows.
143 occlusion_bridge_max_m: float = 8.0
144 occlusion_bridge_max_angle_deg: float = 4.0
145 occlusion_bridge_max_lateral_m: float = 0.40
146
147 # Parallel-face deduplication (two faces of one physical rail).
148 # ``dedupe_*`` are retained for backward compatibility; the active policy is
149 # driven by ``merge_face_*`` (see README "Face / barrier merge policy").
150 dedupe_face_max_sep_m: float = 1.0
151 dedupe_max_angle_deg: float = 12.0
152 merge_face_max_spacing_m: float = 1.3
153 merge_face_max_heading_deg: float = 5.0
154 merge_face_min_station_overlap: float = 0.5
155 merge_face_max_faces: int = 2
156
157 # Instance acceptance (applied after merge)
158 min_length_m: float = 12.0
159 max_local_width_m: float = 0.75
160 min_longitudinal_coverage: float = 0.35
161
162 # Ordered-walk polyline construction
163 polyline_bin_m: float = 1.0
164 polyline_smooth_window: int = 5
165 walk_max_step_m: float = 0.30
166
167 # Gap recording along station
168 gap_min_span_m: float = 2.0
169
170 # Vehicle / occlusion-shadow rejection on cluster height distribution
171 max_cluster_height_spread_m: float = 0.80
172 max_cluster_p95_height_m: float = 1.15
173
174 # Straightness check along sliding window (short clusters only)
175 straightness_window_m: float = 10.0
176 max_straightness_deviation_m: float = 0.50
177 straightness_max_length_m: float = 25.0
178
179 # Heuristic type classification thresholds
180 w_beam_min_height_m: float = 0.40
181 w_beam_max_height_m: float = 0.90
182 w_beam_max_height_spread_m: float = 0.55
183 concrete_min_height_m: float = 0.80
184 concrete_max_height_spread_m: float = 0.45
185 cable_suspect_max_spread_m: float = 0.25
186
187 # Per-run confidence heuristic (0-1); see README "Run confidence".
188 # confidence = 0.35*support + 0.25*continuity + 0.25*extent + 0.15*height
189 confidence_density_norm_pts_per_m: float = 500.0
190 confidence_full_extent_m: float = 40.0
191 confidence_max_height_std_m: float = 0.2
192
193 # Memory hardening (deployment target is a 32 GB RAM Azure node).
194 memory_budget_gb: float = 10.0
195 station_process_window_m: float = 5.0
196 decimation_enabled: bool = False
197 decimation_voxel_m: float = 0.05
198 decimation_density_cap: int = 400000
199 # Records larger than this stream through the corridor crop in chunks of
200 # this many points instead of being materialized whole (byte-identical
201 # results for records at or below the threshold, which use the old path).
202 record_chunk_points: int = 4000000
203 # Exclusion clustering guard: DBSCAN memory scales with the number of
204 # eps-neighbour pairs. When a cheap grid estimate of that count exceeds
205 # this cap the exclusion candidates are voxel-decimated first (auto-trigger
206 # only; sparse segments are untouched). segment_134's dense record
207 # estimated 4.0e9 pairs (25 GB RSS); curated segments peak at 6.3e8.
208 exclusion_pair_estimate_max: float = 1000000000.0
209 exclusion_decimation_cell_m: float = 0.10
210 # After the density trigger decimates, the residual DBSCAN runs under the
211 # shared iolabs.common.memory_guard watchdog (subprocess + psutil RSS
212 # monitor, hard kill above the limit) as a second line of defense. Mirrors
213 # the subcluster_dbscan_memory_guard wiring in
214 # iolabs_point_cloud_modelling_lines / iolabs_geometry_geometry.fit_spline.
215 exclusion_use_shared_watchdog: bool = True
216 exclusion_dbscan_mem_limit_gb: float = 6.0
217 exclusion_dbscan_timeout_s: float = 120.0
218
219 # Wall detection: independent evidence/fitting channel (see README "Noise
220 # walls"). ``wall_detection_enabled=False`` is a process-level kill switch;
221 # it emits ``"walls": []`` and allocates no wall grids.
222 wall_detection_enabled: bool = True
223 wall_cell_m: float = 0.25
224 wall_height_bin_m: float = 0.25
225 wall_min_height_m: float = 0.30
226 wall_max_height_m: float = 8.00
227 wall_offset_min_m: float = 1.50
228 # Dataset ground truth (segments 133-137; segment_135 confirmed walls near
229 # offset ~23 m) puts walls at spine offsets 21-25 m; 20.0 would miss them.
230 wall_offset_max_m: float = 26.00
231 wall_min_cell_points: int = 6
232 wall_min_top_height_m: float = 2.50
233 wall_max_top_height_m: float = 8.00
234 # Grazing-angle MLS returns are banded, not continuous: production
235 # segment_135 wall cells measured occupied-bin fill p10=0.040/p50=0.071.
236 wall_min_vertical_fill: float = 0.05
237 # Per-cell minimum distinct occupied height bins; rejects single-scanline
238 # artifacts.
239 wall_min_occupied_bins: int = 2
240 # Per-cell occupied-bin span (last - first occupied bin, inclusive) in
241 # metres: separates vertical-sheet wall cells (bins spread over metres)
242 # from grazing-angle surface/embankment cells banded within ~0.5 m.
243 wall_min_cell_height_span_m: float = 1.5
244
245 # Wall-view overrides of the shared clustering/merge/fit config (see
246 # ``wall_view_config()``).
247 wall_cluster_eps_m: float = 0.40
248 wall_cluster_min_samples: int = 3
249 wall_merge_gap_m: float = 4.50
250 wall_merge_angle_deg: float = 8.0
251 wall_merge_lateral_max_m: float = 1.00
252 # Real occluded walls (segment_135) show raw-data voids up to ~13.8 m;
253 # 14.0 keeps that structure bridgeable while the 4deg/0.4 m collinearity
254 # guards below still block unrelated fragments from fusing.
255 wall_occlusion_bridge_max_m: float = 14.00
256 wall_occlusion_bridge_max_angle_deg: float = 4.0
257 wall_occlusion_bridge_max_lateral_m: float = 0.40
258 # Staggered noise-wall rows fit as separate ~14 m instances after polyline
259 # smoothing (segment_135: 14.86 m / 13.92 m); vegetation rejection is
260 # carried by the width/straightness/planarity/crest gates, not length.
261 wall_min_length_m: float = 13.0
262 wall_max_local_width_m: float = 1.80
263 wall_min_longitudinal_coverage: float = 0.60
264 wall_max_cluster_height_spread_m: float = 12.0
265 wall_max_cluster_p95_height_m: float = 12.0
266 wall_straightness_window_m: float = 10.0
267 wall_max_straightness_deviation_m: float = 0.35
268 wall_straightness_max_length_m: float = 25.0
269 # Sparse/occluded tail regions leave the wall polyline fit on banded,
270 # far-range evidence that meanders (segment_135); a stronger lateral
271 # smoothing window than the guardrail default (5) is needed to tame it.
272 wall_polyline_smooth_window: int = 9
273
274 # Post-fit wall-only gates (crest profile, truck rejection, mandatory 3D
275 # PCA plane checks); not part of ``wall_view_config()``.
276 wall_profile_bin_m: float = 1.00
277 # Real crest profiles ramp at their ends; a genuine structure was rejected
278 # by 0.005 m in production. Truck rejection is handled separately by the
279 # truck double-gate below.
280 wall_max_top_profile_spread_m: float = 1.50
281 wall_truck_max_top_m: float = 4.20
282 # EU max articulated truck length is ~18.75 m; 20.0 keeps the truck
283 # double-gate effective (top <= wall_truck_max_top_m AND length < this)
284 # while remaining just above that bound.
285 wall_truck_min_length_m: float = 20.0
286 wall_min_planarity: float = 0.55
287 wall_max_plane_normal_z_abs: float = 0.35
288 # Grazing-angle MLS returns are height-banded (segment_135 row B:
289 # planarity=0.368, normal_z_abs=0.005): a clearly-vertical cell can sit
290 # just under the mandatory planarity ratio. Moderate planarity is
291 # accepted when the normal is unambiguously vertical.
292 wall_min_planarity_vertical: float = 0.25
293 # Banded returns can also collapse to a line-degenerate (not plane-like)
294 # moment shape, making the plane normal numerically arbitrary
295 # (segment_135 row A: planarity=0.020, normal_z_abs=1.000, yet the
296 # moments are unambiguously line-like). A high linearity ratio plus a
297 # thin fitted width certifies a genuine vertical sheet without relying on
298 # that ill-conditioned normal.
299 wall_line_bypass_min_linearity: float = 0.75
300 wall_line_bypass_max_width_m: float = 1.0
301
302 # Carriageway rejection gate: a wall candidate between the carriageway
303 # edge-line guardrails is a vehicle (or bridge-deck returns sharing its
304 # cells), not a genuine noise wall (see README "Carriageway rejection
305 # gate"; production segment_135 false positive at offset -4.544 m).
306 wall_reject_inside_carriageway: bool = True
307 # Fallback minimum |mean_offset_m| for a wall when no same-side guardrail
308 # exists to compare against.
309 wall_min_abs_offset_m: float = 6.0
310 # A wall may interleave up to this much inside the outermost same-side
311 # guardrail before being treated as inside the carriageway.
312 wall_outside_rail_margin_m: float = 0.5
313
314 # A ground-standing wall's first returns start near the ground; a bottom-height
315 # profile starting above this is an elevated bridge parapet/deck structure
316 # measured from the wrong base.
317 wall_max_bottom_height_m: float = 2.0
318
319 # Guardrail rail-vs-support decomposition (post cadence + support class).
320 # Height cut lines are literature-derived (Swiss/German hardware: rail band
321 # top edge ~0.75 m, Sigma-100 post 100x55 mm, ASTRA 11005 post spacings
322 # 1.33 / 2.00 m and DDSP 4.00 m), not yet tuned on our clouds; keep in
323 # config. All three feature flags default true; setting them false restores
324 # the pre-feature behavior exactly.
325 enable_post_cadence: bool = True
326 enable_support_class: bool = True
327 enable_component_masks: bool = True
328 post_low_band_min_m: float = 0.10
329 post_low_band_max_m: float = 0.35
330 post_station_bin_m: float = 0.10
331 post_lateral_halfwidth_m: float = 0.60
332 post_catalog_spacings_m: list[float] = field(
333 default_factory=lambda: [1.33, 2.0, 4.0]
334 )
335 post_spacing_snap_rel_tol: float = 0.12
336 post_min_period_m: float = 0.8
337 post_max_period_m: float = 6.0
338 post_min_confidence: float = 0.35
339 post_slot_min_points: int = 3
340 # Per-post peak detection (``posts.detect_run_posts``). The run-level comb
341 # (``post_min_confidence``) is only a scoring prior now: on a long rail the
342 # low band also carries continuous grass/plinth clutter, which drowns the
343 # comb contrast, so posts are accepted individually against a ROLLING local
344 # background instead of all-or-nothing against the run mean.
345 post_peak_smooth_m: float = 0.3
346 post_peak_background_window_m: float = 5.0
347 post_peak_min_prominence: float = 3.0
348 # A dense low band is also a NOISY one: at b points per smoothing window the
349 # Poisson swing is sqrt(b), so a fixed point floor would fabricate posts out
350 # of grass on exactly the cluttered runs this feature exists for. The
351 # effective floor is max(post_peak_min_prominence, sigmas * sqrt(background)).
352 post_peak_noise_sigmas: float = 3.0
353 post_peak_min_confidence: float = 0.25
354 # Wider above-background blobs are plinths / kerbs / parked clutter, not a
355 # 0.10 m post footprint. Measured at half prominence (see detect_run_posts).
356 post_max_station_extent_m: float = 0.45
357 # Measured post top is clamped to [rail band bottom, beam bottom + margin].
358 post_top_margin_m: float = 0.10
359 # Behind-beam shaft claim: a post-footprint point this far outboard of the
360 # rail's LOCAL centerline (not of its run-mean offset โ€” a 50 m polyline
361 # wanders further off its own mean than this threshold, which made the
362 # first cut of this rule inert on every curved run) sits on the far side of
363 # the beam from the road, so it is post shaft, not beam, and may be claimed
364 # up to the rail top. The threshold is the larger of
365 # ``post_behind_beam_offset_m`` (half a w-beam depth plus a margin: the
366 # floor, and what a rail with no measured width gets) and half the rail's
367 # ``width_m`` plus ``post_behind_beam_margin_m`` (what a wide rail needs).
368 post_claim_behind_beam: bool = True
369 post_behind_beam_offset_m: float = 0.22
370 post_behind_beam_margin_m: float = 0.05
371 # ... and once the post line itself is MEASURED (``_measured_post_side``),
372 # the threshold moves off that generic floor onto the hardware: the post's
373 # front face is ``|post_lat| - post_behind_beam_front_margin_m`` (an
374 # IPE-100 flange at 0.05 m plus the spacer that holds the plank off it),
375 # never nearer than the beam's own edge. The floor costs the A4/5 105
376 # median rails half their shaft: post line at 0.24-0.25 m against a 0.22 m
377 # threshold leaves the spacer and the post's road-side half to the rail.
378 post_behind_beam_front_margin_m: float = 0.10
379 # Where a measured post is PUT: the parent polyline at that post's station,
380 # displaced by ``post.offset_m`` minus the polyline's OWN spine offset there
381 # (round 7). With this off the displacement is measured against the run's
382 # constant ``mean_offset_m`` instead -- the round-6 behaviour, kept only so
383 # the flags-off byte-identity replay has something to compare against. On a
384 # run that wanders (A4/5 105 rail 1: 0.69 m end to end) the mean form walks
385 # the published post train diagonally across its own rail.
386 post_xy_local_offset_enabled: bool = True
387 # Measured per-rail beam underside (``posts.measure_beam_bottom``). The
388 # evidence pass folds a HEIGHT histogram over [post_low_band_min_m,
389 # beam_bottom_hist_max_m] alongside the station histogram, scoped to a
390 # tighter lateral halfwidth than the post band (the beam sits on the run's
391 # mean offset; kerb / soil returns further out only blur the onset).
392 # ``beam_bottom_hist_bin_m`` divides the distance from
393 # ``post_low_band_min_m`` to 0.35 / 0.75 / 0.85 exactly, so the rail band
394 # floor and the plausibility cap fall on bin edges rather than inside a bin.
395 beam_bottom_hist_bin_m: float = 0.025
396 # Ceiling of that histogram. 1.30 m (= ``max_height_m``, 48 bins from the
397 # 0.10 m floor) rather than the 1.00 m of rounds 3-6: the beam TOP walk-up
398 # and ``detect_top_member`` both need headroom ABOVE the structure to tell
399 # a bounded member (a Kastenprofil tube: mass ends at 0.98 m and there is
400 # nothing over it) from an unbounded one (a noise wall / hedge / parapet,
401 # which keeps going). At 1.00 m every A4/5 median tube reported
402 # ``truncated`` against what was really the knob, not the cloud.
403 # ``measure_beam_bottom`` is provably unchanged by the raise: its window is
404 # ``component_rail_band_m`` = [0.35, 0.85) and its walk is downward only,
405 # so bins added above cannot move the scale, the dense groups or the
406 # underside.
407 beam_bottom_hist_max_m: float = 1.30
408 beam_bottom_lateral_halfwidth_m: float = 0.40
409 # A candidate beam band is a contiguous group of bins carrying at least this
410 # fraction of the tallest bin in the rail band. Candidates are tried lowest
411 # first (a stacked double w-beam has two, and the upper one is often the
412 # taller), but only TRIED: the low band's own tail can clear this floor and
413 # group up below the beam, and on A4/5 066 rail 5 it does.
414 beam_bottom_band_fraction: float = 0.15
415 # Walking down from a candidate's peak, the underside is where the count
416 # first drops below this fraction of the peak bin.
417 beam_bottom_onset_fraction: float = 0.20
418 # ... and the drop has to be a STEP, not a drift across that threshold. A
419 # continuous barrier mistyped w_beam (A4/5 066 rail 2) has no underside at
420 # all, only a smooth ramp, and any walk-down threshold stops somewhere
421 # arbitrary in it. The knob sits in the gap the A4/5 rails measure out
422 # between two populations: the nine rails that do carry a beam step
423 # 2.00-54x at their onset (the 2.00 is 132 rail 0), while on the seven that
424 # do not, the strongest single-bin rise ANYWHERE in the rail band is 1.67x
425 # โ€” and that is already a harder test than this guard, which only ever
426 # looks at the bin the walk stopped on.
427 beam_bottom_min_onset_ratio: float = 1.8
428 beam_bottom_min_peak_points: int = 50
429 # Round 7: the same walk, upwards, giving the beam TOP -- and with it the
430 # shaft cap the claim should always have used. Gates the MEASUREMENT (the
431 # walk in ``_band_underside``, hence ``detect_top_member``'s precondition
432 # and the shaft cap's preference) as well as the PUBLICATION
433 # (``beam_bottom.top_height_m`` / ``top_measured`` / ``reason_top`` and
434 # ``polyline_beam_top_z_m``), so with it off guardrails.json is
435 # byte-identical to the round-6 one and no member can be detected.
436 post_beam_top_enabled: bool = True
437 # Plausibility window for the result: below ``component_rail_band_m[0]`` it
438 # is not beam (no rail evidence is counted there), above this it is a
439 # gantry / sign / noise wall, not a w-beam underside.
440 beam_bottom_max_m: float = 0.75
441 # Beam band [bottom, top] above the road, used as the fallback when a rail
442 # instance carries no measured ``polyline_bottom_z_m`` / ``polyline_top_z_m``.
443 component_rail_band_m: list[float] = field(default_factory=lambda: [0.35, 0.85])
444 component_support_max_height_m: float = 0.50
445 component_support_station_tol_m: float = 0.20
446 component_support_footprint_m: float = 0.25
447
448 # --- Round 7: the top member (the Kastenprofil box tube on the A4/5
449 # median rails). ``detect_top_member`` measures the band ABOVE the beam
450 # top, and the two load-bearing gates are the mass fraction and the
451 # STATION COVERAGE: mass alone accepts a 27 m stub of vegetation behind a
452 # rail (A4/5 066 rail 1, mass fraction 0.44), and only "is this band there
453 # at every station of the run" rejects it (coverage 0.57 against 1.00 on
454 # all four real tubes).
455 post_top_member_enabled: bool = True
456 # Where the tube's rows go. "guardrail_top_rail" (default) emits the
457 # companion instance and LAS 74; "guardrail_support" folds them into the
458 # parent's support instance (LAS 72); "w_beam" leaves them on the parent
459 # rail (LAS 66). The last two emit no companion instance, so the fusion
460 # JSON paint has nothing to read and only the mask sidecar carries them.
461 post_top_member_type: str = "guardrail_top_rail"
462 # Mass above the measured beam top, over the mass in the rail window.
463 # Measured 0.49-0.53 on the four A4/5 tubes; 0.002-0.066 on nine of the
464 # twelve rails without one, 0.39-0.44 on the two 066 outliers coverage
465 # rejects.
466 post_top_member_min_mass_fraction: float = 0.15
467 # A member is "the thing above the post line", so there has to be a post
468 # line: below this many measured posts the run reports ``no_posts``.
469 post_top_member_min_posts: int = 2
470 # A bin is part of the band when it carries this fraction of the tallest
471 # bin above the beam top; the band is the contiguous dense group with the
472 # largest MASS (not the topmost one -- with the 1.30 m ceiling that picks
473 # a blob 0.30 m over the beam on A4/5 105 rail 3).
474 post_top_member_band_fraction: float = 0.15
475 # Reported, not enforced (a thin band that is present at every station is
476 # still a member; the real discriminators are mass and coverage).
477 post_top_member_min_thickness_m: float = 0.075
478 # A station bin counts as covered when the band carries this many points
479 # in it, over the station bins that carry any point of the run's slab.
480 post_top_member_min_bin_points: int = 3
481 post_top_member_min_coverage: float = 0.90
482 # Colocation with the measured post line, and the band's own lateral
483 # spread. Both are REPORTED on every rail; the gate is off by default
484 # (mass + coverage already separate the two populations by 0.33 of
485 # coverage, and three rails without a tube pass the lateral test anyway).
486 post_top_member_lateral_gate_enabled: bool = False
487 # ``post_top_member_max_lateral_offset_m`` is enforced whatever that flag
488 # says in ONE place: the prism's axis. A post median that disagrees with
489 # the band's own measured lateral by more than this is not the line the
490 # member runs along, and sweeping a full-length 0.25 m prism down it would
491 # paint whatever stands behind the rail (see ``_top_rail_geometry``).
492 post_top_member_max_lateral_offset_m: float = 0.12
493 post_top_member_max_lateral_spread_m: float = 0.15
494 # Halfwidth of the swept prism that claims the tube, about the robust post
495 # line. The measured 2-98 percentile lateral extent of the four A4/5 tubes
496 # about that line is within [-0.20, +0.17] m.
497 post_top_member_halfwidth_m: float = 0.25
498 # --- Round 7: the behind-beam outward sign, from the MEASURED post side.
499 # ``sign(mean_offset_m)`` assumes the posts are always further from the
500 # spine than the beam; on the A4/5 median rails that is true on only half
501 # of them, and the shaft claim is completely dead on the other half. The
502 # three guards are what keep every rail whose posts sit ON the line (the
503 # outer rails: |side| 0.004-0.079) on the old sign, bit for bit.
504 post_behind_beam_use_measured_side: bool = True
505 post_behind_beam_min_post_offset_m: float = 0.10
506 post_behind_beam_min_posts: int = 4
507 post_behind_beam_min_side_agreement: float = 0.70
508
509 # Overlay kill switches (also mirrored in ``PerspectiveConfig`` so the
510 # independent perspective CLI shares the same rollback behavior).
511 overlay_extent_enabled: bool = True
512 overlay_ground_model_diff_enabled: bool = False
513
514
515class DetectorConfigError(ConfigError):
516 """Raised when the guardrails config contains unsupported keys."""
51726
27logger = logging.getLogger(__name__)
51828
519#: Import package holding the packaged default JSON, used when ``__package__``29#: Import package holding the packaged default JSON, used when ``__package__``
520#: is unset because ``config.py`` was executed as a loose script.30#: is unset because ``config.py`` was executed as a loose script.
521_PACKAGE_NAME = "guardrails"31_PACKAGE_NAME = "guardrails"
522_DEFAULT_CONFIG_NAME = "guardrails.default.json"32_DEFAULT_CONFIG_NAME = "guardrails.default.json"
52333
34#: Guardrail-named target field -> ``wall_*`` source field, applied by
35#: :func:`wall_view_config`.
36_WALL_VIEW_MAP: dict[str, str] = {
37 "occupancy_cell_m": "wall_cell_m",
38 "cluster_eps_m": "wall_cluster_eps_m",
39 "cluster_min_samples": "wall_cluster_min_samples",
40 "merge_gap_m": "wall_merge_gap_m",
41 "merge_angle_deg": "wall_merge_angle_deg",
42 "merge_lateral_max_m": "wall_merge_lateral_max_m",
43 "occlusion_bridge_max_m": "wall_occlusion_bridge_max_m",
44 "occlusion_bridge_max_angle_deg": "wall_occlusion_bridge_max_angle_deg",
45 "occlusion_bridge_max_lateral_m": "wall_occlusion_bridge_max_lateral_m",
46 "min_length_m": "wall_min_length_m",
47 "max_local_width_m": "wall_max_local_width_m",
48 "min_longitudinal_coverage": "wall_min_longitudinal_coverage",
49 "max_cluster_height_spread_m": "wall_max_cluster_height_spread_m",
50 "max_cluster_p95_height_m": "wall_max_cluster_p95_height_m",
51 "straightness_window_m": "wall_straightness_window_m",
52 "max_straightness_deviation_m": "wall_max_straightness_deviation_m",
53 "straightness_max_length_m": "wall_straightness_max_length_m",
54 "polyline_smooth_window": "wall_polyline_smooth_window",
55}
56
57
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):
84 """Raised when the guardrails config contains unsupported keys."""
85
52486
525def load_default_config_dict() -> dict[str, Any]:87def load_default_config_dict() -> dict[str, Any]:
526 """Return the package-owned default config as a plain dict.88 """Return the package-owned default config as a plain dict.
52789
528 Returns:90 Returns:
529 The decoded ``guardrails.default.json`` object.91 The decoded ``guardrails.default.json`` object.
530 """92 """
531 return load_packaged_json(__package__ or _PACKAGE_NAME, _DEFAULT_CONFIG_NAME)93 return config_loader.load_packaged_json(
94 __package__ or _PACKAGE_NAME, _DEFAULT_CONFIG_NAME
95 )
53296
53397
534def config_from_dict(raw: dict[str, Any]) -> DetectorConfig:98def config_from_dict(raw: dict[str, Any]) -> DetectorConfig:
535 """Build a validated :class:`DetectorConfig` from a raw mapping.99 """Build a validated :class:`DetectorConfig` from a raw mapping.
536100
537 Unknown keys and values that do not fit their declared field type are101 Unknown keys and values that do not fit their declared field type are
538 rejected by :func:`iolabs.common.config_loader.dataclass_from_mapping`;102 rejected by the shared pydantic layer; the band-length rule on
539 the band-length rule below is the one guardrails-specific check that the103 ``residue_lever_band_m`` is the one guardrails-specific check that the
540 declared type ``list[float]`` cannot express.104 declared type ``list[float]`` cannot express.
541105
542 Args:106 Args:
543 raw: Merged config mapping (packaged defaults plus overrides).107 raw: Merged config mapping (packaged defaults plus overrides).
Importance #5: guardrails/config.py @@ -580,33 +153,23 @@
580def wall_view_config(config: DetectorConfig) -> DetectorConfig:153def wall_view_config(config: DetectorConfig) -> DetectorConfig:
581 """Return a wall-view :class:`DetectorConfig` for the shared fitter.154 """Return a wall-view :class:`DetectorConfig` for the shared fitter.
582155
583 Maps every ``wall_*`` clustering/merge/fit override onto the matching156 Maps every ``wall_*`` clustering/merge/fit override onto the matching
584 guardrail-named field via ``dataclasses.replace``. No other field157 guardrail-named field via ``model_copy``. No other field changes, and the
585 changes, and the source ``config`` is never mutated (frozen dataclass).158 source ``config`` is never mutated (frozen model). This lets
586 This lets ``detect_instances()``/``_fit_instance()`` run unmodified for159 ``detect_instances()``/``_fit_instance()`` run unmodified for walls: only
587 walls: only the config view differs, not the fitter code.160 the config view differs, not the fitter code.
161
162 Args:
163 config: The loaded detector config.
164
165 Returns:
166 A copy whose geometry fields carry the ``wall_*`` values.
588 """167 """
589 return replace(168 return config.model_copy(
590 config,169 update={
591 occupancy_cell_m=config.wall_cell_m,170 target: getattr(config, source) for target, source in _WALL_VIEW_MAP.items()
592 cluster_eps_m=config.wall_cluster_eps_m,171 }
593 cluster_min_samples=config.wall_cluster_min_samples,
594 merge_gap_m=config.wall_merge_gap_m,
595 merge_angle_deg=config.wall_merge_angle_deg,
596 merge_lateral_max_m=config.wall_merge_lateral_max_m,
597 occlusion_bridge_max_m=config.wall_occlusion_bridge_max_m,
598 occlusion_bridge_max_angle_deg=config.wall_occlusion_bridge_max_angle_deg,
599 occlusion_bridge_max_lateral_m=config.wall_occlusion_bridge_max_lateral_m,
600 min_length_m=config.wall_min_length_m,
601 max_local_width_m=config.wall_max_local_width_m,
602 min_longitudinal_coverage=config.wall_min_longitudinal_coverage,
603 max_cluster_height_spread_m=config.wall_max_cluster_height_spread_m,
604 max_cluster_p95_height_m=config.wall_max_cluster_p95_height_m,
605 straightness_window_m=config.wall_straightness_window_m,
606 max_straightness_deviation_m=config.wall_max_straightness_deviation_m,
607 straightness_max_length_m=config.wall_straightness_max_length_m,
608 polyline_smooth_window=config.wall_polyline_smooth_window,
609 )172 )
610173
611174
612def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]:175def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]:
Importance #6: guardrails/config.py @@ -620,5 +183,7 @@
620183
621 Raises:184 Raises:
622 DetectorConfigError: An override is missing its ``=``.185 DetectorConfigError: An override is missing its ``=``.
623 """186 """
624 return _parse_set_overrides(raw_overrides, error_cls=DetectorConfigError)187 return config_loader.parse_set_overrides(
188 raw_overrides, error_cls=DetectorConfigError
189 )
Importance #7: guardrails/lane_xml.py @@ -442,14 +442,17 @@
442 max_carriageway_width_m: float = 15.0,442 max_carriageway_width_m: float = 15.0,
443) -> LateralZoneModel:443) -> LateralZoneModel:
444 """Project segment-local XML edge samples into spine station/offset bins."""444 """Project segment-local XML edge samples into spine station/offset bins."""
445 projected: list[tuple[str, int, np.ndarray, np.ndarray]] = []445 projected: list[tuple[str, int, np.ndarray, np.ndarray]] = []
446 # ``spine.project`` only reads immutable defaults here, so one shared
447 # instance serves every edge (building one per edge validates 200+ fields).
448 project_config = DetectorConfig()
446 for edge_index, edge in enumerate(edges):449 for edge_index, edge in enumerate(edges):
447 cropped = _crop_xy(_densify(edge.xy), segment_bbox)450 cropped = _crop_xy(_densify(edge.xy), segment_bbox)
448 if not len(cropped):451 if not len(cropped):
449 continue452 continue
450 stations, offsets = spine.project(453 stations, offsets = spine.project(
451 np.column_stack((cropped, np.zeros(len(cropped)))), DetectorConfig()454 np.column_stack((cropped, np.zeros(len(cropped)))), project_config
452 )455 )
453 projected.append((edge.lane_id, edge_index, stations, offsets))456 projected.append((edge.lane_id, edge_index, stations, offsets))
454 if not projected:457 if not projected:
455 raise ValueError("Lane XML contains no edge geometry inside the segment bbox")458 raise ValueError("Lane XML contains no edge geometry inside the segment bbox")
Importance #8: guardrails/outputs.py @@ -7,9 +7,8 @@
7"""7"""
88
9import gc9import gc
10import logging10import logging
11from dataclasses import replace
12from pathlib import Path11from pathlib import Path
1312
14import numpy as np13import numpy as np
15from iolabs.common.point_masks_io import write_point_masks14from iolabs.common.point_masks_io import write_point_masks
Importance #9: guardrails/outputs.py @@ -83,9 +82,9 @@
83 widen_low_band = collect_height_station and (82 widen_low_band = collect_height_station and (
84 config.post_low_band_min_m < config.min_height_m83 config.post_low_band_min_m < config.min_height_m
85 )84 )
86 replay_config = (85 replay_config = (
87 replace(config, min_height_m=config.post_low_band_min_m)86 config.model_copy(update={"min_height_m": config.post_low_band_min_m})
88 if widen_low_band87 if widen_low_band
89 else config88 else config
90 )89 )
91 min_height_m = replay_config.min_height_m90 min_height_m = replay_config.min_height_m
Importance #10: tests/test_config.py @@ -1,6 +1,5 @@
1import argparse1import argparse
2from dataclasses import asdict, fields, replace
3from pathlib import Path2from pathlib import Path
43
5import pytest4import pytest
65
Importance #11: tests/test_config.py @@ -37,11 +36,11 @@
37 "polyline_smooth_window": "wall_polyline_smooth_window",36 "polyline_smooth_window": "wall_polyline_smooth_window",
38}37}
3938
4039
41def test_default_json_matches_dataclass_defaults() -> None:40def test_default_json_matches_model_defaults() -> None:
42 """guardrails.default.json is the schema source of truth; keep it in sync."""41 """guardrails.default.json is the schema source of truth; keep it in sync."""
43 defaults = asdict(DetectorConfig())42 defaults = DetectorConfig().model_dump()
44 json_config = load_default_config_dict()43 json_config = load_default_config_dict()
45 assert set(json_config) == set(defaults)44 assert set(json_config) == set(defaults)
46 for key, value in defaults.items():45 for key, value in defaults.items():
47 assert json_config[key] == value, key46 assert json_config[key] == value, key
Importance #12: tests/test_config.py @@ -135,9 +134,9 @@
135 "wall_max_straightness_deviation_m": 0.45,134 "wall_max_straightness_deviation_m": 0.45,
136 "wall_straightness_max_length_m": 30.0,135 "wall_straightness_max_length_m": 30.0,
137 "wall_polyline_smooth_window": 11,136 "wall_polyline_smooth_window": 11,
138 }137 }
139 source = replace(DetectorConfig(), **nondefault_wall_values)138 source = DetectorConfig(**nondefault_wall_values)
140 result = wall_view_config(source)139 result = wall_view_config(source)
141140
142 # Every mapped field changed in the returned config to the nondefault141 # Every mapped field changed in the returned config to the nondefault
143 # wall_* value, and differs from the (untouched) source guardrail field.142 # wall_* value, and differs from the (untouched) source guardrail field.
Importance #13: tests/test_config.py @@ -145,15 +144,15 @@
145 expected = nondefault_wall_values[wall_field]144 expected = nondefault_wall_values[wall_field]
146 assert getattr(result, target_field) == expected, target_field145 assert getattr(result, target_field) == expected, target_field
147 assert getattr(result, target_field) != getattr(source, target_field), target_field146 assert getattr(result, target_field) != getattr(source, target_field), target_field
148147
149 # Source config is untouched (frozen dataclass; replace() never mutates).148 # Source config is untouched (frozen model; model_copy never mutates).
150 for wall_field, value in nondefault_wall_values.items():149 for wall_field, value in nondefault_wall_values.items():
151 assert getattr(source, wall_field) == value150 assert getattr(source, wall_field) == value
152151
153 # Every unrelated (unmapped) field is identical between source and result.152 # Every unrelated (unmapped) field is identical between source and result.
154 mapped_targets = set(_WALL_VIEW_FIELD_MAP)153 mapped_targets = set(_WALL_VIEW_FIELD_MAP)
155 for field in fields(DetectorConfig):154 for field_name in DetectorConfig.model_fields:
156 if field.name in mapped_targets:155 if field_name in mapped_targets:
157 continue156 continue
158 assert getattr(result, field.name) == getattr(source, field.name), field.name157 assert getattr(result, field_name) == getattr(source, field_name), field_name
159158
Importance #14: tests/test_detect_wall_integration.py @@ -1,6 +1,6 @@
11
2from dataclasses import fields, replace2from dataclasses import fields
3from pathlib import Path3from pathlib import Path
4from unittest.mock import call4from unittest.mock import call
55
6import numpy as np6import numpy as np
Importance #15: tests/test_detect_wall_integration.py @@ -66,9 +66,9 @@
66 result = _accumulate_candidates(66 result = _accumulate_candidates(
67 [points_path],67 [points_path],
68 _FlatGround(),68 _FlatGround(),
69 _frame(),69 _frame(),
70 replace(DetectorConfig(), wall_detection_enabled=False),70 DetectorConfig(wall_detection_enabled=False),
71 spine=_straight_spine(),71 spine=_straight_spine(),
72 segment_index=0,72 segment_index=0,
73 )73 )
7474
Importance #16: tests/test_detect_wall_integration.py @@ -77,10 +77,9 @@
7777
78def test_wall_accumulator_is_chunk_independent_and_grid_bounded(tmp_path: Path) -> None:78def test_wall_accumulator_is_chunk_independent_and_grid_bounded(tmp_path: Path) -> None:
79 points_path = tmp_path / "Record000_run3_points.npz"79 points_path = tmp_path / "Record000_run3_points.npz"
80 _write_points(points_path, _wall_points())80 _write_points(points_path, _wall_points())
81 base = replace(81 base = DetectorConfig(
82 DetectorConfig(),
83 wall_detection_enabled=True,82 wall_detection_enabled=True,
84 wall_cell_m=1.0,83 wall_cell_m=1.0,
85 wall_height_bin_m=0.25,84 wall_height_bin_m=0.25,
86 )85 )
Importance #17: tests/test_detect_wall_integration.py @@ -88,17 +87,17 @@
88 whole = _accumulate_candidates(87 whole = _accumulate_candidates(
89 [points_path],88 [points_path],
90 _FlatGround(),89 _FlatGround(),
91 _frame(),90 _frame(),
92 replace(base, record_chunk_points=10_000),91 base.model_copy(update={"record_chunk_points": 10_000}),
93 spine=_straight_spine(),92 spine=_straight_spine(),
94 segment_index=0,93 segment_index=0,
95 ).wall_evidence94 ).wall_evidence
96 chunked = _accumulate_candidates(95 chunked = _accumulate_candidates(
97 [points_path],96 [points_path],
98 _FlatGround(),97 _FlatGround(),
99 _frame(),98 _frame(),
100 replace(base, record_chunk_points=2),99 base.model_copy(update={"record_chunk_points": 2}),
101 spine=_straight_spine(),100 spine=_straight_spine(),
102 segment_index=0,101 segment_index=0,
103 ).wall_evidence102 ).wall_evidence
104103
Importance #18: tests/test_detect_wall_integration.py @@ -222,10 +221,9 @@
222 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))221 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))
223 for suffix in (".json", "_rgb.png", "_intensity.png"):222 for suffix in (".json", "_rgb.png", "_intensity.png"):
224 (tile_dir / f"segment_000{suffix}").touch()223 (tile_dir / f"segment_000{suffix}").touch()
225224
226 config = replace(225 config = DetectorConfig(
227 DetectorConfig(),
228 wall_detection_enabled=wall_enabled,226 wall_detection_enabled=wall_enabled,
229 lane_xml_zones_enabled=False,227 lane_xml_zones_enabled=False,
230 precision_gate_enabled=False,228 precision_gate_enabled=False,
231 )229 )
Importance #19: tests/test_detect_wall_integration.py @@ -346,10 +344,9 @@
346 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))344 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))
347 for suffix in (".json", "_rgb.png", "_intensity.png"):345 for suffix in (".json", "_rgb.png", "_intensity.png"):
348 (tile_dir / f"segment_000{suffix}").touch()346 (tile_dir / f"segment_000{suffix}").touch()
349347
350 config = replace(348 config = DetectorConfig(
351 DetectorConfig(),
352 lane_xml_zones_enabled=False,349 lane_xml_zones_enabled=False,
353 precision_gate_enabled=False,350 precision_gate_enabled=False,
354 )351 )
355 frame = _frame()352 frame = _frame()
Importance #20: tests/test_detect_wall_integration.py @@ -454,10 +451,9 @@
454 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))451 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))
455 for suffix in (".json", "_rgb.png", "_intensity.png"):452 for suffix in (".json", "_rgb.png", "_intensity.png"):
456 (tile_dir / f"segment_000{suffix}").touch()453 (tile_dir / f"segment_000{suffix}").touch()
457454
458 config = replace(455 config = DetectorConfig(
459 DetectorConfig(),
460 lane_xml_zones_enabled=False,456 lane_xml_zones_enabled=False,
461 precision_gate_enabled=False,457 precision_gate_enabled=False,
462 )458 )
463 frame = _frame()459 frame = _frame()
Importance #21: tests/test_detect_wall_integration.py @@ -555,10 +551,9 @@
555 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))551 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))
556 for suffix in (".json", "_rgb.png", "_intensity.png"):552 for suffix in (".json", "_rgb.png", "_intensity.png"):
557 (tile_dir / f"segment_000{suffix}").touch()553 (tile_dir / f"segment_000{suffix}").touch()
558554
559 config = replace(555 config = DetectorConfig(
560 DetectorConfig(),
561 lane_xml_zones_enabled=False,556 lane_xml_zones_enabled=False,
562 precision_gate_enabled=False,557 precision_gate_enabled=False,
563 )558 )
564 frame = _frame()559 frame = _frame()
Importance #22: tests/test_detect_wall_integration.py @@ -734,19 +729,17 @@
734 (tmp_path / output_name / "segment_000" / "guardrails.json").read_text()729 (tmp_path / output_name / "segment_000" / "guardrails.json").read_text()
735 )["walls"]730 )["walls"]
736731
737 zones_off = run(732 zones_off = run(
738 replace(733 DetectorConfig(
739 DetectorConfig(),
740 wall_detection_enabled=True,734 wall_detection_enabled=True,
741 lane_xml_zones_enabled=False,735 lane_xml_zones_enabled=False,
742 precision_gate_enabled=False,736 precision_gate_enabled=False,
743 ),737 ),
744 "zones_off",738 "zones_off",
745 )739 )
746 zones_on = run(740 zones_on = run(
747 replace(741 DetectorConfig(
748 DetectorConfig(),
749 wall_detection_enabled=True,742 wall_detection_enabled=True,
750 lane_xml_zones_enabled=True,743 lane_xml_zones_enabled=True,
751 precision_gate_enabled=False,744 precision_gate_enabled=False,
752 lane_xml_path=str(Path(__file__).parent / "fixtures" / "mini_lanes.xml"),745 lane_xml_path=str(Path(__file__).parent / "fixtures" / "mini_lanes.xml"),
Importance #23: tests/test_edge_gate.py @@ -5,9 +5,8 @@
5 C ramp outer y=-50, C ramp inner y=-38 (x = 40..80)5 C ramp outer y=-50, C ramp inner y=-38 (x = 40..80)
6The spine is the straight x-axis at y=0, so spine offset == world y.6The spine is the straight x-axis at y=0, so spine offset == world y.
7"""7"""
88
9from dataclasses import replace
10from pathlib import Path9from pathlib import Path
1110
12import numpy as np11import numpy as np
13import pytest12import pytest
Importance #24: tests/test_edge_gate.py @@ -116,9 +115,9 @@
116 assert index.tree.n == len(index.edge_xy)115 assert index.tree.n == len(index.edge_xy)
117116
118117
119def test_build_edge_index_none_when_no_edge_intersects_bbox() -> None:118def test_build_edge_index_none_when_no_edge_intersects_bbox() -> None:
120 config = replace(DetectorConfig(), zone_bbox_margin_m=1.0)119 config = DetectorConfig(zone_bbox_margin_m=1.0)
121120
122 index = edge_gate.build_edge_index(121 index = edge_gate.build_edge_index(
123 _lane_data(),122 _lane_data(),
124 segment_bbox=(5000.0, 5000.0, 5100.0, 5100.0),123 segment_bbox=(5000.0, 5000.0, 5100.0, 5100.0),
Importance #25: tests/test_edge_gate.py @@ -229,9 +228,9 @@
229 assert exclusions == []228 assert exclusions == []
230229
231230
232def test_e1_threshold_is_config_driven() -> None:231def test_e1_threshold_is_config_driven() -> None:
233 config = replace(DetectorConfig(), edge_gate_max_rail_distance_m=30.0)232 config = DetectorConfig(edge_gate_max_rail_distance_m=30.0)
234233
235 kept, exclusions = _apply(234 kept, exclusions = _apply(
236 [_run(-25.0, x0=45.0, x1=75.0)],235 [_run(-25.0, x0=45.0, x1=75.0)],
237 config=config,236 config=config,
Importance #26: tests/test_edge_gate.py @@ -248,9 +247,9 @@
248 ys = np.where(xs < 50.0, 5.0, A_INNER_Y + 4.0)247 ys = np.where(xs < 50.0, 5.0, A_INNER_Y + 4.0)
249 run = _run(0.0)248 run = _run(0.0)
250 run["polyline"] = np.column_stack([xs, ys, np.zeros(len(xs))]).tolist()249 run["polyline"] = np.column_stack([xs, ys, np.zeros(len(xs))]).tolist()
251250
252 lenient = replace(DetectorConfig(), edge_gate_interior_max_frac=0.75)251 lenient = DetectorConfig(edge_gate_interior_max_frac=0.75)
253 kept_lenient, excluded_lenient = _apply([dict(run)], config=lenient)252 kept_lenient, excluded_lenient = _apply([dict(run)], config=lenient)
254 kept_strict, excluded_strict = _apply([dict(run)], config=DetectorConfig())253 kept_strict, excluded_strict = _apply([dict(run)], config=DetectorConfig())
255254
256 assert len(kept_lenient) == 1255 assert len(kept_lenient) == 1
Importance #27: tests/test_edge_gate.py @@ -290,9 +289,9 @@
290 assert exclusions == []289 assert exclusions == []
291290
292291
293def test_disabled_gate_keeps_every_run_untouched() -> None:292def test_disabled_gate_keeps_every_run_untouched() -> None:
294 config = replace(DetectorConfig(), edge_gate_enabled=False)293 config = DetectorConfig(edge_gate_enabled=False)
295 runs = [_run(5.0, run_id=1), _run(-25.0, run_id=2, x0=45.0, x1=75.0)]294 runs = [_run(5.0, run_id=1), _run(-25.0, run_id=2, x0=45.0, x1=75.0)]
296295
297 kept, exclusions = _apply(runs, config=config)296 kept, exclusions = _apply(runs, config=config)
298297
Importance #28: tests/test_posts.py @@ -7,9 +7,8 @@
77
8import json8import json
9import logging9import logging
10import math10import math
11from dataclasses import replace
12from types import SimpleNamespace11from types import SimpleNamespace
1312
14import numpy as np13import numpy as np
15import pytest14import pytest
Importance #29: tests/test_posts.py @@ -1717,9 +1716,9 @@
1717 np.testing.assert_array_equal(out, np.array([2, 0], dtype=np.int32))1716 np.testing.assert_array_equal(out, np.array([2, 0], dtype=np.int32))
17181717
17191718
1720def test_build_support_claim_honours_the_behind_beam_kill_switch() -> None:1719def test_build_support_claim_honours_the_behind_beam_kill_switch() -> None:
1721 config = replace(DetectorConfig(), post_claim_behind_beam=False)1720 config = DetectorConfig(post_claim_behind_beam=False)
1722 claim = build_support_claim(1721 claim = build_support_claim(
1723 _measured_parent(0, bottom_height=0.55), _support_dict([0.0, 2.0]), config1722 _measured_parent(0, bottom_height=0.55), _support_dict([0.0, 2.0]), config
1724 )1723 )
1725 assert claim.behind_beam_min_dist_m is None1724 assert claim.behind_beam_min_dist_m is None
Importance #30: tests/test_precision_gate.py @@ -1,7 +1,6 @@
1import copy1import copy
2import json2import json
3from dataclasses import replace
4from pathlib import Path3from pathlib import Path
54
6import numpy as np5import numpy as np
7import pytest6import pytest
Importance #31: tests/test_precision_gate.py @@ -350,9 +349,9 @@
350 run_overrides: dict[str, object],349 run_overrides: dict[str, object],
351 metric_overrides: dict[str, object],350 metric_overrides: dict[str, object],
352 miss_overrides: dict[str, object],351 miss_overrides: dict[str, object],
353) -> None:352) -> None:
354 config = replace(DetectorConfig(), precision_gate_enabled=True)353 config = DetectorConfig(precision_gate_enabled=True)
355 run = _base_run(**run_overrides)354 run = _base_run(**run_overrides)
356 hit_metrics = _base_metrics(**metric_overrides)355 hit_metrics = _base_metrics(**metric_overrides)
357 miss_metrics = _base_metrics(**(metric_overrides | miss_overrides))356 miss_metrics = _base_metrics(**(metric_overrides | miss_overrides))
358357
Importance #32: tests/test_precision_gate.py @@ -363,9 +362,9 @@
363 assert detect._precision_rule_for_metrics(run, miss_metrics, config, kind=kind) is None362 assert detect._precision_rule_for_metrics(run, miss_metrics, config, kind=kind) is None
364363
365364
366def test_precision_walls_only_use_g0() -> None:365def test_precision_walls_only_use_g0() -> None:
367 config = replace(DetectorConfig(), precision_gate_enabled=True)366 config = DetectorConfig(precision_gate_enabled=True)
368 vehicle_wall = _base_run(length_m=10.0, mean_height_m=1.0)367 vehicle_wall = _base_run(length_m=10.0, mean_height_m=1.0)
369 metrics = _base_metrics(density_per_m=1000.0)368 metrics = _base_metrics(density_per_m=1000.0)
370369
371 assert (370 assert (
Importance #33: tests/test_precision_gate.py @@ -423,9 +422,9 @@
423 return rails, walls422 return rails, walls
424423
425424
426def test_precision_gate_integration_attribution_schema_and_stable_ids() -> None:425def test_precision_gate_integration_attribution_schema_and_stable_ids() -> None:
427 config = replace(DetectorConfig(), precision_gate_enabled=True)426 config = DetectorConfig(precision_gate_enabled=True)
428 rails, walls = _integration_records()427 rails, walls = _integration_records()
429 exclusions: list[dict[str, object]] = []428 exclusions: list[dict[str, object]] = []
430429
431 kept_rails, kept_walls = detect._apply_precision_gate(430 kept_rails, kept_walls = detect._apply_precision_gate(
Importance #34: tests/test_precision_gate.py @@ -489,9 +488,9 @@
489 assert index is None488 assert index is None
490489
491490
492def test_precision_gate_enabled_defaults_keep_without_evidence_index() -> None:491def test_precision_gate_enabled_defaults_keep_without_evidence_index() -> None:
493 config = replace(DetectorConfig(), precision_gate_enabled=True)492 config = DetectorConfig(precision_gate_enabled=True)
494 rails, walls = _integration_records()493 rails, walls = _integration_records()
495 exclusions: list[dict[str, object]] = []494 exclusions: list[dict[str, object]] = []
496 before = copy.deepcopy((rails, walls))495 before = copy.deepcopy((rails, walls))
497496
Importance #35: tests/test_precision_gate.py @@ -510,9 +509,9 @@
510 assert exclusions == []509 assert exclusions == []
511510
512511
513def test_precision_gate_disabled_is_byte_identical_and_skips_evidence() -> None:512def test_precision_gate_disabled_is_byte_identical_and_skips_evidence() -> None:
514 config = replace(DetectorConfig(), precision_gate_enabled=False)513 config = DetectorConfig(precision_gate_enabled=False)
515 rails, walls = _integration_records()514 rails, walls = _integration_records()
516 exclusions = [{"reason": "existing", "source_id": 42}]515 exclusions = [{"reason": "existing", "source_id": 42}]
517 before = json.dumps(516 before = json.dumps(
518 {"guardrails": rails, "walls": walls, "corridor_exclusions": exclusions},517 {"guardrails": rails, "walls": walls, "corridor_exclusions": exclusions},
Importance #36: tests/test_support_class.py @@ -5,9 +5,8 @@
5pattern as ``tests/test_point_masks.py::test_collect_point_masks_bounded_filtering``),5pattern as ``tests/test_point_masks.py::test_collect_point_masks_bounded_filtering``),
6so a point's (station, offset, height) is simply its (x, y, z).6so a point's (station, offset, height) is simply its (x, y, z).
7"""7"""
88
9from dataclasses import replace
10from types import SimpleNamespace9from types import SimpleNamespace
1110
12import numpy as np11import numpy as np
13import pytest12import pytest
Importance #37: tests/test_support_class.py @@ -576,11 +575,9 @@
576 low = np.column_stack(575 low = np.column_stack(
577 [np.arange(6) * 0.001 + 0.15, np.full(6, 0.05), np.full(6, 0.15)]576 [np.arange(6) * 0.001 + 0.15, np.full(6, 0.05), np.full(6, 0.15)]
578 )577 )
579 call = _masks_fixture(tmp_path, monkeypatch, np.vstack([base, low]))578 call = _masks_fixture(tmp_path, monkeypatch, np.vstack([base, low]))
580 config = replace(579 config = DetectorConfig(decimation_enabled=True, decimation_density_cap=3)
581 DetectorConfig(), decimation_enabled=True, decimation_density_cap=3
582 )
583580
584 record_id, point_index, _instance_id = call(config)581 record_id, point_index, _instance_id = call(config)
585 rec_w, idx_w, _inst_w, _height_w, _station_w, _z_w = call(582 rec_w, idx_w, _inst_w, _height_w, _station_w, _z_w = call(
586 config, collect_height_station=True583 config, collect_height_station=True
Importance #38: tests/test_support_class.py @@ -1036,9 +1033,9 @@
1036 )1033 )
1037 assert list(idx) == [0], "only the return behind the beam is post shaft"1034 assert list(idx) == [0], "only the return behind the beam is post shaft"
1038 assert list(inst) == [1]1035 assert list(inst) == [1]
10391036
1040 off = replace(config, post_claim_behind_beam=False)1037 off = config.model_copy(update={"post_claim_behind_beam": False})
1041 blind_claim = build_support_claim(parent, support, off)1038 blind_claim = build_support_claim(parent, support, off)
1042 _rec, idx_off, _inst_off, _h, _s, _z = call(1039 _rec, idx_off, _inst_off, _h, _s, _z = call(
1043 off, collect_height_station=True, support_posts={1: blind_claim}1040 off, collect_height_station=True, support_posts={1: blind_claim}
1044 )1041 )
Importance #39: tests/test_support_class.py @@ -1116,11 +1113,9 @@
1116 low = np.column_stack(1113 low = np.column_stack(
1117 [np.arange(6) * 0.001 + 0.15, np.full(6, 0.05), np.full(6, 0.52)]1114 [np.arange(6) * 0.001 + 0.15, np.full(6, 0.05), np.full(6, 0.52)]
1118 )1115 )
1119 call = _masks_fixture(tmp_path, monkeypatch, np.vstack([base, low]))1116 call = _masks_fixture(tmp_path, monkeypatch, np.vstack([base, low]))
1120 config = replace(1117 config = DetectorConfig(decimation_enabled=True, decimation_density_cap=3)
1121 DetectorConfig(), decimation_enabled=True, decimation_density_cap=3
1122 )
1123 claim = build_support_claim(1118 claim = build_support_claim(
1124 {1119 {
1125 "polyline_station_m": [0.0, 1.0],1120 "polyline_station_m": [0.0, 1.0],
1126 "polyline_ground_z_m": [0.0, 0.0],1121 "polyline_ground_z_m": [0.0, 0.0],
Importance #40: tests/test_support_class.py @@ -1209,10 +1204,9 @@
1209 _write_record(segment_dir, points, name="Record000_run3_points.npz")1204 _write_record(segment_dir, points, name="Record000_run3_points.npz")
1210 for suffix in (".json", "_rgb.png", "_intensity.png"):1205 for suffix in (".json", "_rgb.png", "_intensity.png"):
1211 (tile_dir / f"segment_000{suffix}").touch()1206 (tile_dir / f"segment_000{suffix}").touch()
12121207
1213 config = replace(1208 config = DetectorConfig(
1214 DetectorConfig(),
1215 wall_detection_enabled=False,1209 wall_detection_enabled=False,
1216 lane_xml_zones_enabled=False,1210 lane_xml_zones_enabled=False,
1217 precision_gate_enabled=False,1211 precision_gate_enabled=False,
1218 **overrides,1212 **overrides,
Importance #41: tests/test_support_class.py @@ -1562,9 +1556,9 @@
1562 slope = float(np.polyfit(stations, laterals, 1)[0])1556 slope = float(np.polyfit(stations, laterals, 1)[0])
1563 assert abs(slope) < 0.002, f"post lateral still trends at {slope:.4f} m/m"1557 assert abs(slope) < 0.002, f"post lateral still trends at {slope:.4f} m/m"
15641558
1565 # ... and the round-6 behaviour is what the kill switch restores.1559 # ... and the round-6 behaviour is what the kill switch restores.
1566 off = replace(config, post_xy_local_offset_enabled=False)1560 off = config.model_copy(update={"post_xy_local_offset_enabled": False})
1567 old_xy = posts._post_world_xy(parent, stations, offsets, off)1561 old_xy = posts._post_world_xy(parent, stations, offsets, off)
1568 old_laterals = posts._post_lateral_offsets(parent, old_xy, stations)1562 old_laterals = posts._post_lateral_offsets(parent, old_xy, stations)
1569 old_slope = float(np.polyfit(stations, old_laterals, 1)[0])1563 old_slope = float(np.polyfit(stations, old_laterals, 1)[0])
1570 assert old_slope == pytest.approx(0.02, abs=0.002)1564 assert old_slope == pytest.approx(0.02, abs=0.002)
Importance #42: tests/test_support_class.py @@ -1578,9 +1572,12 @@
1578 stations = np.linspace(1.0, 39.0, 20)1572 stations = np.linspace(1.0, 39.0, 20)
1579 offsets = np.full(stations.size, 4.30)1573 offsets = np.full(stations.size, 4.30)
1580 new_xy = posts._post_world_xy(parent, stations, offsets, config)1574 new_xy = posts._post_world_xy(parent, stations, offsets, config)
1581 old_xy = posts._post_world_xy(1575 old_xy = posts._post_world_xy(
1582 parent, stations, offsets, replace(config, post_xy_local_offset_enabled=False)1576 parent,
1577 stations,
1578 offsets,
1579 config.model_copy(update={"post_xy_local_offset_enabled": False}),
1583 )1580 )
1584 np.testing.assert_allclose(new_xy, old_xy, atol=1e-12)1581 np.testing.assert_allclose(new_xy, old_xy, atol=1e-12)
15851582
15861583
Importance #43: tests/test_support_class.py @@ -1592,9 +1589,12 @@
1592 stations = np.linspace(1.0, 39.0, 20)1589 stations = np.linspace(1.0, 39.0, 20)
1593 offsets = np.full(stations.size, 4.70)1590 offsets = np.full(stations.size, 4.70)
1594 xy = posts._post_world_xy(parent, stations, offsets, config)1591 xy = posts._post_world_xy(parent, stations, offsets, config)
1595 expected = posts._post_world_xy(1592 expected = posts._post_world_xy(
1596 parent, stations, offsets, replace(config, post_xy_local_offset_enabled=False)1593 parent,
1594 stations,
1595 offsets,
1596 config.model_copy(update={"post_xy_local_offset_enabled": False}),
1597 )1597 )
1598 np.testing.assert_allclose(xy, expected, atol=1e-12)1598 np.testing.assert_allclose(xy, expected, atol=1e-12)
15991599
16001600
Importance #44: tests/test_top_member.py @@ -238,9 +238,11 @@
238 }238 }
239239
240 on, off = _rail(), _rail()240 on, off = _rail(), _rail()
241 posts.attach_beam_bottom([on], evidence, config)241 posts.attach_beam_bottom([on], evidence, config)
242 posts.attach_beam_bottom([off], evidence, replace(config, post_beam_top_enabled=False))242 posts.attach_beam_bottom(
243 [off], evidence, config.model_copy(update={"post_beam_top_enabled": False})
244 )
243245
244 assert on["beam_bottom"]["top_height_m"] == pytest.approx(0.775)246 assert on["beam_bottom"]["top_height_m"] == pytest.approx(0.775)
245 assert on["beam_bottom"]["top_measured"] is True247 assert on["beam_bottom"]["top_measured"] is True
246 assert on["polyline_beam_top_z_m"] == [100.775, 100.775]248 assert on["polyline_beam_top_z_m"] == [100.775, 100.775]
Importance #45: tests/test_top_member.py @@ -261,9 +263,9 @@
261 would leave ``detect_top_member`` and the shaft cap working off a number263 would leave ``detect_top_member`` and the shaft cap working off a number
262 nothing downstream can see -- and the shaft cap would silently fall back to264 nothing downstream can see -- and the shaft cap would silently fall back to
263 ``polyline_top_z_m``, a median column height that sits INSIDE the beam.265 ``polyline_top_z_m``, a median column height that sits INSIDE the beam.
264 """266 """
265 config = replace(DetectorConfig(), post_beam_top_enabled=False)267 config = DetectorConfig(post_beam_top_enabled=False)
266 counts = _band_counts(268 counts = _band_counts(
267 config, (0.10, 0.45, 300), (0.45, 0.775, 5000), (0.80, 0.975, 4000)269 config, (0.10, 0.45, 300), (0.45, 0.775, 5000), (0.80, 0.975, 4000)
268 )270 )
269 evidence = _height_evidence(counts, config)271 evidence = _height_evidence(counts, config)
Importance #46: tests/test_top_member.py @@ -504,9 +506,9 @@
504506
505507
506def test_top_member_is_gated_off_by_its_flag() -> None:508def test_top_member_is_gated_off_by_its_flag() -> None:
507 """``post_top_member_enabled=False`` writes nothing at all."""509 """``post_top_member_enabled=False`` writes nothing at all."""
508 config = replace(DetectorConfig(), post_top_member_enabled=False)510 config = DetectorConfig(post_top_member_enabled=False)
509 evidence = _rail_shape(config, beam=(0.45, 0.775, 180), member=(0.80, 0.975, 170))511 evidence = _rail_shape(config, beam=(0.45, 0.775, 180), member=(0.80, 0.975, 170))
510 rails = [{"id": 0, "polyline": [[0.0, 4.0], [20.0, 4.0]],512 rails = [{"id": 0, "polyline": [[0.0, 4.0], [20.0, 4.0]],
511 "polyline_station_m": [0.0, 20.0], "polyline_ground_z_m": [0.0, 0.0]}]513 "polyline_station_m": [0.0, 20.0], "polyline_ground_z_m": [0.0, 0.0]}]
512 members = posts.attach_top_members(514 members = posts.attach_top_members(
Importance #47: tests/test_top_member.py @@ -559,9 +561,11 @@
559 assert behind is not None and bool(behind[0]) is True561 assert behind is not None and bool(behind[0]) is True
560 assert bool(claim.behind_beam(post_xy - np.array([0.0, 0.50]), 0)[0]) is False562 assert bool(claim.behind_beam(post_xy - np.array([0.0, 0.50]), 0)[0]) is False
561563
562 off = posts.build_support_claim(564 off = posts.build_support_claim(
563 rail, support, replace(config, post_behind_beam_use_measured_side=False)565 rail,
566 support,
567 config.model_copy(update={"post_behind_beam_use_measured_side": False}),
564 )568 )
565 assert off.beam_outward_xy[0][1] == pytest.approx(-1.0)569 assert off.beam_outward_xy[0][1] == pytest.approx(-1.0)
566570
567571
Importance #48: tests/test_top_member.py @@ -625,9 +629,9 @@
625 post_xy = np.asarray(support["polyline"], dtype=float)629 post_xy = np.asarray(support["polyline"], dtype=float)
626 column = post_xy[0] + np.array([0.0, -0.08]) # 0.17 m off the rail line630 column = post_xy[0] + np.array([0.0, -0.08]) # 0.17 m off the rail line
627 assert bool(claim.behind_beam(column[None, :], 0)[0]) is True631 assert bool(claim.behind_beam(column[None, :], 0)[0]) is True
628 old = posts.build_support_claim(632 old = posts.build_support_claim(
629 rail, support, replace(config, post_behind_beam_front_margin_m=0.0)633 rail, support, config.model_copy(update={"post_behind_beam_front_margin_m": 0.0})
630 )634 )
631 assert old.behind_beam_min_dist_m == pytest.approx(0.25)635 assert old.behind_beam_min_dist_m == pytest.approx(0.25)
632 assert bool(old.behind_beam(column[None, :], 0)[0]) is False636 assert bool(old.behind_beam(column[None, :], 0)[0]) is False
633637
Importance #49: tests/test_top_member.py @@ -651,9 +655,11 @@
651 assert claim.behind_beam_min_dist_m == pytest.approx(655 assert claim.behind_beam_min_dist_m == pytest.approx(
652 config.post_behind_beam_offset_m656 config.post_behind_beam_offset_m
653 )657 )
654 off = posts.build_support_claim(658 off = posts.build_support_claim(
655 rail, support, replace(config, post_behind_beam_use_measured_side=False)659 rail,
660 support,
661 config.model_copy(update={"post_behind_beam_use_measured_side": False}),
656 )662 )
657 assert off.behind_beam_min_dist_m == pytest.approx(663 assert off.behind_beam_min_dist_m == pytest.approx(
658 config.post_behind_beam_offset_m664 config.post_behind_beam_offset_m
659 )665 )
Importance #50: tests/test_top_member.py @@ -859,9 +865,9 @@
859 member_lateral_m=0.25,865 member_lateral_m=0.25,
860 )866 )
861 beam = measure_beam_bottom(evidence, config)867 beam = measure_beam_bottom(evidence, config)
862 assert posts.detect_top_member(evidence, beam, 0.25, 2, config).present is True868 assert posts.detect_top_member(evidence, beam, 0.25, 2, config).present is True
863 strict = replace(config, post_top_member_min_posts=4)869 strict = config.model_copy(update={"post_top_member_min_posts": 4})
864 rejected = posts.detect_top_member(evidence, beam, 0.25, 2, strict)870 rejected = posts.detect_top_member(evidence, beam, 0.25, 2, strict)
865 assert rejected.present is False and rejected.reason == "no_posts"871 assert rejected.present is False and rejected.reason == "no_posts"
866 assert posts.detect_top_member(evidence, beam, 0.25, 4, strict).present is True872 assert posts.detect_top_member(evidence, beam, 0.25, 4, strict).present is True
867873
Importance #51: tests/test_top_member.py @@ -876,9 +882,9 @@
876882
877883
878def test_top_member_routing_emits_no_companion_but_still_claims() -> None:884def test_top_member_routing_emits_no_companion_but_still_claims() -> None:
879 """"guardrail_support" / "w_beam" route the rows without a new instance."""885 """"guardrail_support" / "w_beam" route the rows without a new instance."""
880 config = replace(DetectorConfig(), post_top_member_type="guardrail_support")886 config = DetectorConfig(post_top_member_type="guardrail_support")
881 rail, support = _tube_rail()887 rail, support = _tube_rail()
882 instances, geometry = posts.build_top_rail_instances(888 instances, geometry = posts.build_top_rail_instances(
883 [rail], [support], {0: 0}, {0: _member()}, 9, config889 [rail], [support], {0: 0}, {0: _member()}, 9, config
884 )890 )
Importance #52: tests/test_wall_geometry.py @@ -1,7 +1,7 @@
11
2from collections.abc import Callable2from collections.abc import Callable
3from dataclasses import fields, replace3from dataclasses import fields
44
5import numpy as np5import numpy as np
66
7from guardrails.config import DetectorConfig, wall_view_config7from guardrails.config import DetectorConfig, wall_view_config
Importance #53: tests/test_wall_geometry.py @@ -156,9 +156,9 @@
156 # A finer height bin (0.19 m) makes the banded-fill arithmetic below land156 # A finer height bin (0.19 m) makes the banded-fill arithmetic below land
157 # on realistic production numbers (segment_135 wall cells: p50 fill157 # on realistic production numbers (segment_135 wall cells: p50 fill
158 # 0.071) while still exercising the default wall_min_vertical_fill=0.05158 # 0.071) while still exercising the default wall_min_vertical_fill=0.05
159 # and wall_min_occupied_bins=2 gates.159 # and wall_min_occupied_bins=2 gates.
160 config = replace(DetectorConfig(), wall_height_bin_m=0.19)160 config = DetectorConfig(wall_height_bin_m=0.19)
161 evidence = _empty_evidence(1, 4, config)161 evidence = _empty_evidence(1, 4, config)
162 evidence.counts[0] = [20, 20, 20, 20]162 evidence.counts[0] = [20, 20, 20, 20]
163 evidence.top_height_m[0] = [8.0, 1.0, 2.5, 3.2]163 evidence.top_height_m[0] = [8.0, 1.0, 2.5, 3.2]
164164
Importance #54: tests/test_wall_geometry.py @@ -227,9 +227,9 @@
227 assert detect_wall_instances(evidence, config=config) == []227 assert detect_wall_instances(evidence, config=config) == []
228228
229229
230def test_fit_instance_persists_fitted_width() -> None:230def test_fit_instance_persists_fitted_width() -> None:
231 config = replace(DetectorConfig(), min_length_m=5.0, max_local_width_m=2.0)231 config = DetectorConfig(min_length_m=5.0, max_local_width_m=2.0)
232 x = np.repeat(np.linspace(0.0, 10.0, 40), 3)232 x = np.repeat(np.linspace(0.0, 10.0, 40), 3)
233 y = np.tile(np.array([-0.2, 0.0, 0.2]), 40)233 y = np.tile(np.array([-0.2, 0.0, 0.2]), 40)
234 fitted = _fit_instance(234 fitted = _fit_instance(
235 np.column_stack((x, y)), np.ones(len(x)), np.full(len(x), 0.6), config235 np.column_stack((x, y)), np.ones(len(x)), np.full(len(x), 0.6), config
Importance #55: tests/test_wall_geometry.py @@ -609,9 +609,9 @@
609 assert rejected == walls609 assert rejected == walls
610610
611611
612def test_wall_carriageway_gate_disabled_keeps_everything() -> None:612def test_wall_carriageway_gate_disabled_keeps_everything() -> None:
613 config = replace(DetectorConfig(), wall_reject_inside_carriageway=False)613 config = DetectorConfig(wall_reject_inside_carriageway=False)
614 walls = [_wall(-4.544), _wall(None), _wall(-1.0)]614 walls = [_wall(-4.544), _wall(None), _wall(-1.0)]
615615
616 kept, rejected = filter_walls_outside_carriageway(walls, [_rail(-7.0)], config)616 kept, rejected = filter_walls_outside_carriageway(walls, [_rail(-7.0)], config)
617617
Importance #56: pyproject.toml @@ -1,16 +1,17 @@
1[project]1[project]
2name = "guardrails"2name = "guardrails"
3version = "0.4.0"3version = "0.4.1"
4description = "Classical geometric guardrail detection in MLS LiDAR point clouds"4description = "Classical geometric guardrail detection in MLS LiDAR point clouds"
5readme = "README.md"5readme = "README.md"
6requires-python = ">=3.11"6requires-python = ">=3.11"
7dependencies = [7dependencies = [
8 "numpy>=2.0",8 "numpy>=2.0",
9 "pillow>=10.0",9 "pillow>=10.0",
10 "scikit-learn>=1.5",10 "scikit-learn>=1.5",
11 "scipy>=1.13",11 "scipy>=1.13",
12 "iolabs-common>=0.7.0",12 "iolabs-common>=0.8.0",
13 "pydantic>=2.7",
13 "iolabs-geometry-geometry>=0.11.0",14 "iolabs-geometry-geometry>=0.11.0",
14 "iolabs-geometry-raster>=0.2.0",15 "iolabs-geometry-raster>=0.2.0",
15 "iolabs-geometry-visualization>=0.7.0",16 "iolabs-geometry-visualization>=0.7.0",
16 "iolabs-point-cloud-modelling-export",17 "iolabs-point-cloud-modelling-export",
Importance #57: README.md @@ -24,12 +24,20 @@
2424
25Following the other iolabs point-cloud packages25Following the other iolabs point-cloud packages
26(`iolabs_point_cloud_segmentation_trajectory` etc.), the package owns an26(`iolabs_point_cloud_segmentation_trajectory` etc.), the package owns an
27algorithm config `guardrails/guardrails.default.json`. `guardrails/config.py` is27algorithm config `guardrails/guardrails.default.json`. `guardrails/config.py` is
28the loader/schema: the frozen `DetectorConfig` dataclass is the typed params28the loader/schema: `DetectorConfig` is a frozen pydantic model derived from
29object and its field set is the schema. Every dataclass default is kept29`iolabs.common.config_loader.ConfigModel` (the fleet SSOT: it rejects unknown
30keys and coerces raw JSON / `--set` values to the declared field types), and its
31field set is the schema. The field declarations live in the two mixins
32`guardrails/_config_fields.py` and `guardrails/_config_fields_posts.py` (split
33only to keep every module under 500 lines). Every model default is kept
30identical to the JSON (asserted by `tests/test_config.py`).34identical to the JSON (asserted by `tests/test_config.py`).
3135
36**Adding a config key:** declare the field on the matching mixin and add the
37same key/default to `guardrails.default.json`. Nothing else โ€” there is no
38allowed-key list and no coercion helper to update.
39
32Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are40Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are
33JSON-decoded), never repo-local JSON files:41JSON-decoded), never repo-local JSON files:
3442
35```bash43```bash
Importance #58: guardrails/_config_fields.py @@ -0,0 +1,312 @@
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 #59: guardrails/_config_fields_posts.py @@ -0,0 +1,204 @@
1"""Field declarations for :class:`guardrails.config.DetectorConfig` (part 2).
2
3The guardrail rail-vs-support decomposition levers (post cadence, beam
4underside, top member). Split out of ``config.py`` for the 500-line limit; see
5:mod:`guardrails._config_fields` for the rest of the schema.
6"""
7
8from typing import Literal
9
10from iolabs.common import config_loader
11
12
13class PostFields(config_loader.ConfigModel):
14 """Post cadence, beam-underside and top-member levers."""
15
16 # Guardrail rail-vs-support decomposition (post cadence + support class).
17 # Height cut lines are literature-derived (Swiss/German hardware: rail band
18 # top edge ~0.75 m, Sigma-100 post 100x55 mm, ASTRA 11005 post spacings
19 # 1.33 / 2.00 m and DDSP 4.00 m), not yet tuned on our clouds; keep in
20 # config. All three feature flags default true; setting them false restores
21 # the pre-feature behavior exactly.
22 enable_post_cadence: bool = True
23 enable_support_class: bool = True
24 enable_component_masks: bool = True
25 post_low_band_min_m: float = 0.10
26 post_low_band_max_m: float = 0.35
27 post_station_bin_m: float = 0.10
28 post_lateral_halfwidth_m: float = 0.60
29 post_catalog_spacings_m: list[float] = [1.33, 2.0, 4.0]
30 post_spacing_snap_rel_tol: float = 0.12
31 post_min_period_m: float = 0.8
32 post_max_period_m: float = 6.0
33 post_min_confidence: float = 0.35
34 post_slot_min_points: int = 3
35 # Per-post peak detection (``posts.detect_run_posts``). The run-level comb
36 # (``post_min_confidence``) is only a scoring prior now: on a long rail the
37 # low band also carries continuous grass/plinth clutter, which drowns the
38 # comb contrast, so posts are accepted individually against a ROLLING local
39 # background instead of all-or-nothing against the run mean.
40 post_peak_smooth_m: float = 0.3
41 post_peak_background_window_m: float = 5.0
42 post_peak_min_prominence: float = 3.0
43 # A dense low band is also a NOISY one: at b points per smoothing window the
44 # Poisson swing is sqrt(b), so a fixed point floor would fabricate posts out
45 # of grass on exactly the cluttered runs this feature exists for. The
46 # effective floor is max(post_peak_min_prominence, sigmas * sqrt(background)).
47 post_peak_noise_sigmas: float = 3.0
48 post_peak_min_confidence: float = 0.25
49 # Wider above-background blobs are plinths / kerbs / parked clutter, not a
50 # 0.10 m post footprint. Measured at half prominence (see detect_run_posts).
51 post_max_station_extent_m: float = 0.45
52 # Measured post top is clamped to [rail band bottom, beam bottom + margin].
53 post_top_margin_m: float = 0.10
54 # Behind-beam shaft claim: a post-footprint point this far outboard of the
55 # rail's LOCAL centerline (not of its run-mean offset โ€” a 50 m polyline
56 # wanders further off its own mean than this threshold, which made the
57 # first cut of this rule inert on every curved run) sits on the far side of
58 # the beam from the road, so it is post shaft, not beam, and may be claimed
59 # up to the rail top. The threshold is the larger of
60 # ``post_behind_beam_offset_m`` (half a w-beam depth plus a margin: the
61 # floor, and what a rail with no measured width gets) and half the rail's
62 # ``width_m`` plus ``post_behind_beam_margin_m`` (what a wide rail needs).
63 post_claim_behind_beam: bool = True
64 post_behind_beam_offset_m: float = 0.22
65 post_behind_beam_margin_m: float = 0.05
66 # ... and once the post line itself is MEASURED (``_measured_post_side``),
67 # the threshold moves off that generic floor onto the hardware: the post's
68 # front face is ``|post_lat| - post_behind_beam_front_margin_m`` (an
69 # IPE-100 flange at 0.05 m plus the spacer that holds the plank off it),
70 # never nearer than the beam's own edge. The floor costs the A4/5 105
71 # median rails half their shaft: post line at 0.24-0.25 m against a 0.22 m
72 # threshold leaves the spacer and the post's road-side half to the rail.
73 post_behind_beam_front_margin_m: float = 0.10
74 # Where a measured post is PUT: the parent polyline at that post's station,
75 # displaced by ``post.offset_m`` minus the polyline's OWN spine offset there
76 # (round 7). With this off the displacement is measured against the run's
77 # constant ``mean_offset_m`` instead -- the round-6 behaviour, kept only so
78 # the flags-off byte-identity replay has something to compare against. On a
79 # run that wanders (A4/5 105 rail 1: 0.69 m end to end) the mean form walks
80 # the published post train diagonally across its own rail.
81 post_xy_local_offset_enabled: bool = True
82 # Measured per-rail beam underside (``posts.measure_beam_bottom``). The
83 # evidence pass folds a HEIGHT histogram over [post_low_band_min_m,
84 # beam_bottom_hist_max_m] alongside the station histogram, scoped to a
85 # tighter lateral halfwidth than the post band (the beam sits on the run's
86 # mean offset; kerb / soil returns further out only blur the onset).
87 # ``beam_bottom_hist_bin_m`` divides the distance from
88 # ``post_low_band_min_m`` to 0.35 / 0.75 / 0.85 exactly, so the rail band
89 # floor and the plausibility cap fall on bin edges rather than inside a bin.
90 beam_bottom_hist_bin_m: float = 0.025
91 # Ceiling of that histogram. 1.30 m (= ``max_height_m``, 48 bins from the
92 # 0.10 m floor) rather than the 1.00 m of rounds 3-6: the beam TOP walk-up
93 # and ``detect_top_member`` both need headroom ABOVE the structure to tell
94 # a bounded member (a Kastenprofil tube: mass ends at 0.98 m and there is
95 # nothing over it) from an unbounded one (a noise wall / hedge / parapet,
96 # which keeps going). At 1.00 m every A4/5 median tube reported
97 # ``truncated`` against what was really the knob, not the cloud.
98 # ``measure_beam_bottom`` is provably unchanged by the raise: its window is
99 # ``component_rail_band_m`` = [0.35, 0.85) and its walk is downward only,
100 # so bins added above cannot move the scale, the dense groups or the
101 # underside.
102 beam_bottom_hist_max_m: float = 1.30
103 beam_bottom_lateral_halfwidth_m: float = 0.40
104 # A candidate beam band is a contiguous group of bins carrying at least this
105 # fraction of the tallest bin in the rail band. Candidates are tried lowest
106 # first (a stacked double w-beam has two, and the upper one is often the
107 # taller), but only TRIED: the low band's own tail can clear this floor and
108 # group up below the beam, and on A4/5 066 rail 5 it does.
109 beam_bottom_band_fraction: float = 0.15
110 # Walking down from a candidate's peak, the underside is where the count
111 # first drops below this fraction of the peak bin.
112 beam_bottom_onset_fraction: float = 0.20
113 # ... and the drop has to be a STEP, not a drift across that threshold. A
114 # continuous barrier mistyped w_beam (A4/5 066 rail 2) has no underside at
115 # all, only a smooth ramp, and any walk-down threshold stops somewhere
116 # arbitrary in it. The knob sits in the gap the A4/5 rails measure out
117 # between two populations: the nine rails that do carry a beam step
118 # 2.00-54x at their onset (the 2.00 is 132 rail 0), while on the seven that
119 # do not, the strongest single-bin rise ANYWHERE in the rail band is 1.67x
120 # โ€” and that is already a harder test than this guard, which only ever
121 # looks at the bin the walk stopped on.
122 beam_bottom_min_onset_ratio: float = 1.8
123 beam_bottom_min_peak_points: int = 50
124 # Round 7: the same walk, upwards, giving the beam TOP -- and with it the
125 # shaft cap the claim should always have used. Gates the MEASUREMENT (the
126 # walk in ``_band_underside``, hence ``detect_top_member``'s precondition
127 # and the shaft cap's preference) as well as the PUBLICATION
128 # (``beam_bottom.top_height_m`` / ``top_measured`` / ``reason_top`` and
129 # ``polyline_beam_top_z_m``), so with it off guardrails.json is
130 # byte-identical to the round-6 one and no member can be detected.
131 post_beam_top_enabled: bool = True
132 # Plausibility window for the result: below ``component_rail_band_m[0]`` it
133 # is not beam (no rail evidence is counted there), above this it is a
134 # gantry / sign / noise wall, not a w-beam underside.
135 beam_bottom_max_m: float = 0.75
136 # Beam band [bottom, top] above the road, used as the fallback when a rail
137 # instance carries no measured ``polyline_bottom_z_m`` / ``polyline_top_z_m``.
138 component_rail_band_m: list[float] = [0.35, 0.85]
139 component_support_max_height_m: float = 0.50
140 component_support_station_tol_m: float = 0.20
141 component_support_footprint_m: float = 0.25
142
143 # --- Round 7: the top member (the Kastenprofil box tube on the A4/5
144 # median rails). ``detect_top_member`` measures the band ABOVE the beam
145 # top, and the two load-bearing gates are the mass fraction and the
146 # STATION COVERAGE: mass alone accepts a 27 m stub of vegetation behind a
147 # rail (A4/5 066 rail 1, mass fraction 0.44), and only "is this band there
148 # at every station of the run" rejects it (coverage 0.57 against 1.00 on
149 # all four real tubes).
150 post_top_member_enabled: bool = True
151 # Where the tube's rows go. "guardrail_top_rail" (default) emits the
152 # companion instance and LAS 74; "guardrail_support" folds them into the
153 # parent's support instance (LAS 72); "w_beam" leaves them on the parent
154 # rail (LAS 66). The last two emit no companion instance, so the fusion
155 # JSON paint has nothing to read and only the mask sidecar carries them.
156 post_top_member_type: Literal[
157 "guardrail_top_rail", "guardrail_support", "w_beam"
158 ] = "guardrail_top_rail"
159 # Mass above the measured beam top, over the mass in the rail window.
160 # Measured 0.49-0.53 on the four A4/5 tubes; 0.002-0.066 on nine of the
161 # twelve rails without one, 0.39-0.44 on the two 066 outliers coverage
162 # rejects.
163 post_top_member_min_mass_fraction: float = 0.15
164 # A member is "the thing above the post line", so there has to be a post
165 # line: below this many measured posts the run reports ``no_posts``.
166 post_top_member_min_posts: int = 2
167 # A bin is part of the band when it carries this fraction of the tallest
168 # bin above the beam top; the band is the contiguous dense group with the
169 # largest MASS (not the topmost one -- with the 1.30 m ceiling that picks
170 # a blob 0.30 m over the beam on A4/5 105 rail 3).
171 post_top_member_band_fraction: float = 0.15
172 # Reported, not enforced (a thin band that is present at every station is
173 # still a member; the real discriminators are mass and coverage).
174 post_top_member_min_thickness_m: float = 0.075
175 # A station bin counts as covered when the band carries this many points
176 # in it, over the station bins that carry any point of the run's slab.
177 post_top_member_min_bin_points: int = 3
178 post_top_member_min_coverage: float = 0.90
179 # Colocation with the measured post line, and the band's own lateral
180 # spread. Both are REPORTED on every rail; the gate is off by default
181 # (mass + coverage already separate the two populations by 0.33 of
182 # coverage, and three rails without a tube pass the lateral test anyway).
183 post_top_member_lateral_gate_enabled: bool = False
184 # ``post_top_member_max_lateral_offset_m`` is enforced whatever that flag
185 # says in ONE place: the prism's axis. A post median that disagrees with
186 # the band's own measured lateral by more than this is not the line the
187 # member runs along, and sweeping a full-length 0.25 m prism down it would
188 # paint whatever stands behind the rail (see ``_top_rail_geometry``).
189 post_top_member_max_lateral_offset_m: float = 0.12
190 post_top_member_max_lateral_spread_m: float = 0.15
191 # Halfwidth of the swept prism that claims the tube, about the robust post
192 # line. The measured 2-98 percentile lateral extent of the four A4/5 tubes
193 # about that line is within [-0.20, +0.17] m.
194 post_top_member_halfwidth_m: float = 0.25
195 # --- Round 7: the behind-beam outward sign, from the MEASURED post side.
196 # ``sign(mean_offset_m)`` assumes the posts are always further from the
197 # spine than the beam; on the A4/5 median rails that is true on only half
198 # of them, and the shaft claim is completely dead on the other half. The
199 # three guards are what keep every rail whose posts sit ON the line (the
200 # outer rails: |side| 0.004-0.079) on the old sign, bit for bit.
201 post_behind_beam_use_measured_side: bool = True
202 post_behind_beam_min_post_offset_m: float = 0.10
203 post_behind_beam_min_posts: int = 4
204 post_behind_beam_min_side_agreement: float = 0.70
0
Importance #60: guardrails/config.py @@ -4,540 +4,104 @@
4(``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a4(``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a
5``guardrails.default.json`` algorithm config, and a typed params object5``guardrails.default.json`` algorithm config, and a typed params object
6(:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are6(:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are
7applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON.7applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON.
8``config.py`` is the loader/schema: the dataclass field set is the schema and
9every field default is kept identical to ``guardrails.default.json`` (guarded by
10a unit test), so ``DetectorConfig()`` and ``load_config()`` agree.
11"""
128
9The schema is the pydantic model :class:`DetectorConfig`, derived from
10:class:`iolabs.common.config_loader.ConfigModel`: unknown keys are rejected and
11raw JSON / ``--set`` values are coerced to the declared field types by the
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"""
1318
14import copy
15import logging19import logging
16from dataclasses import dataclass, field, replace
17from typing import Any20from typing import Any
1821
19from iolabs.common.config_loader import (22import pydantic
20 ConfigError,23from iolabs.common import config_loader
21 dataclass_from_mapping,
22 load_packaged_json,
23)
24from iolabs.common.config_loader import parse_set_overrides as _parse_set_overrides
25
26logger = logging.getLogger(__name__)
27
2824
29@dataclass(frozen=True)25from . import _config_fields, _config_fields_posts
30class DetectorConfig:
31 """Spatial and geometric thresholds, in metres unless stated otherwise."""
32
33 # Ground model
34 ground_cell_m: float = 0.75
35 ground_percentile: float = 8.0
36
37 # Corridor crop (station / offset frame)
38 corridor_offset_min_m: float = 1.5
39 corridor_offset_max_m: float = 10.0
40 corridor_include_median_zone: bool = True
41 median_corridor_offset_min_m: float = 0.8
42 median_corridor_offset_max_m: float = 3.8
43 corridor_max_height_m: float = 2.0
44 station_window_m: float = 5.0
45 median_side_max_offset_m: float = 3.5
46
47 # Optional lane-XML carriageway / rail-zone scoping
48 lane_xml_zones_enabled: bool = True
49 lane_xml_path: str | None = None
50 rail_zone_margin_m: float = 10.0
51 outer_rail_band_m: float = 20.0
52 single_edge_rail_margin_m: float = 15.0
53 max_carriageway_width_m: float = 15.0
54 zone_bbox_margin_m: float = 140.0
55 interior_rejection_depth_m: float = 2.0
56
57 # Optional late edge gate: instance-level distance filters against the
58 # lane-XML edge lines (rules E1/E2), applied after the precision gate.
59 # edge_gate_max_rail_distance_m was calibrated on A1 segments 060/066/085:
60 # real rails measure <= 3.7 m from an XML edge, noise >= 5.4 m.
61 edge_gate_enabled: bool = True
62 edge_gate_max_rail_distance_m: float = 5.0
63 edge_gate_interior_depth_m: float = 0.5
64 edge_gate_interior_max_frac: float = 0.5
65 edge_gate_apply_to_walls: bool = False
66
67 # Optional late precision gate over final rail/wall runs.
68 precision_gate_enabled: bool = True
69 precision_deep_interior_depth_m: float = 2.0
70 precision_deep_interior_frac_min: float = 0.50
71 precision_vehicle_max_length_m: float = 15.0
72 precision_vehicle_min_density_per_m: float = 750.0
73 precision_vehicle_min_mean_height_m: float = 0.80
74 precision_low_max_mean_height_m: float = 0.35
75 precision_sparse_max_density_per_m: float = 300.0
76 precision_sparse_min_outboard_gap_m: float = 6.0
77 precision_curve_min_line_rmse_m: float = 0.010
78 precision_far_min_axis_dist_m: float = 18.0
79 precision_long_low_min_length_m: float = 25.0
80 precision_edge_beyond_frac_min: float = 0.25
81 precision_dense_low_min_density_per_m: float = 2500.0
82 precision_parallel_min_inboard_gap_m: float = 3.0
83 precision_parallel_min_overlap_frac: float = 0.75
84 precision_unknown_far_min_axis_dist_m: float = 20.0
85 precision_very_far_min_outboard_gap_m: float = 12.0
86 precision_very_far_min_axis_dist_m: float = 25.0
87 precision_edge_abeam_window_m: float = 15.0
88 precision_edge_outboard_epsilon_m: float = 0.30
89
90 # Occupancy grid for candidate cells
91 occupancy_cell_m: float = 0.10
92
93 # Height band for initial point candidates (also drives candidates overlay)
94 min_height_m: float = 0.20
95 max_height_m: float = 1.30
96
97 # Per-cell rail-band fraction and mean-height gates
98 rail_band_min_m: float = 0.35
99 rail_band_max_m: float = 0.85
100 min_cell_points: int = 3
101 min_rail_points: int = 2
102 min_rail_fraction: float = 0.40
103 min_mean_height_m: float = 0.42
104 max_mean_height_m: float = 0.78
105
106 # Optional tablecloth-residue candidate lever
107 tablecloth_masks_dir: str | None = None
108 residue_union_enabled: bool = True
109 residue_cell_frac: float = 0.8
110 residue_lever_band_m: list[float] = field(default_factory=lambda: [0.30, 1.20])
111
112 # Vegetation rejection: compact height-above-ground spread within a cell
113 max_cell_height_spread_m: float = 0.50
114
115 # Tall-object fraction per cell (trees, poles)
116 tall_min_m: float = 1.30
117 tall_max_m: float = 4.50
118 max_tall_fraction: float = 0.12
119
120 # Local covariance / eigenvector candidate filter (cell-level)
121 eigen_neighborhood_radius_m: float = 0.40
122 eigen_min_neighbors: int = 5
123 min_linearity: float = 0.30
124 min_verticality: float = 0.15
125 use_eigen_cell_filter: bool = False
126
127 # DBSCAN clustering on selected occupancy cells
128 cluster_eps_m: float = 0.20
129 cluster_min_samples: int = 3
130
131 # Post-cluster merge of collinear fragments
132 merge_gap_m: float = 4.5
133 merge_angle_deg: float = 15.0
134 merge_lateral_max_m: float = 0.50
135
136 # Occlusion bridging: join collinear fragments across a parked-vehicle /
137 # occlusion shadow when heading and offset stay continuous (defect 4). The
138 # bridged station interval is recorded in ``gap_spans`` (never interpolated
139 # silently).
140 # Default is conservative (8 m) so bridging never fuses two distinct
141 # barriers into one instance; raise via --set occlusion_bridge_max_m=15 for
142 # datasets with longer occlusion shadows.
143 occlusion_bridge_max_m: float = 8.0
144 occlusion_bridge_max_angle_deg: float = 4.0
145 occlusion_bridge_max_lateral_m: float = 0.40
146
147 # Parallel-face deduplication (two faces of one physical rail).
148 # ``dedupe_*`` are retained for backward compatibility; the active policy is
149 # driven by ``merge_face_*`` (see README "Face / barrier merge policy").
150 dedupe_face_max_sep_m: float = 1.0
151 dedupe_max_angle_deg: float = 12.0
152 merge_face_max_spacing_m: float = 1.3
153 merge_face_max_heading_deg: float = 5.0
154 merge_face_min_station_overlap: float = 0.5
155 merge_face_max_faces: int = 2
156
157 # Instance acceptance (applied after merge)
158 min_length_m: float = 12.0
159 max_local_width_m: float = 0.75
160 min_longitudinal_coverage: float = 0.35
161
162 # Ordered-walk polyline construction
163 polyline_bin_m: float = 1.0
164 polyline_smooth_window: int = 5
165 walk_max_step_m: float = 0.30
166
167 # Gap recording along station
168 gap_min_span_m: float = 2.0
169
170 # Vehicle / occlusion-shadow rejection on cluster height distribution
171 max_cluster_height_spread_m: float = 0.80
172 max_cluster_p95_height_m: float = 1.15
173
174 # Straightness check along sliding window (short clusters only)
175 straightness_window_m: float = 10.0
176 max_straightness_deviation_m: float = 0.50
177 straightness_max_length_m: float = 25.0
178
179 # Heuristic type classification thresholds
180 w_beam_min_height_m: float = 0.40
181 w_beam_max_height_m: float = 0.90
182 w_beam_max_height_spread_m: float = 0.55
183 concrete_min_height_m: float = 0.80
184 concrete_max_height_spread_m: float = 0.45
185 cable_suspect_max_spread_m: float = 0.25
186
187 # Per-run confidence heuristic (0-1); see README "Run confidence".
188 # confidence = 0.35*support + 0.25*continuity + 0.25*extent + 0.15*height
189 confidence_density_norm_pts_per_m: float = 500.0
190 confidence_full_extent_m: float = 40.0
191 confidence_max_height_std_m: float = 0.2
192
193 # Memory hardening (deployment target is a 32 GB RAM Azure node).
194 memory_budget_gb: float = 10.0
195 station_process_window_m: float = 5.0
196 decimation_enabled: bool = False
197 decimation_voxel_m: float = 0.05
198 decimation_density_cap: int = 400000
199 # Records larger than this stream through the corridor crop in chunks of
200 # this many points instead of being materialized whole (byte-identical
201 # results for records at or below the threshold, which use the old path).
202 record_chunk_points: int = 4000000
203 # Exclusion clustering guard: DBSCAN memory scales with the number of
204 # eps-neighbour pairs. When a cheap grid estimate of that count exceeds
205 # this cap the exclusion candidates are voxel-decimated first (auto-trigger
206 # only; sparse segments are untouched). segment_134's dense record
207 # estimated 4.0e9 pairs (25 GB RSS); curated segments peak at 6.3e8.
208 exclusion_pair_estimate_max: float = 1000000000.0
209 exclusion_decimation_cell_m: float = 0.10
210 # After the density trigger decimates, the residual DBSCAN runs under the
211 # shared iolabs.common.memory_guard watchdog (subprocess + psutil RSS
212 # monitor, hard kill above the limit) as a second line of defense. Mirrors
213 # the subcluster_dbscan_memory_guard wiring in
214 # iolabs_point_cloud_modelling_lines / iolabs_geometry_geometry.fit_spline.
215 exclusion_use_shared_watchdog: bool = True
216 exclusion_dbscan_mem_limit_gb: float = 6.0
217 exclusion_dbscan_timeout_s: float = 120.0
218
219 # Wall detection: independent evidence/fitting channel (see README "Noise
220 # walls"). ``wall_detection_enabled=False`` is a process-level kill switch;
221 # it emits ``"walls": []`` and allocates no wall grids.
222 wall_detection_enabled: bool = True
223 wall_cell_m: float = 0.25
224 wall_height_bin_m: float = 0.25
225 wall_min_height_m: float = 0.30
226 wall_max_height_m: float = 8.00
227 wall_offset_min_m: float = 1.50
228 # Dataset ground truth (segments 133-137; segment_135 confirmed walls near
229 # offset ~23 m) puts walls at spine offsets 21-25 m; 20.0 would miss them.
230 wall_offset_max_m: float = 26.00
231 wall_min_cell_points: int = 6
232 wall_min_top_height_m: float = 2.50
233 wall_max_top_height_m: float = 8.00
234 # Grazing-angle MLS returns are banded, not continuous: production
235 # segment_135 wall cells measured occupied-bin fill p10=0.040/p50=0.071.
236 wall_min_vertical_fill: float = 0.05
237 # Per-cell minimum distinct occupied height bins; rejects single-scanline
238 # artifacts.
239 wall_min_occupied_bins: int = 2
240 # Per-cell occupied-bin span (last - first occupied bin, inclusive) in
241 # metres: separates vertical-sheet wall cells (bins spread over metres)
242 # from grazing-angle surface/embankment cells banded within ~0.5 m.
243 wall_min_cell_height_span_m: float = 1.5
244
245 # Wall-view overrides of the shared clustering/merge/fit config (see
246 # ``wall_view_config()``).
247 wall_cluster_eps_m: float = 0.40
248 wall_cluster_min_samples: int = 3
249 wall_merge_gap_m: float = 4.50
250 wall_merge_angle_deg: float = 8.0
251 wall_merge_lateral_max_m: float = 1.00
252 # Real occluded walls (segment_135) show raw-data voids up to ~13.8 m;
253 # 14.0 keeps that structure bridgeable while the 4deg/0.4 m collinearity
254 # guards below still block unrelated fragments from fusing.
255 wall_occlusion_bridge_max_m: float = 14.00
256 wall_occlusion_bridge_max_angle_deg: float = 4.0
257 wall_occlusion_bridge_max_lateral_m: float = 0.40
258 # Staggered noise-wall rows fit as separate ~14 m instances after polyline
259 # smoothing (segment_135: 14.86 m / 13.92 m); vegetation rejection is
260 # carried by the width/straightness/planarity/crest gates, not length.
261 wall_min_length_m: float = 13.0
262 wall_max_local_width_m: float = 1.80
263 wall_min_longitudinal_coverage: float = 0.60
264 wall_max_cluster_height_spread_m: float = 12.0
265 wall_max_cluster_p95_height_m: float = 12.0
266 wall_straightness_window_m: float = 10.0
267 wall_max_straightness_deviation_m: float = 0.35
268 wall_straightness_max_length_m: float = 25.0
269 # Sparse/occluded tail regions leave the wall polyline fit on banded,
270 # far-range evidence that meanders (segment_135); a stronger lateral
271 # smoothing window than the guardrail default (5) is needed to tame it.
272 wall_polyline_smooth_window: int = 9
273
274 # Post-fit wall-only gates (crest profile, truck rejection, mandatory 3D
275 # PCA plane checks); not part of ``wall_view_config()``.
276 wall_profile_bin_m: float = 1.00
277 # Real crest profiles ramp at their ends; a genuine structure was rejected
278 # by 0.005 m in production. Truck rejection is handled separately by the
279 # truck double-gate below.
280 wall_max_top_profile_spread_m: float = 1.50
281 wall_truck_max_top_m: float = 4.20
282 # EU max articulated truck length is ~18.75 m; 20.0 keeps the truck
283 # double-gate effective (top <= wall_truck_max_top_m AND length < this)
284 # while remaining just above that bound.
285 wall_truck_min_length_m: float = 20.0
286 wall_min_planarity: float = 0.55
287 wall_max_plane_normal_z_abs: float = 0.35
288 # Grazing-angle MLS returns are height-banded (segment_135 row B:
289 # planarity=0.368, normal_z_abs=0.005): a clearly-vertical cell can sit
290 # just under the mandatory planarity ratio. Moderate planarity is
291 # accepted when the normal is unambiguously vertical.
292 wall_min_planarity_vertical: float = 0.25
293 # Banded returns can also collapse to a line-degenerate (not plane-like)
294 # moment shape, making the plane normal numerically arbitrary
295 # (segment_135 row A: planarity=0.020, normal_z_abs=1.000, yet the
296 # moments are unambiguously line-like). A high linearity ratio plus a
297 # thin fitted width certifies a genuine vertical sheet without relying on
298 # that ill-conditioned normal.
299 wall_line_bypass_min_linearity: float = 0.75
300 wall_line_bypass_max_width_m: float = 1.0
301
302 # Carriageway rejection gate: a wall candidate between the carriageway
303 # edge-line guardrails is a vehicle (or bridge-deck returns sharing its
304 # cells), not a genuine noise wall (see README "Carriageway rejection
305 # gate"; production segment_135 false positive at offset -4.544 m).
306 wall_reject_inside_carriageway: bool = True
307 # Fallback minimum |mean_offset_m| for a wall when no same-side guardrail
308 # exists to compare against.
309 wall_min_abs_offset_m: float = 6.0
310 # A wall may interleave up to this much inside the outermost same-side
311 # guardrail before being treated as inside the carriageway.
312 wall_outside_rail_margin_m: float = 0.5
313
314 # A ground-standing wall's first returns start near the ground; a bottom-height
315 # profile starting above this is an elevated bridge parapet/deck structure
316 # measured from the wrong base.
317 wall_max_bottom_height_m: float = 2.0
318
319 # Guardrail rail-vs-support decomposition (post cadence + support class).
320 # Height cut lines are literature-derived (Swiss/German hardware: rail band
321 # top edge ~0.75 m, Sigma-100 post 100x55 mm, ASTRA 11005 post spacings
322 # 1.33 / 2.00 m and DDSP 4.00 m), not yet tuned on our clouds; keep in
323 # config. All three feature flags default true; setting them false restores
324 # the pre-feature behavior exactly.
325 enable_post_cadence: bool = True
326 enable_support_class: bool = True
327 enable_component_masks: bool = True
328 post_low_band_min_m: float = 0.10
329 post_low_band_max_m: float = 0.35
330 post_station_bin_m: float = 0.10
331 post_lateral_halfwidth_m: float = 0.60
332 post_catalog_spacings_m: list[float] = field(
333 default_factory=lambda: [1.33, 2.0, 4.0]
334 )
335 post_spacing_snap_rel_tol: float = 0.12
336 post_min_period_m: float = 0.8
337 post_max_period_m: float = 6.0
338 post_min_confidence: float = 0.35
339 post_slot_min_points: int = 3
340 # Per-post peak detection (``posts.detect_run_posts``). The run-level comb
341 # (``post_min_confidence``) is only a scoring prior now: on a long rail the
342 # low band also carries continuous grass/plinth clutter, which drowns the
343 # comb contrast, so posts are accepted individually against a ROLLING local
344 # background instead of all-or-nothing against the run mean.
345 post_peak_smooth_m: float = 0.3
346 post_peak_background_window_m: float = 5.0
347 post_peak_min_prominence: float = 3.0
348 # A dense low band is also a NOISY one: at b points per smoothing window the
349 # Poisson swing is sqrt(b), so a fixed point floor would fabricate posts out
350 # of grass on exactly the cluttered runs this feature exists for. The
351 # effective floor is max(post_peak_min_prominence, sigmas * sqrt(background)).
352 post_peak_noise_sigmas: float = 3.0
353 post_peak_min_confidence: float = 0.25
354 # Wider above-background blobs are plinths / kerbs / parked clutter, not a
355 # 0.10 m post footprint. Measured at half prominence (see detect_run_posts).
356 post_max_station_extent_m: float = 0.45
357 # Measured post top is clamped to [rail band bottom, beam bottom + margin].
358 post_top_margin_m: float = 0.10
359 # Behind-beam shaft claim: a post-footprint point this far outboard of the
360 # rail's LOCAL centerline (not of its run-mean offset โ€” a 50 m polyline
361 # wanders further off its own mean than this threshold, which made the
362 # first cut of this rule inert on every curved run) sits on the far side of
363 # the beam from the road, so it is post shaft, not beam, and may be claimed
364 # up to the rail top. The threshold is the larger of
365 # ``post_behind_beam_offset_m`` (half a w-beam depth plus a margin: the
366 # floor, and what a rail with no measured width gets) and half the rail's
367 # ``width_m`` plus ``post_behind_beam_margin_m`` (what a wide rail needs).
368 post_claim_behind_beam: bool = True
369 post_behind_beam_offset_m: float = 0.22
370 post_behind_beam_margin_m: float = 0.05
371 # ... and once the post line itself is MEASURED (``_measured_post_side``),
372 # the threshold moves off that generic floor onto the hardware: the post's
373 # front face is ``|post_lat| - post_behind_beam_front_margin_m`` (an
374 # IPE-100 flange at 0.05 m plus the spacer that holds the plank off it),
375 # never nearer than the beam's own edge. The floor costs the A4/5 105
376 # median rails half their shaft: post line at 0.24-0.25 m against a 0.22 m
377 # threshold leaves the spacer and the post's road-side half to the rail.
378 post_behind_beam_front_margin_m: float = 0.10
379 # Where a measured post is PUT: the parent polyline at that post's station,
380 # displaced by ``post.offset_m`` minus the polyline's OWN spine offset there
381 # (round 7). With this off the displacement is measured against the run's
382 # constant ``mean_offset_m`` instead -- the round-6 behaviour, kept only so
383 # the flags-off byte-identity replay has something to compare against. On a
384 # run that wanders (A4/5 105 rail 1: 0.69 m end to end) the mean form walks
385 # the published post train diagonally across its own rail.
386 post_xy_local_offset_enabled: bool = True
387 # Measured per-rail beam underside (``posts.measure_beam_bottom``). The
388 # evidence pass folds a HEIGHT histogram over [post_low_band_min_m,
389 # beam_bottom_hist_max_m] alongside the station histogram, scoped to a
390 # tighter lateral halfwidth than the post band (the beam sits on the run's
391 # mean offset; kerb / soil returns further out only blur the onset).
392 # ``beam_bottom_hist_bin_m`` divides the distance from
393 # ``post_low_band_min_m`` to 0.35 / 0.75 / 0.85 exactly, so the rail band
394 # floor and the plausibility cap fall on bin edges rather than inside a bin.
395 beam_bottom_hist_bin_m: float = 0.025
396 # Ceiling of that histogram. 1.30 m (= ``max_height_m``, 48 bins from the
397 # 0.10 m floor) rather than the 1.00 m of rounds 3-6: the beam TOP walk-up
398 # and ``detect_top_member`` both need headroom ABOVE the structure to tell
399 # a bounded member (a Kastenprofil tube: mass ends at 0.98 m and there is
400 # nothing over it) from an unbounded one (a noise wall / hedge / parapet,
401 # which keeps going). At 1.00 m every A4/5 median tube reported
402 # ``truncated`` against what was really the knob, not the cloud.
403 # ``measure_beam_bottom`` is provably unchanged by the raise: its window is
404 # ``component_rail_band_m`` = [0.35, 0.85) and its walk is downward only,
405 # so bins added above cannot move the scale, the dense groups or the
406 # underside.
407 beam_bottom_hist_max_m: float = 1.30
408 beam_bottom_lateral_halfwidth_m: float = 0.40
409 # A candidate beam band is a contiguous group of bins carrying at least this
410 # fraction of the tallest bin in the rail band. Candidates are tried lowest
411 # first (a stacked double w-beam has two, and the upper one is often the
412 # taller), but only TRIED: the low band's own tail can clear this floor and
413 # group up below the beam, and on A4/5 066 rail 5 it does.
414 beam_bottom_band_fraction: float = 0.15
415 # Walking down from a candidate's peak, the underside is where the count
416 # first drops below this fraction of the peak bin.
417 beam_bottom_onset_fraction: float = 0.20
418 # ... and the drop has to be a STEP, not a drift across that threshold. A
419 # continuous barrier mistyped w_beam (A4/5 066 rail 2) has no underside at
420 # all, only a smooth ramp, and any walk-down threshold stops somewhere
421 # arbitrary in it. The knob sits in the gap the A4/5 rails measure out
422 # between two populations: the nine rails that do carry a beam step
423 # 2.00-54x at their onset (the 2.00 is 132 rail 0), while on the seven that
424 # do not, the strongest single-bin rise ANYWHERE in the rail band is 1.67x
425 # โ€” and that is already a harder test than this guard, which only ever
426 # looks at the bin the walk stopped on.
427 beam_bottom_min_onset_ratio: float = 1.8
428 beam_bottom_min_peak_points: int = 50
429 # Round 7: the same walk, upwards, giving the beam TOP -- and with it the
430 # shaft cap the claim should always have used. Gates the MEASUREMENT (the
431 # walk in ``_band_underside``, hence ``detect_top_member``'s precondition
432 # and the shaft cap's preference) as well as the PUBLICATION
433 # (``beam_bottom.top_height_m`` / ``top_measured`` / ``reason_top`` and
434 # ``polyline_beam_top_z_m``), so with it off guardrails.json is
435 # byte-identical to the round-6 one and no member can be detected.
436 post_beam_top_enabled: bool = True
437 # Plausibility window for the result: below ``component_rail_band_m[0]`` it
438 # is not beam (no rail evidence is counted there), above this it is a
439 # gantry / sign / noise wall, not a w-beam underside.
440 beam_bottom_max_m: float = 0.75
441 # Beam band [bottom, top] above the road, used as the fallback when a rail
442 # instance carries no measured ``polyline_bottom_z_m`` / ``polyline_top_z_m``.
443 component_rail_band_m: list[float] = field(default_factory=lambda: [0.35, 0.85])
444 component_support_max_height_m: float = 0.50
445 component_support_station_tol_m: float = 0.20
446 component_support_footprint_m: float = 0.25
447
448 # --- Round 7: the top member (the Kastenprofil box tube on the A4/5
449 # median rails). ``detect_top_member`` measures the band ABOVE the beam
450 # top, and the two load-bearing gates are the mass fraction and the
451 # STATION COVERAGE: mass alone accepts a 27 m stub of vegetation behind a
452 # rail (A4/5 066 rail 1, mass fraction 0.44), and only "is this band there
453 # at every station of the run" rejects it (coverage 0.57 against 1.00 on
454 # all four real tubes).
455 post_top_member_enabled: bool = True
456 # Where the tube's rows go. "guardrail_top_rail" (default) emits the
457 # companion instance and LAS 74; "guardrail_support" folds them into the
458 # parent's support instance (LAS 72); "w_beam" leaves them on the parent
459 # rail (LAS 66). The last two emit no companion instance, so the fusion
460 # JSON paint has nothing to read and only the mask sidecar carries them.
461 post_top_member_type: str = "guardrail_top_rail"
462 # Mass above the measured beam top, over the mass in the rail window.
463 # Measured 0.49-0.53 on the four A4/5 tubes; 0.002-0.066 on nine of the
464 # twelve rails without one, 0.39-0.44 on the two 066 outliers coverage
465 # rejects.
466 post_top_member_min_mass_fraction: float = 0.15
467 # A member is "the thing above the post line", so there has to be a post
468 # line: below this many measured posts the run reports ``no_posts``.
469 post_top_member_min_posts: int = 2
470 # A bin is part of the band when it carries this fraction of the tallest
471 # bin above the beam top; the band is the contiguous dense group with the
472 # largest MASS (not the topmost one -- with the 1.30 m ceiling that picks
473 # a blob 0.30 m over the beam on A4/5 105 rail 3).
474 post_top_member_band_fraction: float = 0.15
475 # Reported, not enforced (a thin band that is present at every station is
476 # still a member; the real discriminators are mass and coverage).
477 post_top_member_min_thickness_m: float = 0.075
478 # A station bin counts as covered when the band carries this many points
479 # in it, over the station bins that carry any point of the run's slab.
480 post_top_member_min_bin_points: int = 3
481 post_top_member_min_coverage: float = 0.90
482 # Colocation with the measured post line, and the band's own lateral
483 # spread. Both are REPORTED on every rail; the gate is off by default
484 # (mass + coverage already separate the two populations by 0.33 of
485 # coverage, and three rails without a tube pass the lateral test anyway).
486 post_top_member_lateral_gate_enabled: bool = False
487 # ``post_top_member_max_lateral_offset_m`` is enforced whatever that flag
488 # says in ONE place: the prism's axis. A post median that disagrees with
489 # the band's own measured lateral by more than this is not the line the
490 # member runs along, and sweeping a full-length 0.25 m prism down it would
491 # paint whatever stands behind the rail (see ``_top_rail_geometry``).
492 post_top_member_max_lateral_offset_m: float = 0.12
493 post_top_member_max_lateral_spread_m: float = 0.15
494 # Halfwidth of the swept prism that claims the tube, about the robust post
495 # line. The measured 2-98 percentile lateral extent of the four A4/5 tubes
496 # about that line is within [-0.20, +0.17] m.
497 post_top_member_halfwidth_m: float = 0.25
498 # --- Round 7: the behind-beam outward sign, from the MEASURED post side.
499 # ``sign(mean_offset_m)`` assumes the posts are always further from the
500 # spine than the beam; on the A4/5 median rails that is true on only half
501 # of them, and the shaft claim is completely dead on the other half. The
502 # three guards are what keep every rail whose posts sit ON the line (the
503 # outer rails: |side| 0.004-0.079) on the old sign, bit for bit.
504 post_behind_beam_use_measured_side: bool = True
505 post_behind_beam_min_post_offset_m: float = 0.10
506 post_behind_beam_min_posts: int = 4
507 post_behind_beam_min_side_agreement: float = 0.70
508
509 # Overlay kill switches (also mirrored in ``PerspectiveConfig`` so the
510 # independent perspective CLI shares the same rollback behavior).
511 overlay_extent_enabled: bool = True
512 overlay_ground_model_diff_enabled: bool = False
513
514
515class DetectorConfigError(ConfigError):
516 """Raised when the guardrails config contains unsupported keys."""
51726
27logger = logging.getLogger(__name__)
51828
519#: Import package holding the packaged default JSON, used when ``__package__``29#: Import package holding the packaged default JSON, used when ``__package__``
520#: is unset because ``config.py`` was executed as a loose script.30#: is unset because ``config.py`` was executed as a loose script.
521_PACKAGE_NAME = "guardrails"31_PACKAGE_NAME = "guardrails"
522_DEFAULT_CONFIG_NAME = "guardrails.default.json"32_DEFAULT_CONFIG_NAME = "guardrails.default.json"
52333
34#: Guardrail-named target field -> ``wall_*`` source field, applied by
35#: :func:`wall_view_config`.
36_WALL_VIEW_MAP: dict[str, str] = {
37 "occupancy_cell_m": "wall_cell_m",
38 "cluster_eps_m": "wall_cluster_eps_m",
39 "cluster_min_samples": "wall_cluster_min_samples",
40 "merge_gap_m": "wall_merge_gap_m",
41 "merge_angle_deg": "wall_merge_angle_deg",
42 "merge_lateral_max_m": "wall_merge_lateral_max_m",
43 "occlusion_bridge_max_m": "wall_occlusion_bridge_max_m",
44 "occlusion_bridge_max_angle_deg": "wall_occlusion_bridge_max_angle_deg",
45 "occlusion_bridge_max_lateral_m": "wall_occlusion_bridge_max_lateral_m",
46 "min_length_m": "wall_min_length_m",
47 "max_local_width_m": "wall_max_local_width_m",
48 "min_longitudinal_coverage": "wall_min_longitudinal_coverage",
49 "max_cluster_height_spread_m": "wall_max_cluster_height_spread_m",
50 "max_cluster_p95_height_m": "wall_max_cluster_p95_height_m",
51 "straightness_window_m": "wall_straightness_window_m",
52 "max_straightness_deviation_m": "wall_max_straightness_deviation_m",
53 "straightness_max_length_m": "wall_straightness_max_length_m",
54 "polyline_smooth_window": "wall_polyline_smooth_window",
55}
56
57
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):
84 """Raised when the guardrails config contains unsupported keys."""
85
52486
525def load_default_config_dict() -> dict[str, Any]:87def load_default_config_dict() -> dict[str, Any]:
526 """Return the package-owned default config as a plain dict.88 """Return the package-owned default config as a plain dict.
52789
528 Returns:90 Returns:
529 The decoded ``guardrails.default.json`` object.91 The decoded ``guardrails.default.json`` object.
530 """92 """
531 return load_packaged_json(__package__ or _PACKAGE_NAME, _DEFAULT_CONFIG_NAME)93 return config_loader.load_packaged_json(
94 __package__ or _PACKAGE_NAME, _DEFAULT_CONFIG_NAME
95 )
53296
53397
534def config_from_dict(raw: dict[str, Any]) -> DetectorConfig:98def config_from_dict(raw: dict[str, Any]) -> DetectorConfig:
535 """Build a validated :class:`DetectorConfig` from a raw mapping.99 """Build a validated :class:`DetectorConfig` from a raw mapping.
536100
537 Unknown keys and values that do not fit their declared field type are101 Unknown keys and values that do not fit their declared field type are
538 rejected by :func:`iolabs.common.config_loader.dataclass_from_mapping`;102 rejected by the shared pydantic layer; the band-length rule on
539 the band-length rule below is the one guardrails-specific check that the103 ``residue_lever_band_m`` is the one guardrails-specific check that the
540 declared type ``list[float]`` cannot express.104 declared type ``list[float]`` cannot express.
541105
542 Args:106 Args:
543 raw: Merged config mapping (packaged defaults plus overrides).107 raw: Merged config mapping (packaged defaults plus overrides).
Importance #61: guardrails/config.py @@ -549,30 +113,39 @@
549 DetectorConfigError: ``raw`` holds an unknown key, a value that is not113 DetectorConfigError: ``raw`` holds an unknown key, a value that is not
550 valid for its declared field type, or a ``residue_lever_band_m``114 valid for its declared field type, or a ``residue_lever_band_m``
551 that is not a ``[min_m, max_m]`` pair.115 that is not a ``[min_m, max_m]`` pair.
552 """116 """
553 config = dataclass_from_mapping(117 return config_loader.validate_config(
554 DetectorConfig,118 DetectorConfig,
555 raw,119 raw,
556 context="guardrails config",120 context="guardrails config",
557 error_cls=DetectorConfigError,121 error_cls=DetectorConfigError,
558 )122 )
559 if len(config.residue_lever_band_m) != 2:
560 raise DetectorConfigError(
561 "residue_lever_band_m must contain exactly 2 values: [min_m, max_m]"
562 )
563 return config
564123
565124
566def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig:125def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig:
567 """Load the default config and apply flat ``PATH=VALUE`` overrides.126 """Load the default config and apply flat ``PATH=VALUE`` overrides.
568127
569 Overrides come from the CLI ``--set`` flag (already parsed into a dict).128 Overrides come from the CLI ``--set`` flag (already parsed into a dict).
129
130 Args:
131 overrides: Flat mapping of config key to value, or ``None``.
132
133 Returns:
134 The validated config.
135
136 Raises:
137 DetectorConfigError: An override names an unknown key or holds a value
138 that is not valid for its declared field type.
570 """139 """
571 merged = copy.deepcopy(load_default_config_dict())140 config = config_loader.load_config(
572 for key, value in (overrides or {}).items():141 DetectorConfig,
573 merged[key] = value142 package=__package__ or _PACKAGE_NAME,
574 config = config_from_dict(merged)143 filename=_DEFAULT_CONFIG_NAME,
144 overrides=overrides,
145 context="guardrails config",
146 error_cls=DetectorConfigError,
147 )
575 if overrides:148 if overrides:
576 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))149 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
577 return config150 return config
578151
Importance #62: guardrails/config.py @@ -580,33 +153,23 @@
580def wall_view_config(config: DetectorConfig) -> DetectorConfig:153def wall_view_config(config: DetectorConfig) -> DetectorConfig:
581 """Return a wall-view :class:`DetectorConfig` for the shared fitter.154 """Return a wall-view :class:`DetectorConfig` for the shared fitter.
582155
583 Maps every ``wall_*`` clustering/merge/fit override onto the matching156 Maps every ``wall_*`` clustering/merge/fit override onto the matching
584 guardrail-named field via ``dataclasses.replace``. No other field157 guardrail-named field via ``model_copy``. No other field changes, and the
585 changes, and the source ``config`` is never mutated (frozen dataclass).158 source ``config`` is never mutated (frozen model). This lets
586 This lets ``detect_instances()``/``_fit_instance()`` run unmodified for159 ``detect_instances()``/``_fit_instance()`` run unmodified for walls: only
587 walls: only the config view differs, not the fitter code.160 the config view differs, not the fitter code.
161
162 Args:
163 config: The loaded detector config.
164
165 Returns:
166 A copy whose geometry fields carry the ``wall_*`` values.
588 """167 """
589 return replace(168 return config.model_copy(
590 config,169 update={
591 occupancy_cell_m=config.wall_cell_m,170 target: getattr(config, source) for target, source in _WALL_VIEW_MAP.items()
592 cluster_eps_m=config.wall_cluster_eps_m,171 }
593 cluster_min_samples=config.wall_cluster_min_samples,
594 merge_gap_m=config.wall_merge_gap_m,
595 merge_angle_deg=config.wall_merge_angle_deg,
596 merge_lateral_max_m=config.wall_merge_lateral_max_m,
597 occlusion_bridge_max_m=config.wall_occlusion_bridge_max_m,
598 occlusion_bridge_max_angle_deg=config.wall_occlusion_bridge_max_angle_deg,
599 occlusion_bridge_max_lateral_m=config.wall_occlusion_bridge_max_lateral_m,
600 min_length_m=config.wall_min_length_m,
601 max_local_width_m=config.wall_max_local_width_m,
602 min_longitudinal_coverage=config.wall_min_longitudinal_coverage,
603 max_cluster_height_spread_m=config.wall_max_cluster_height_spread_m,
604 max_cluster_p95_height_m=config.wall_max_cluster_p95_height_m,
605 straightness_window_m=config.wall_straightness_window_m,
606 max_straightness_deviation_m=config.wall_max_straightness_deviation_m,
607 straightness_max_length_m=config.wall_straightness_max_length_m,
608 polyline_smooth_window=config.wall_polyline_smooth_window,
609 )172 )
610173
611174
612def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]:175def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]:
Importance #63: guardrails/config.py @@ -620,5 +183,7 @@
620183
621 Raises:184 Raises:
622 DetectorConfigError: An override is missing its ``=``.185 DetectorConfigError: An override is missing its ``=``.
623 """186 """
624 return _parse_set_overrides(raw_overrides, error_cls=DetectorConfigError)187 return config_loader.parse_set_overrides(
188 raw_overrides, error_cls=DetectorConfigError
189 )
Importance #64: guardrails/lane_xml.py @@ -442,14 +442,17 @@
442 max_carriageway_width_m: float = 15.0,442 max_carriageway_width_m: float = 15.0,
443) -> LateralZoneModel:443) -> LateralZoneModel:
444 """Project segment-local XML edge samples into spine station/offset bins."""444 """Project segment-local XML edge samples into spine station/offset bins."""
445 projected: list[tuple[str, int, np.ndarray, np.ndarray]] = []445 projected: list[tuple[str, int, np.ndarray, np.ndarray]] = []
446 # ``spine.project`` only reads immutable defaults here, so one shared
447 # instance serves every edge (building one per edge validates 200+ fields).
448 project_config = DetectorConfig()
446 for edge_index, edge in enumerate(edges):449 for edge_index, edge in enumerate(edges):
447 cropped = _crop_xy(_densify(edge.xy), segment_bbox)450 cropped = _crop_xy(_densify(edge.xy), segment_bbox)
448 if not len(cropped):451 if not len(cropped):
449 continue452 continue
450 stations, offsets = spine.project(453 stations, offsets = spine.project(
451 np.column_stack((cropped, np.zeros(len(cropped)))), DetectorConfig()454 np.column_stack((cropped, np.zeros(len(cropped)))), project_config
452 )455 )
453 projected.append((edge.lane_id, edge_index, stations, offsets))456 projected.append((edge.lane_id, edge_index, stations, offsets))
454 if not projected:457 if not projected:
455 raise ValueError("Lane XML contains no edge geometry inside the segment bbox")458 raise ValueError("Lane XML contains no edge geometry inside the segment bbox")
Importance #65: guardrails/outputs.py @@ -7,9 +7,8 @@
7"""7"""
88
9import gc9import gc
10import logging10import logging
11from dataclasses import replace
12from pathlib import Path11from pathlib import Path
1312
14import numpy as np13import numpy as np
15from iolabs.common.point_masks_io import write_point_masks14from iolabs.common.point_masks_io import write_point_masks
Importance #66: guardrails/outputs.py @@ -83,9 +82,9 @@
83 widen_low_band = collect_height_station and (82 widen_low_band = collect_height_station and (
84 config.post_low_band_min_m < config.min_height_m83 config.post_low_band_min_m < config.min_height_m
85 )84 )
86 replay_config = (85 replay_config = (
87 replace(config, min_height_m=config.post_low_band_min_m)86 config.model_copy(update={"min_height_m": config.post_low_band_min_m})
88 if widen_low_band87 if widen_low_band
89 else config88 else config
90 )89 )
91 min_height_m = replay_config.min_height_m90 min_height_m = replay_config.min_height_m
Importance #67: pyproject.toml @@ -1,16 +1,17 @@
1[project]1[project]
2name = "guardrails"2name = "guardrails"
3version = "0.4.0"3version = "0.4.1"
4description = "Classical geometric guardrail detection in MLS LiDAR point clouds"4description = "Classical geometric guardrail detection in MLS LiDAR point clouds"
5readme = "README.md"5readme = "README.md"
6requires-python = ">=3.11"6requires-python = ">=3.11"
7dependencies = [7dependencies = [
8 "numpy>=2.0",8 "numpy>=2.0",
9 "pillow>=10.0",9 "pillow>=10.0",
10 "scikit-learn>=1.5",10 "scikit-learn>=1.5",
11 "scipy>=1.13",11 "scipy>=1.13",
12 "iolabs-common>=0.7.0",12 "iolabs-common>=0.8.0",
13 "pydantic>=2.7",
13 "iolabs-geometry-geometry>=0.11.0",14 "iolabs-geometry-geometry>=0.11.0",
14 "iolabs-geometry-raster>=0.2.0",15 "iolabs-geometry-raster>=0.2.0",
15 "iolabs-geometry-visualization>=0.7.0",16 "iolabs-geometry-visualization>=0.7.0",
16 "iolabs-point-cloud-modelling-export",17 "iolabs-point-cloud-modelling-export",
Importance #68: tests/test_config.py @@ -1,6 +1,5 @@
1import argparse1import argparse
2from dataclasses import asdict, fields, replace
3from pathlib import Path2from pathlib import Path
43
5import pytest4import pytest
65
Importance #69: tests/test_config.py @@ -37,11 +36,11 @@
37 "polyline_smooth_window": "wall_polyline_smooth_window",36 "polyline_smooth_window": "wall_polyline_smooth_window",
38}37}
3938
4039
41def test_default_json_matches_dataclass_defaults() -> None:40def test_default_json_matches_model_defaults() -> None:
42 """guardrails.default.json is the schema source of truth; keep it in sync."""41 """guardrails.default.json is the schema source of truth; keep it in sync."""
43 defaults = asdict(DetectorConfig())42 defaults = DetectorConfig().model_dump()
44 json_config = load_default_config_dict()43 json_config = load_default_config_dict()
45 assert set(json_config) == set(defaults)44 assert set(json_config) == set(defaults)
46 for key, value in defaults.items():45 for key, value in defaults.items():
47 assert json_config[key] == value, key46 assert json_config[key] == value, key
Importance #70: tests/test_config.py @@ -135,9 +134,9 @@
135 "wall_max_straightness_deviation_m": 0.45,134 "wall_max_straightness_deviation_m": 0.45,
136 "wall_straightness_max_length_m": 30.0,135 "wall_straightness_max_length_m": 30.0,
137 "wall_polyline_smooth_window": 11,136 "wall_polyline_smooth_window": 11,
138 }137 }
139 source = replace(DetectorConfig(), **nondefault_wall_values)138 source = DetectorConfig(**nondefault_wall_values)
140 result = wall_view_config(source)139 result = wall_view_config(source)
141140
142 # Every mapped field changed in the returned config to the nondefault141 # Every mapped field changed in the returned config to the nondefault
143 # wall_* value, and differs from the (untouched) source guardrail field.142 # wall_* value, and differs from the (untouched) source guardrail field.
Importance #71: tests/test_config.py @@ -145,15 +144,15 @@
145 expected = nondefault_wall_values[wall_field]144 expected = nondefault_wall_values[wall_field]
146 assert getattr(result, target_field) == expected, target_field145 assert getattr(result, target_field) == expected, target_field
147 assert getattr(result, target_field) != getattr(source, target_field), target_field146 assert getattr(result, target_field) != getattr(source, target_field), target_field
148147
149 # Source config is untouched (frozen dataclass; replace() never mutates).148 # Source config is untouched (frozen model; model_copy never mutates).
150 for wall_field, value in nondefault_wall_values.items():149 for wall_field, value in nondefault_wall_values.items():
151 assert getattr(source, wall_field) == value150 assert getattr(source, wall_field) == value
152151
153 # Every unrelated (unmapped) field is identical between source and result.152 # Every unrelated (unmapped) field is identical between source and result.
154 mapped_targets = set(_WALL_VIEW_FIELD_MAP)153 mapped_targets = set(_WALL_VIEW_FIELD_MAP)
155 for field in fields(DetectorConfig):154 for field_name in DetectorConfig.model_fields:
156 if field.name in mapped_targets:155 if field_name in mapped_targets:
157 continue156 continue
158 assert getattr(result, field.name) == getattr(source, field.name), field.name157 assert getattr(result, field_name) == getattr(source, field_name), field_name
159158
Importance #72: tests/test_detect_wall_integration.py @@ -1,6 +1,6 @@
11
2from dataclasses import fields, replace2from dataclasses import fields
3from pathlib import Path3from pathlib import Path
4from unittest.mock import call4from unittest.mock import call
55
6import numpy as np6import numpy as np
Importance #73: tests/test_detect_wall_integration.py @@ -66,9 +66,9 @@
66 result = _accumulate_candidates(66 result = _accumulate_candidates(
67 [points_path],67 [points_path],
68 _FlatGround(),68 _FlatGround(),
69 _frame(),69 _frame(),
70 replace(DetectorConfig(), wall_detection_enabled=False),70 DetectorConfig(wall_detection_enabled=False),
71 spine=_straight_spine(),71 spine=_straight_spine(),
72 segment_index=0,72 segment_index=0,
73 )73 )
7474
Importance #74: tests/test_detect_wall_integration.py @@ -77,10 +77,9 @@
7777
78def test_wall_accumulator_is_chunk_independent_and_grid_bounded(tmp_path: Path) -> None:78def test_wall_accumulator_is_chunk_independent_and_grid_bounded(tmp_path: Path) -> None:
79 points_path = tmp_path / "Record000_run3_points.npz"79 points_path = tmp_path / "Record000_run3_points.npz"
80 _write_points(points_path, _wall_points())80 _write_points(points_path, _wall_points())
81 base = replace(81 base = DetectorConfig(
82 DetectorConfig(),
83 wall_detection_enabled=True,82 wall_detection_enabled=True,
84 wall_cell_m=1.0,83 wall_cell_m=1.0,
85 wall_height_bin_m=0.25,84 wall_height_bin_m=0.25,
86 )85 )
Importance #75: tests/test_detect_wall_integration.py @@ -88,17 +87,17 @@
88 whole = _accumulate_candidates(87 whole = _accumulate_candidates(
89 [points_path],88 [points_path],
90 _FlatGround(),89 _FlatGround(),
91 _frame(),90 _frame(),
92 replace(base, record_chunk_points=10_000),91 base.model_copy(update={"record_chunk_points": 10_000}),
93 spine=_straight_spine(),92 spine=_straight_spine(),
94 segment_index=0,93 segment_index=0,
95 ).wall_evidence94 ).wall_evidence
96 chunked = _accumulate_candidates(95 chunked = _accumulate_candidates(
97 [points_path],96 [points_path],
98 _FlatGround(),97 _FlatGround(),
99 _frame(),98 _frame(),
100 replace(base, record_chunk_points=2),99 base.model_copy(update={"record_chunk_points": 2}),
101 spine=_straight_spine(),100 spine=_straight_spine(),
102 segment_index=0,101 segment_index=0,
103 ).wall_evidence102 ).wall_evidence
104103
Importance #76: tests/test_detect_wall_integration.py @@ -222,10 +221,9 @@
222 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))221 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))
223 for suffix in (".json", "_rgb.png", "_intensity.png"):222 for suffix in (".json", "_rgb.png", "_intensity.png"):
224 (tile_dir / f"segment_000{suffix}").touch()223 (tile_dir / f"segment_000{suffix}").touch()
225224
226 config = replace(225 config = DetectorConfig(
227 DetectorConfig(),
228 wall_detection_enabled=wall_enabled,226 wall_detection_enabled=wall_enabled,
229 lane_xml_zones_enabled=False,227 lane_xml_zones_enabled=False,
230 precision_gate_enabled=False,228 precision_gate_enabled=False,
231 )229 )
Importance #77: tests/test_detect_wall_integration.py @@ -346,10 +344,9 @@
346 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))344 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))
347 for suffix in (".json", "_rgb.png", "_intensity.png"):345 for suffix in (".json", "_rgb.png", "_intensity.png"):
348 (tile_dir / f"segment_000{suffix}").touch()346 (tile_dir / f"segment_000{suffix}").touch()
349347
350 config = replace(348 config = DetectorConfig(
351 DetectorConfig(),
352 lane_xml_zones_enabled=False,349 lane_xml_zones_enabled=False,
353 precision_gate_enabled=False,350 precision_gate_enabled=False,
354 )351 )
355 frame = _frame()352 frame = _frame()
Importance #78: tests/test_detect_wall_integration.py @@ -454,10 +451,9 @@
454 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))451 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))
455 for suffix in (".json", "_rgb.png", "_intensity.png"):452 for suffix in (".json", "_rgb.png", "_intensity.png"):
456 (tile_dir / f"segment_000{suffix}").touch()453 (tile_dir / f"segment_000{suffix}").touch()
457454
458 config = replace(455 config = DetectorConfig(
459 DetectorConfig(),
460 lane_xml_zones_enabled=False,456 lane_xml_zones_enabled=False,
461 precision_gate_enabled=False,457 precision_gate_enabled=False,
462 )458 )
463 frame = _frame()459 frame = _frame()
Importance #79: tests/test_detect_wall_integration.py @@ -555,10 +551,9 @@
555 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))551 _write_points(points_path, np.array([[1.0, 1.0, 0.0]]))
556 for suffix in (".json", "_rgb.png", "_intensity.png"):552 for suffix in (".json", "_rgb.png", "_intensity.png"):
557 (tile_dir / f"segment_000{suffix}").touch()553 (tile_dir / f"segment_000{suffix}").touch()
558554
559 config = replace(555 config = DetectorConfig(
560 DetectorConfig(),
561 lane_xml_zones_enabled=False,556 lane_xml_zones_enabled=False,
562 precision_gate_enabled=False,557 precision_gate_enabled=False,
563 )558 )
564 frame = _frame()559 frame = _frame()
Importance #80: tests/test_detect_wall_integration.py @@ -734,19 +729,17 @@
734 (tmp_path / output_name / "segment_000" / "guardrails.json").read_text()729 (tmp_path / output_name / "segment_000" / "guardrails.json").read_text()
735 )["walls"]730 )["walls"]
736731
737 zones_off = run(732 zones_off = run(
738 replace(733 DetectorConfig(
739 DetectorConfig(),
740 wall_detection_enabled=True,734 wall_detection_enabled=True,
741 lane_xml_zones_enabled=False,735 lane_xml_zones_enabled=False,
742 precision_gate_enabled=False,736 precision_gate_enabled=False,
743 ),737 ),
744 "zones_off",738 "zones_off",
745 )739 )
746 zones_on = run(740 zones_on = run(
747 replace(741 DetectorConfig(
748 DetectorConfig(),
749 wall_detection_enabled=True,742 wall_detection_enabled=True,
750 lane_xml_zones_enabled=True,743 lane_xml_zones_enabled=True,
751 precision_gate_enabled=False,744 precision_gate_enabled=False,
752 lane_xml_path=str(Path(__file__).parent / "fixtures" / "mini_lanes.xml"),745 lane_xml_path=str(Path(__file__).parent / "fixtures" / "mini_lanes.xml"),
Importance #81: tests/test_edge_gate.py @@ -5,9 +5,8 @@
5 C ramp outer y=-50, C ramp inner y=-38 (x = 40..80)5 C ramp outer y=-50, C ramp inner y=-38 (x = 40..80)
6The spine is the straight x-axis at y=0, so spine offset == world y.6The spine is the straight x-axis at y=0, so spine offset == world y.
7"""7"""
88
9from dataclasses import replace
10from pathlib import Path9from pathlib import Path
1110
12import numpy as np11import numpy as np
13import pytest12import pytest
Importance #82: tests/test_edge_gate.py @@ -116,9 +115,9 @@
116 assert index.tree.n == len(index.edge_xy)115 assert index.tree.n == len(index.edge_xy)
117116
118117
119def test_build_edge_index_none_when_no_edge_intersects_bbox() -> None:118def test_build_edge_index_none_when_no_edge_intersects_bbox() -> None:
120 config = replace(DetectorConfig(), zone_bbox_margin_m=1.0)119 config = DetectorConfig(zone_bbox_margin_m=1.0)
121120
122 index = edge_gate.build_edge_index(121 index = edge_gate.build_edge_index(
123 _lane_data(),122 _lane_data(),
124 segment_bbox=(5000.0, 5000.0, 5100.0, 5100.0),123 segment_bbox=(5000.0, 5000.0, 5100.0, 5100.0),
Importance #83: tests/test_edge_gate.py @@ -229,9 +228,9 @@
229 assert exclusions == []228 assert exclusions == []
230229
231230
232def test_e1_threshold_is_config_driven() -> None:231def test_e1_threshold_is_config_driven() -> None:
233 config = replace(DetectorConfig(), edge_gate_max_rail_distance_m=30.0)232 config = DetectorConfig(edge_gate_max_rail_distance_m=30.0)
234233
235 kept, exclusions = _apply(234 kept, exclusions = _apply(
236 [_run(-25.0, x0=45.0, x1=75.0)],235 [_run(-25.0, x0=45.0, x1=75.0)],
237 config=config,236 config=config,
Importance #84: tests/test_edge_gate.py @@ -248,9 +247,9 @@
248 ys = np.where(xs < 50.0, 5.0, A_INNER_Y + 4.0)247 ys = np.where(xs < 50.0, 5.0, A_INNER_Y + 4.0)
249 run = _run(0.0)248 run = _run(0.0)
250 run["polyline"] = np.column_stack([xs, ys, np.zeros(len(xs))]).tolist()249 run["polyline"] = np.column_stack([xs, ys, np.zeros(len(xs))]).tolist()
251250
252 lenient = replace(DetectorConfig(), edge_gate_interior_max_frac=0.75)251 lenient = DetectorConfig(edge_gate_interior_max_frac=0.75)
253 kept_lenient, excluded_lenient = _apply([dict(run)], config=lenient)252 kept_lenient, excluded_lenient = _apply([dict(run)], config=lenient)
254 kept_strict, excluded_strict = _apply([dict(run)], config=DetectorConfig())253 kept_strict, excluded_strict = _apply([dict(run)], config=DetectorConfig())
255254
256 assert len(kept_lenient) == 1255 assert len(kept_lenient) == 1
Importance #85: tests/test_edge_gate.py @@ -290,9 +289,9 @@
290 assert exclusions == []289 assert exclusions == []
291290
292291
293def test_disabled_gate_keeps_every_run_untouched() -> None:292def test_disabled_gate_keeps_every_run_untouched() -> None:
294 config = replace(DetectorConfig(), edge_gate_enabled=False)293 config = DetectorConfig(edge_gate_enabled=False)
295 runs = [_run(5.0, run_id=1), _run(-25.0, run_id=2, x0=45.0, x1=75.0)]294 runs = [_run(5.0, run_id=1), _run(-25.0, run_id=2, x0=45.0, x1=75.0)]
296295
297 kept, exclusions = _apply(runs, config=config)296 kept, exclusions = _apply(runs, config=config)
298297
Importance #86: tests/test_posts.py @@ -7,9 +7,8 @@
77
8import json8import json
9import logging9import logging
10import math10import math
11from dataclasses import replace
12from types import SimpleNamespace11from types import SimpleNamespace
1312
14import numpy as np13import numpy as np
15import pytest14import pytest
Importance #87: tests/test_posts.py @@ -1717,9 +1716,9 @@
1717 np.testing.assert_array_equal(out, np.array([2, 0], dtype=np.int32))1716 np.testing.assert_array_equal(out, np.array([2, 0], dtype=np.int32))
17181717
17191718
1720def test_build_support_claim_honours_the_behind_beam_kill_switch() -> None:1719def test_build_support_claim_honours_the_behind_beam_kill_switch() -> None:
1721 config = replace(DetectorConfig(), post_claim_behind_beam=False)1720 config = DetectorConfig(post_claim_behind_beam=False)
1722 claim = build_support_claim(1721 claim = build_support_claim(
1723 _measured_parent(0, bottom_height=0.55), _support_dict([0.0, 2.0]), config1722 _measured_parent(0, bottom_height=0.55), _support_dict([0.0, 2.0]), config
1724 )1723 )
1725 assert claim.behind_beam_min_dist_m is None1724 assert claim.behind_beam_min_dist_m is None
Importance #88: tests/test_precision_gate.py @@ -1,7 +1,6 @@
1import copy1import copy
2import json2import json
3from dataclasses import replace
4from pathlib import Path3from pathlib import Path
54
6import numpy as np5import numpy as np
7import pytest6import pytest
Importance #89: tests/test_precision_gate.py @@ -350,9 +349,9 @@
350 run_overrides: dict[str, object],349 run_overrides: dict[str, object],
351 metric_overrides: dict[str, object],350 metric_overrides: dict[str, object],
352 miss_overrides: dict[str, object],351 miss_overrides: dict[str, object],
353) -> None:352) -> None:
354 config = replace(DetectorConfig(), precision_gate_enabled=True)353 config = DetectorConfig(precision_gate_enabled=True)
355 run = _base_run(**run_overrides)354 run = _base_run(**run_overrides)
356 hit_metrics = _base_metrics(**metric_overrides)355 hit_metrics = _base_metrics(**metric_overrides)
357 miss_metrics = _base_metrics(**(metric_overrides | miss_overrides))356 miss_metrics = _base_metrics(**(metric_overrides | miss_overrides))
358357
Importance #90: tests/test_precision_gate.py @@ -363,9 +362,9 @@
363 assert detect._precision_rule_for_metrics(run, miss_metrics, config, kind=kind) is None362 assert detect._precision_rule_for_metrics(run, miss_metrics, config, kind=kind) is None
364363
365364
366def test_precision_walls_only_use_g0() -> None:365def test_precision_walls_only_use_g0() -> None:
367 config = replace(DetectorConfig(), precision_gate_enabled=True)366 config = DetectorConfig(precision_gate_enabled=True)
368 vehicle_wall = _base_run(length_m=10.0, mean_height_m=1.0)367 vehicle_wall = _base_run(length_m=10.0, mean_height_m=1.0)
369 metrics = _base_metrics(density_per_m=1000.0)368 metrics = _base_metrics(density_per_m=1000.0)
370369
371 assert (370 assert (
Importance #91: tests/test_precision_gate.py @@ -423,9 +422,9 @@
423 return rails, walls422 return rails, walls
424423
425424
426def test_precision_gate_integration_attribution_schema_and_stable_ids() -> None:425def test_precision_gate_integration_attribution_schema_and_stable_ids() -> None:
427 config = replace(DetectorConfig(), precision_gate_enabled=True)426 config = DetectorConfig(precision_gate_enabled=True)
428 rails, walls = _integration_records()427 rails, walls = _integration_records()
429 exclusions: list[dict[str, object]] = []428 exclusions: list[dict[str, object]] = []
430429
431 kept_rails, kept_walls = detect._apply_precision_gate(430 kept_rails, kept_walls = detect._apply_precision_gate(
Importance #92: tests/test_precision_gate.py @@ -489,9 +488,9 @@
489 assert index is None488 assert index is None
490489
491490
492def test_precision_gate_enabled_defaults_keep_without_evidence_index() -> None:491def test_precision_gate_enabled_defaults_keep_without_evidence_index() -> None:
493 config = replace(DetectorConfig(), precision_gate_enabled=True)492 config = DetectorConfig(precision_gate_enabled=True)
494 rails, walls = _integration_records()493 rails, walls = _integration_records()
495 exclusions: list[dict[str, object]] = []494 exclusions: list[dict[str, object]] = []
496 before = copy.deepcopy((rails, walls))495 before = copy.deepcopy((rails, walls))
497496
Importance #93: tests/test_precision_gate.py @@ -510,9 +509,9 @@
510 assert exclusions == []509 assert exclusions == []
511510
512511
513def test_precision_gate_disabled_is_byte_identical_and_skips_evidence() -> None:512def test_precision_gate_disabled_is_byte_identical_and_skips_evidence() -> None:
514 config = replace(DetectorConfig(), precision_gate_enabled=False)513 config = DetectorConfig(precision_gate_enabled=False)
515 rails, walls = _integration_records()514 rails, walls = _integration_records()
516 exclusions = [{"reason": "existing", "source_id": 42}]515 exclusions = [{"reason": "existing", "source_id": 42}]
517 before = json.dumps(516 before = json.dumps(
518 {"guardrails": rails, "walls": walls, "corridor_exclusions": exclusions},517 {"guardrails": rails, "walls": walls, "corridor_exclusions": exclusions},
Importance #94: tests/test_support_class.py @@ -5,9 +5,8 @@
5pattern as ``tests/test_point_masks.py::test_collect_point_masks_bounded_filtering``),5pattern as ``tests/test_point_masks.py::test_collect_point_masks_bounded_filtering``),
6so a point's (station, offset, height) is simply its (x, y, z).6so a point's (station, offset, height) is simply its (x, y, z).
7"""7"""
88
9from dataclasses import replace
10from types import SimpleNamespace9from types import SimpleNamespace
1110
12import numpy as np11import numpy as np
13import pytest12import pytest
Importance #95: tests/test_support_class.py @@ -576,11 +575,9 @@
576 low = np.column_stack(575 low = np.column_stack(
577 [np.arange(6) * 0.001 + 0.15, np.full(6, 0.05), np.full(6, 0.15)]576 [np.arange(6) * 0.001 + 0.15, np.full(6, 0.05), np.full(6, 0.15)]
578 )577 )
579 call = _masks_fixture(tmp_path, monkeypatch, np.vstack([base, low]))578 call = _masks_fixture(tmp_path, monkeypatch, np.vstack([base, low]))
580 config = replace(579 config = DetectorConfig(decimation_enabled=True, decimation_density_cap=3)
581 DetectorConfig(), decimation_enabled=True, decimation_density_cap=3
582 )
583580
584 record_id, point_index, _instance_id = call(config)581 record_id, point_index, _instance_id = call(config)
585 rec_w, idx_w, _inst_w, _height_w, _station_w, _z_w = call(582 rec_w, idx_w, _inst_w, _height_w, _station_w, _z_w = call(
586 config, collect_height_station=True583 config, collect_height_station=True
Importance #96: tests/test_support_class.py @@ -1036,9 +1033,9 @@
1036 )1033 )
1037 assert list(idx) == [0], "only the return behind the beam is post shaft"1034 assert list(idx) == [0], "only the return behind the beam is post shaft"
1038 assert list(inst) == [1]1035 assert list(inst) == [1]
10391036
1040 off = replace(config, post_claim_behind_beam=False)1037 off = config.model_copy(update={"post_claim_behind_beam": False})
1041 blind_claim = build_support_claim(parent, support, off)1038 blind_claim = build_support_claim(parent, support, off)
1042 _rec, idx_off, _inst_off, _h, _s, _z = call(1039 _rec, idx_off, _inst_off, _h, _s, _z = call(
1043 off, collect_height_station=True, support_posts={1: blind_claim}1040 off, collect_height_station=True, support_posts={1: blind_claim}
1044 )1041 )
Importance #97: tests/test_support_class.py @@ -1116,11 +1113,9 @@
1116 low = np.column_stack(1113 low = np.column_stack(
1117 [np.arange(6) * 0.001 + 0.15, np.full(6, 0.05), np.full(6, 0.52)]1114 [np.arange(6) * 0.001 + 0.15, np.full(6, 0.05), np.full(6, 0.52)]
1118 )1115 )
1119 call = _masks_fixture(tmp_path, monkeypatch, np.vstack([base, low]))1116 call = _masks_fixture(tmp_path, monkeypatch, np.vstack([base, low]))
1120 config = replace(1117 config = DetectorConfig(decimation_enabled=True, decimation_density_cap=3)
1121 DetectorConfig(), decimation_enabled=True, decimation_density_cap=3
1122 )
1123 claim = build_support_claim(1118 claim = build_support_claim(
1124 {1119 {
1125 "polyline_station_m": [0.0, 1.0],1120 "polyline_station_m": [0.0, 1.0],
1126 "polyline_ground_z_m": [0.0, 0.0],1121 "polyline_ground_z_m": [0.0, 0.0],
Importance #98: tests/test_support_class.py @@ -1209,10 +1204,9 @@
1209 _write_record(segment_dir, points, name="Record000_run3_points.npz")1204 _write_record(segment_dir, points, name="Record000_run3_points.npz")
1210 for suffix in (".json", "_rgb.png", "_intensity.png"):1205 for suffix in (".json", "_rgb.png", "_intensity.png"):
1211 (tile_dir / f"segment_000{suffix}").touch()1206 (tile_dir / f"segment_000{suffix}").touch()
12121207
1213 config = replace(1208 config = DetectorConfig(
1214 DetectorConfig(),
1215 wall_detection_enabled=False,1209 wall_detection_enabled=False,
1216 lane_xml_zones_enabled=False,1210 lane_xml_zones_enabled=False,
1217 precision_gate_enabled=False,1211 precision_gate_enabled=False,
1218 **overrides,1212 **overrides,
Importance #99: tests/test_support_class.py @@ -1562,9 +1556,9 @@
1562 slope = float(np.polyfit(stations, laterals, 1)[0])1556 slope = float(np.polyfit(stations, laterals, 1)[0])
1563 assert abs(slope) < 0.002, f"post lateral still trends at {slope:.4f} m/m"1557 assert abs(slope) < 0.002, f"post lateral still trends at {slope:.4f} m/m"
15641558
1565 # ... and the round-6 behaviour is what the kill switch restores.1559 # ... and the round-6 behaviour is what the kill switch restores.
1566 off = replace(config, post_xy_local_offset_enabled=False)1560 off = config.model_copy(update={"post_xy_local_offset_enabled": False})
1567 old_xy = posts._post_world_xy(parent, stations, offsets, off)1561 old_xy = posts._post_world_xy(parent, stations, offsets, off)
1568 old_laterals = posts._post_lateral_offsets(parent, old_xy, stations)1562 old_laterals = posts._post_lateral_offsets(parent, old_xy, stations)
1569 old_slope = float(np.polyfit(stations, old_laterals, 1)[0])1563 old_slope = float(np.polyfit(stations, old_laterals, 1)[0])
1570 assert old_slope == pytest.approx(0.02, abs=0.002)1564 assert old_slope == pytest.approx(0.02, abs=0.002)
Importance #100: tests/test_support_class.py @@ -1578,9 +1572,12 @@
1578 stations = np.linspace(1.0, 39.0, 20)1572 stations = np.linspace(1.0, 39.0, 20)
1579 offsets = np.full(stations.size, 4.30)1573 offsets = np.full(stations.size, 4.30)
1580 new_xy = posts._post_world_xy(parent, stations, offsets, config)1574 new_xy = posts._post_world_xy(parent, stations, offsets, config)
1581 old_xy = posts._post_world_xy(1575 old_xy = posts._post_world_xy(
1582 parent, stations, offsets, replace(config, post_xy_local_offset_enabled=False)1576 parent,
1577 stations,
1578 offsets,
1579 config.model_copy(update={"post_xy_local_offset_enabled": False}),
1583 )1580 )
1584 np.testing.assert_allclose(new_xy, old_xy, atol=1e-12)1581 np.testing.assert_allclose(new_xy, old_xy, atol=1e-12)
15851582
15861583
Importance #101: tests/test_support_class.py @@ -1592,9 +1589,12 @@
1592 stations = np.linspace(1.0, 39.0, 20)1589 stations = np.linspace(1.0, 39.0, 20)
1593 offsets = np.full(stations.size, 4.70)1590 offsets = np.full(stations.size, 4.70)
1594 xy = posts._post_world_xy(parent, stations, offsets, config)1591 xy = posts._post_world_xy(parent, stations, offsets, config)
1595 expected = posts._post_world_xy(1592 expected = posts._post_world_xy(
1596 parent, stations, offsets, replace(config, post_xy_local_offset_enabled=False)1593 parent,
1594 stations,
1595 offsets,
1596 config.model_copy(update={"post_xy_local_offset_enabled": False}),
1597 )1597 )
1598 np.testing.assert_allclose(xy, expected, atol=1e-12)1598 np.testing.assert_allclose(xy, expected, atol=1e-12)
15991599
16001600
Importance #102: tests/test_top_member.py @@ -238,9 +238,11 @@
238 }238 }
239239
240 on, off = _rail(), _rail()240 on, off = _rail(), _rail()
241 posts.attach_beam_bottom([on], evidence, config)241 posts.attach_beam_bottom([on], evidence, config)
242 posts.attach_beam_bottom([off], evidence, replace(config, post_beam_top_enabled=False))242 posts.attach_beam_bottom(
243 [off], evidence, config.model_copy(update={"post_beam_top_enabled": False})
244 )
243245
244 assert on["beam_bottom"]["top_height_m"] == pytest.approx(0.775)246 assert on["beam_bottom"]["top_height_m"] == pytest.approx(0.775)
245 assert on["beam_bottom"]["top_measured"] is True247 assert on["beam_bottom"]["top_measured"] is True
246 assert on["polyline_beam_top_z_m"] == [100.775, 100.775]248 assert on["polyline_beam_top_z_m"] == [100.775, 100.775]
Importance #103: tests/test_top_member.py @@ -261,9 +263,9 @@
261 would leave ``detect_top_member`` and the shaft cap working off a number263 would leave ``detect_top_member`` and the shaft cap working off a number
262 nothing downstream can see -- and the shaft cap would silently fall back to264 nothing downstream can see -- and the shaft cap would silently fall back to
263 ``polyline_top_z_m``, a median column height that sits INSIDE the beam.265 ``polyline_top_z_m``, a median column height that sits INSIDE the beam.
264 """266 """
265 config = replace(DetectorConfig(), post_beam_top_enabled=False)267 config = DetectorConfig(post_beam_top_enabled=False)
266 counts = _band_counts(268 counts = _band_counts(
267 config, (0.10, 0.45, 300), (0.45, 0.775, 5000), (0.80, 0.975, 4000)269 config, (0.10, 0.45, 300), (0.45, 0.775, 5000), (0.80, 0.975, 4000)
268 )270 )
269 evidence = _height_evidence(counts, config)271 evidence = _height_evidence(counts, config)
Importance #104: tests/test_top_member.py @@ -504,9 +506,9 @@
504506
505507
506def test_top_member_is_gated_off_by_its_flag() -> None:508def test_top_member_is_gated_off_by_its_flag() -> None:
507 """``post_top_member_enabled=False`` writes nothing at all."""509 """``post_top_member_enabled=False`` writes nothing at all."""
508 config = replace(DetectorConfig(), post_top_member_enabled=False)510 config = DetectorConfig(post_top_member_enabled=False)
509 evidence = _rail_shape(config, beam=(0.45, 0.775, 180), member=(0.80, 0.975, 170))511 evidence = _rail_shape(config, beam=(0.45, 0.775, 180), member=(0.80, 0.975, 170))
510 rails = [{"id": 0, "polyline": [[0.0, 4.0], [20.0, 4.0]],512 rails = [{"id": 0, "polyline": [[0.0, 4.0], [20.0, 4.0]],
511 "polyline_station_m": [0.0, 20.0], "polyline_ground_z_m": [0.0, 0.0]}]513 "polyline_station_m": [0.0, 20.0], "polyline_ground_z_m": [0.0, 0.0]}]
512 members = posts.attach_top_members(514 members = posts.attach_top_members(
Importance #105: tests/test_top_member.py @@ -559,9 +561,11 @@
559 assert behind is not None and bool(behind[0]) is True561 assert behind is not None and bool(behind[0]) is True
560 assert bool(claim.behind_beam(post_xy - np.array([0.0, 0.50]), 0)[0]) is False562 assert bool(claim.behind_beam(post_xy - np.array([0.0, 0.50]), 0)[0]) is False
561563
562 off = posts.build_support_claim(564 off = posts.build_support_claim(
563 rail, support, replace(config, post_behind_beam_use_measured_side=False)565 rail,
566 support,
567 config.model_copy(update={"post_behind_beam_use_measured_side": False}),
564 )568 )
565 assert off.beam_outward_xy[0][1] == pytest.approx(-1.0)569 assert off.beam_outward_xy[0][1] == pytest.approx(-1.0)
566570
567571
Importance #106: tests/test_top_member.py @@ -625,9 +629,9 @@
625 post_xy = np.asarray(support["polyline"], dtype=float)629 post_xy = np.asarray(support["polyline"], dtype=float)
626 column = post_xy[0] + np.array([0.0, -0.08]) # 0.17 m off the rail line630 column = post_xy[0] + np.array([0.0, -0.08]) # 0.17 m off the rail line
627 assert bool(claim.behind_beam(column[None, :], 0)[0]) is True631 assert bool(claim.behind_beam(column[None, :], 0)[0]) is True
628 old = posts.build_support_claim(632 old = posts.build_support_claim(
629 rail, support, replace(config, post_behind_beam_front_margin_m=0.0)633 rail, support, config.model_copy(update={"post_behind_beam_front_margin_m": 0.0})
630 )634 )
631 assert old.behind_beam_min_dist_m == pytest.approx(0.25)635 assert old.behind_beam_min_dist_m == pytest.approx(0.25)
632 assert bool(old.behind_beam(column[None, :], 0)[0]) is False636 assert bool(old.behind_beam(column[None, :], 0)[0]) is False
633637
Importance #107: tests/test_top_member.py @@ -651,9 +655,11 @@
651 assert claim.behind_beam_min_dist_m == pytest.approx(655 assert claim.behind_beam_min_dist_m == pytest.approx(
652 config.post_behind_beam_offset_m656 config.post_behind_beam_offset_m
653 )657 )
654 off = posts.build_support_claim(658 off = posts.build_support_claim(
655 rail, support, replace(config, post_behind_beam_use_measured_side=False)659 rail,
660 support,
661 config.model_copy(update={"post_behind_beam_use_measured_side": False}),
656 )662 )
657 assert off.behind_beam_min_dist_m == pytest.approx(663 assert off.behind_beam_min_dist_m == pytest.approx(
658 config.post_behind_beam_offset_m664 config.post_behind_beam_offset_m
659 )665 )
Importance #108: tests/test_top_member.py @@ -859,9 +865,9 @@
859 member_lateral_m=0.25,865 member_lateral_m=0.25,
860 )866 )
861 beam = measure_beam_bottom(evidence, config)867 beam = measure_beam_bottom(evidence, config)
862 assert posts.detect_top_member(evidence, beam, 0.25, 2, config).present is True868 assert posts.detect_top_member(evidence, beam, 0.25, 2, config).present is True
863 strict = replace(config, post_top_member_min_posts=4)869 strict = config.model_copy(update={"post_top_member_min_posts": 4})
864 rejected = posts.detect_top_member(evidence, beam, 0.25, 2, strict)870 rejected = posts.detect_top_member(evidence, beam, 0.25, 2, strict)
865 assert rejected.present is False and rejected.reason == "no_posts"871 assert rejected.present is False and rejected.reason == "no_posts"
866 assert posts.detect_top_member(evidence, beam, 0.25, 4, strict).present is True872 assert posts.detect_top_member(evidence, beam, 0.25, 4, strict).present is True
867873
Importance #109: tests/test_top_member.py @@ -876,9 +882,9 @@
876882
877883
878def test_top_member_routing_emits_no_companion_but_still_claims() -> None:884def test_top_member_routing_emits_no_companion_but_still_claims() -> None:
879 """"guardrail_support" / "w_beam" route the rows without a new instance."""885 """"guardrail_support" / "w_beam" route the rows without a new instance."""
880 config = replace(DetectorConfig(), post_top_member_type="guardrail_support")886 config = DetectorConfig(post_top_member_type="guardrail_support")
881 rail, support = _tube_rail()887 rail, support = _tube_rail()
882 instances, geometry = posts.build_top_rail_instances(888 instances, geometry = posts.build_top_rail_instances(
883 [rail], [support], {0: 0}, {0: _member()}, 9, config889 [rail], [support], {0: 0}, {0: _member()}, 9, config
884 )890 )
Importance #110: tests/test_wall_geometry.py @@ -1,7 +1,7 @@
11
2from collections.abc import Callable2from collections.abc import Callable
3from dataclasses import fields, replace3from dataclasses import fields
44
5import numpy as np5import numpy as np
66
7from guardrails.config import DetectorConfig, wall_view_config7from guardrails.config import DetectorConfig, wall_view_config
Importance #111: tests/test_wall_geometry.py @@ -156,9 +156,9 @@
156 # A finer height bin (0.19 m) makes the banded-fill arithmetic below land156 # A finer height bin (0.19 m) makes the banded-fill arithmetic below land
157 # on realistic production numbers (segment_135 wall cells: p50 fill157 # on realistic production numbers (segment_135 wall cells: p50 fill
158 # 0.071) while still exercising the default wall_min_vertical_fill=0.05158 # 0.071) while still exercising the default wall_min_vertical_fill=0.05
159 # and wall_min_occupied_bins=2 gates.159 # and wall_min_occupied_bins=2 gates.
160 config = replace(DetectorConfig(), wall_height_bin_m=0.19)160 config = DetectorConfig(wall_height_bin_m=0.19)
161 evidence = _empty_evidence(1, 4, config)161 evidence = _empty_evidence(1, 4, config)
162 evidence.counts[0] = [20, 20, 20, 20]162 evidence.counts[0] = [20, 20, 20, 20]
163 evidence.top_height_m[0] = [8.0, 1.0, 2.5, 3.2]163 evidence.top_height_m[0] = [8.0, 1.0, 2.5, 3.2]
164164
Importance #112: tests/test_wall_geometry.py @@ -227,9 +227,9 @@
227 assert detect_wall_instances(evidence, config=config) == []227 assert detect_wall_instances(evidence, config=config) == []
228228
229229
230def test_fit_instance_persists_fitted_width() -> None:230def test_fit_instance_persists_fitted_width() -> None:
231 config = replace(DetectorConfig(), min_length_m=5.0, max_local_width_m=2.0)231 config = DetectorConfig(min_length_m=5.0, max_local_width_m=2.0)
232 x = np.repeat(np.linspace(0.0, 10.0, 40), 3)232 x = np.repeat(np.linspace(0.0, 10.0, 40), 3)
233 y = np.tile(np.array([-0.2, 0.0, 0.2]), 40)233 y = np.tile(np.array([-0.2, 0.0, 0.2]), 40)
234 fitted = _fit_instance(234 fitted = _fit_instance(
235 np.column_stack((x, y)), np.ones(len(x)), np.full(len(x), 0.6), config235 np.column_stack((x, y)), np.ones(len(x)), np.full(len(x), 0.6), config
Importance #113: tests/test_wall_geometry.py @@ -609,9 +609,9 @@
609 assert rejected == walls609 assert rejected == walls
610610
611611
612def test_wall_carriageway_gate_disabled_keeps_everything() -> None:612def test_wall_carriageway_gate_disabled_keeps_everything() -> None:
613 config = replace(DetectorConfig(), wall_reject_inside_carriageway=False)613 config = DetectorConfig(wall_reject_inside_carriageway=False)
614 walls = [_wall(-4.544), _wall(None), _wall(-1.0)]614 walls = [_wall(-4.544), _wall(None), _wall(-1.0)]
615615
616 kept, rejected = filter_walls_outside_carriageway(walls, [_rail(-7.0)], config)616 kept, rejected = filter_walls_outside_carriageway(walls, [_rail(-7.0)], config)
617617