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(-)
| 549 | DetectorConfigError: ``raw`` holds an unknown key, a value that is not | 113 | 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 | ||
| 564 | 123 | ||
| 565 | 124 | ||
| 566 | def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig: | 125 | def 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. |
| 568 | 127 | ||
| 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] = value | 142 | 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 config | 150 | return config |
| 578 | 151 |
| 1 | """Field declarations for :class:`guardrails.config.DetectorConfig` (part 1). | ||
| 2 | |||
| 3 | Split out of ``config.py`` only to keep both modules under the 500-line limit: | ||
| 4 | the mixins here carry no behaviour, and the config schema is still the flat | ||
| 5 | key set of ``guardrails.default.json``. Part 2 (the post / beam / top-member | ||
| 6 | levers) lives in :mod:`guardrails._config_fields_posts`. | ||
| 7 | """ | ||
| 8 | |||
| 9 | from iolabs.common import config_loader | ||
| 10 | |||
| 11 | |||
| 12 | class CoreFields(config_loader.ConfigModel): | ||
| 13 | """Ground, corridor, candidate, cluster, fit and memory levers.""" | ||
| 14 | |||
| 15 | # Ground model | ||
| 16 | ground_cell_m: float = 0.75 | ||
| 17 | ground_percentile: float = 8.0 | ||
| 18 | |||
| 19 | # Corridor crop (station / offset frame) | ||
| 20 | corridor_offset_min_m: float = 1.5 | ||
| 21 | corridor_offset_max_m: float = 10.0 | ||
| 22 | corridor_include_median_zone: bool = True | ||
| 23 | median_corridor_offset_min_m: float = 0.8 | ||
| 24 | median_corridor_offset_max_m: float = 3.8 | ||
| 25 | corridor_max_height_m: float = 2.0 | ||
| 26 | station_window_m: float = 5.0 | ||
| 27 | median_side_max_offset_m: float = 3.5 | ||
| 28 | |||
| 29 | # Optional lane-XML carriageway / rail-zone scoping | ||
| 30 | lane_xml_zones_enabled: bool = True | ||
| 31 | lane_xml_path: str | None = None | ||
| 32 | rail_zone_margin_m: float = 10.0 | ||
| 33 | outer_rail_band_m: float = 20.0 | ||
| 34 | single_edge_rail_margin_m: float = 15.0 | ||
| 35 | max_carriageway_width_m: float = 15.0 | ||
| 36 | zone_bbox_margin_m: float = 140.0 | ||
| 37 | interior_rejection_depth_m: float = 2.0 | ||
| 38 | |||
| 39 | # Optional late edge gate: instance-level distance filters against the | ||
| 40 | # lane-XML edge lines (rules E1/E2), applied after the precision gate. | ||
| 41 | # edge_gate_max_rail_distance_m was calibrated on A1 segments 060/066/085: | ||
| 42 | # real rails measure <= 3.7 m from an XML edge, noise >= 5.4 m. | ||
| 43 | edge_gate_enabled: bool = True | ||
| 44 | edge_gate_max_rail_distance_m: float = 5.0 | ||
| 45 | edge_gate_interior_depth_m: float = 0.5 | ||
| 46 | edge_gate_interior_max_frac: float = 0.5 | ||
| 47 | edge_gate_apply_to_walls: bool = False | ||
| 48 | |||
| 49 | # Optional late precision gate over final rail/wall runs. | ||
| 50 | precision_gate_enabled: bool = True | ||
| 51 | precision_deep_interior_depth_m: float = 2.0 | ||
| 52 | precision_deep_interior_frac_min: float = 0.50 | ||
| 53 | precision_vehicle_max_length_m: float = 15.0 | ||
| 54 | precision_vehicle_min_density_per_m: float = 750.0 | ||
| 55 | precision_vehicle_min_mean_height_m: float = 0.80 | ||
| 56 | precision_low_max_mean_height_m: float = 0.35 | ||
| 57 | precision_sparse_max_density_per_m: float = 300.0 | ||
| 58 | precision_sparse_min_outboard_gap_m: float = 6.0 | ||
| 59 | precision_curve_min_line_rmse_m: float = 0.010 | ||
| 60 | precision_far_min_axis_dist_m: float = 18.0 | ||
| 61 | precision_long_low_min_length_m: float = 25.0 | ||
| 62 | precision_edge_beyond_frac_min: float = 0.25 | ||
| 63 | precision_dense_low_min_density_per_m: float = 2500.0 | ||
| 64 | precision_parallel_min_inboard_gap_m: float = 3.0 | ||
| 65 | precision_parallel_min_overlap_frac: float = 0.75 | ||
| 66 | precision_unknown_far_min_axis_dist_m: float = 20.0 | ||
| 67 | precision_very_far_min_outboard_gap_m: float = 12.0 | ||
| 68 | precision_very_far_min_axis_dist_m: float = 25.0 | ||
| 69 | precision_edge_abeam_window_m: float = 15.0 | ||
| 70 | precision_edge_outboard_epsilon_m: float = 0.30 | ||
| 71 | |||
| 72 | # Occupancy grid for candidate cells | ||
| 73 | occupancy_cell_m: float = 0.10 | ||
| 74 | |||
| 75 | # Height band for initial point candidates (also drives candidates overlay) | ||
| 76 | min_height_m: float = 0.20 | ||
| 77 | max_height_m: float = 1.30 | ||
| 78 | |||
| 79 | # Per-cell rail-band fraction and mean-height gates | ||
| 80 | rail_band_min_m: float = 0.35 | ||
| 81 | rail_band_max_m: float = 0.85 | ||
| 82 | min_cell_points: int = 3 | ||
| 83 | min_rail_points: int = 2 | ||
| 84 | min_rail_fraction: float = 0.40 | ||
| 85 | min_mean_height_m: float = 0.42 | ||
| 86 | max_mean_height_m: float = 0.78 | ||
| 87 | |||
| 88 | # Optional tablecloth-residue candidate lever | ||
| 89 | tablecloth_masks_dir: str | None = None | ||
| 90 | residue_union_enabled: bool = True | ||
| 91 | residue_cell_frac: float = 0.8 | ||
| 92 | residue_lever_band_m: list[float] = [0.30, 1.20] | ||
| 93 | |||
| 94 | # Vegetation rejection: compact height-above-ground spread within a cell | ||
| 95 | max_cell_height_spread_m: float = 0.50 | ||
| 96 | |||
| 97 | # Tall-object fraction per cell (trees, poles) | ||
| 98 | tall_min_m: float = 1.30 | ||
| 99 | tall_max_m: float = 4.50 | ||
| 100 | max_tall_fraction: float = 0.12 | ||
| 101 | |||
| 102 | # Local covariance / eigenvector candidate filter (cell-level) | ||
| 103 | eigen_neighborhood_radius_m: float = 0.40 | ||
| 104 | eigen_min_neighbors: int = 5 | ||
| 105 | min_linearity: float = 0.30 | ||
| 106 | min_verticality: float = 0.15 | ||
| 107 | use_eigen_cell_filter: bool = False | ||
| 108 | |||
| 109 | # DBSCAN clustering on selected occupancy cells | ||
| 110 | cluster_eps_m: float = 0.20 | ||
| 111 | cluster_min_samples: int = 3 | ||
| 112 | |||
| 113 | # Post-cluster merge of collinear fragments | ||
| 114 | merge_gap_m: float = 4.5 | ||
| 115 | merge_angle_deg: float = 15.0 | ||
| 116 | merge_lateral_max_m: float = 0.50 | ||
| 117 | |||
| 118 | # Occlusion bridging: join collinear fragments across a parked-vehicle / | ||
| 119 | # occlusion shadow when heading and offset stay continuous (defect 4). The | ||
| 120 | # bridged station interval is recorded in ``gap_spans`` (never interpolated | ||
| 121 | # silently). | ||
| 122 | # Default is conservative (8 m) so bridging never fuses two distinct | ||
| 123 | # barriers into one instance; raise via --set occlusion_bridge_max_m=15 for | ||
| 124 | # datasets with longer occlusion shadows. | ||
| 125 | occlusion_bridge_max_m: float = 8.0 | ||
| 126 | occlusion_bridge_max_angle_deg: float = 4.0 | ||
| 127 | occlusion_bridge_max_lateral_m: float = 0.40 | ||
| 128 | |||
| 129 | # Parallel-face deduplication (two faces of one physical rail). | ||
| 130 | # ``dedupe_*`` are retained for backward compatibility; the active policy is | ||
| 131 | # driven by ``merge_face_*`` (see README "Face / barrier merge policy"). | ||
| 132 | dedupe_face_max_sep_m: float = 1.0 | ||
| 133 | dedupe_max_angle_deg: float = 12.0 | ||
| 134 | merge_face_max_spacing_m: float = 1.3 | ||
| 135 | merge_face_max_heading_deg: float = 5.0 | ||
| 136 | merge_face_min_station_overlap: float = 0.5 | ||
| 137 | merge_face_max_faces: int = 2 | ||
| 138 | |||
| 139 | # Instance acceptance (applied after merge) | ||
| 140 | min_length_m: float = 12.0 | ||
| 141 | max_local_width_m: float = 0.75 | ||
| 142 | min_longitudinal_coverage: float = 0.35 | ||
| 143 | |||
| 144 | # Ordered-walk polyline construction | ||
| 145 | polyline_bin_m: float = 1.0 | ||
| 146 | polyline_smooth_window: int = 5 | ||
| 147 | walk_max_step_m: float = 0.30 | ||
| 148 | |||
| 149 | # Gap recording along station | ||
| 150 | gap_min_span_m: float = 2.0 | ||
| 151 | |||
| 152 | # Vehicle / occlusion-shadow rejection on cluster height distribution | ||
| 153 | max_cluster_height_spread_m: float = 0.80 | ||
| 154 | max_cluster_p95_height_m: float = 1.15 | ||
| 155 | |||
| 156 | # Straightness check along sliding window (short clusters only) | ||
| 157 | straightness_window_m: float = 10.0 | ||
| 158 | max_straightness_deviation_m: float = 0.50 | ||
| 159 | straightness_max_length_m: float = 25.0 | ||
| 160 | |||
| 161 | # Heuristic type classification thresholds | ||
| 162 | w_beam_min_height_m: float = 0.40 | ||
| 163 | w_beam_max_height_m: float = 0.90 | ||
| 164 | w_beam_max_height_spread_m: float = 0.55 | ||
| 165 | concrete_min_height_m: float = 0.80 | ||
| 166 | concrete_max_height_spread_m: float = 0.45 | ||
| 167 | cable_suspect_max_spread_m: float = 0.25 | ||
| 168 | |||
| 169 | # Per-run confidence heuristic (0-1); see README "Run confidence". | ||
| 170 | # confidence = 0.35*support + 0.25*continuity + 0.25*extent + 0.15*height | ||
| 171 | confidence_density_norm_pts_per_m: float = 500.0 | ||
| 172 | confidence_full_extent_m: float = 40.0 | ||
| 173 | confidence_max_height_std_m: float = 0.2 | ||
| 174 | |||
| 175 | # Memory hardening (deployment target is a 32 GB RAM Azure node). | ||
| 176 | memory_budget_gb: float = 10.0 | ||
| 177 | station_process_window_m: float = 5.0 | ||
| 178 | decimation_enabled: bool = False | ||
| 179 | decimation_voxel_m: float = 0.05 | ||
| 180 | decimation_density_cap: int = 400000 | ||
| 181 | # Records larger than this stream through the corridor crop in chunks of | ||
| 182 | # this many points instead of being materialized whole (byte-identical | ||
| 183 | # results for records at or below the threshold, which use the old path). | ||
| 184 | record_chunk_points: int = 4000000 | ||
| 185 | # Exclusion clustering guard: DBSCAN memory scales with the number of | ||
| 186 | # eps-neighbour pairs. When a cheap grid estimate of that count exceeds | ||
| 187 | # this cap the exclusion candidates are voxel-decimated first (auto-trigger | ||
| 188 | # only; sparse segments are untouched). segment_134's dense record | ||
| 189 | # estimated 4.0e9 pairs (25 GB RSS); curated segments peak at 6.3e8. | ||
| 190 | exclusion_pair_estimate_max: float = 1000000000.0 | ||
| 191 | exclusion_decimation_cell_m: float = 0.10 | ||
| 192 | # After the density trigger decimates, the residual DBSCAN runs under the | ||
| 193 | # shared iolabs.common.memory_guard watchdog (subprocess + psutil RSS | ||
| 194 | # monitor, hard kill above the limit) as a second line of defense. Mirrors | ||
| 195 | # the subcluster_dbscan_memory_guard wiring in | ||
| 196 | # iolabs_point_cloud_modelling_lines / iolabs_geometry_geometry.fit_spline. | ||
| 197 | exclusion_use_shared_watchdog: bool = True | ||
| 198 | exclusion_dbscan_mem_limit_gb: float = 6.0 | ||
| 199 | exclusion_dbscan_timeout_s: float = 120.0 | ||
| 200 | |||
| 201 | |||
| 202 | class WallFields(config_loader.ConfigModel): | ||
| 203 | """Noise-wall detection, wall-view fit overrides and wall-only gates.""" | ||
| 204 | |||
| 205 | # Wall detection: independent evidence/fitting channel (see README "Noise | ||
| 206 | # walls"). ``wall_detection_enabled=False`` is a process-level kill switch; | ||
| 207 | # it emits ``"walls": []`` and allocates no wall grids. | ||
| 208 | wall_detection_enabled: bool = True | ||
| 209 | wall_cell_m: float = 0.25 | ||
| 210 | wall_height_bin_m: float = 0.25 | ||
| 211 | wall_min_height_m: float = 0.30 | ||
| 212 | wall_max_height_m: float = 8.00 | ||
| 213 | wall_offset_min_m: float = 1.50 | ||
| 214 | # Dataset ground truth (segments 133-137; segment_135 confirmed walls near | ||
| 215 | # offset ~23 m) puts walls at spine offsets 21-25 m; 20.0 would miss them. | ||
| 216 | wall_offset_max_m: float = 26.00 | ||
| 217 | wall_min_cell_points: int = 6 | ||
| 218 | wall_min_top_height_m: float = 2.50 | ||
| 219 | wall_max_top_height_m: float = 8.00 | ||
| 220 | # Grazing-angle MLS returns are banded, not continuous: production | ||
| 221 | # segment_135 wall cells measured occupied-bin fill p10=0.040/p50=0.071. | ||
| 222 | wall_min_vertical_fill: float = 0.05 | ||
| 223 | # Per-cell minimum distinct occupied height bins; rejects single-scanline | ||
| 224 | # artifacts. | ||
| 225 | wall_min_occupied_bins: int = 2 | ||
| 226 | # Per-cell occupied-bin span (last - first occupied bin, inclusive) in | ||
| 227 | # metres: separates vertical-sheet wall cells (bins spread over metres) | ||
| 228 | # from grazing-angle surface/embankment cells banded within ~0.5 m. | ||
| 229 | wall_min_cell_height_span_m: float = 1.5 | ||
| 230 | |||
| 231 | # Wall-view overrides of the shared clustering/merge/fit config (see | ||
| 232 | # ``wall_view_config()``). | ||
| 233 | wall_cluster_eps_m: float = 0.40 | ||
| 234 | wall_cluster_min_samples: int = 3 | ||
| 235 | wall_merge_gap_m: float = 4.50 | ||
| 236 | wall_merge_angle_deg: float = 8.0 | ||
| 237 | wall_merge_lateral_max_m: float = 1.00 | ||
| 238 | # Real occluded walls (segment_135) show raw-data voids up to ~13.8 m; | ||
| 239 | # 14.0 keeps that structure bridgeable while the 4deg/0.4 m collinearity | ||
| 240 | # guards below still block unrelated fragments from fusing. | ||
| 241 | wall_occlusion_bridge_max_m: float = 14.00 | ||
| 242 | wall_occlusion_bridge_max_angle_deg: float = 4.0 | ||
| 243 | wall_occlusion_bridge_max_lateral_m: float = 0.40 | ||
| 244 | # Staggered noise-wall rows fit as separate ~14 m instances after polyline | ||
| 245 | # smoothing (segment_135: 14.86 m / 13.92 m); vegetation rejection is | ||
| 246 | # carried by the width/straightness/planarity/crest gates, not length. | ||
| 247 | wall_min_length_m: float = 13.0 | ||
| 248 | wall_max_local_width_m: float = 1.80 | ||
| 249 | wall_min_longitudinal_coverage: float = 0.60 | ||
| 250 | wall_max_cluster_height_spread_m: float = 12.0 | ||
| 251 | wall_max_cluster_p95_height_m: float = 12.0 | ||
| 252 | wall_straightness_window_m: float = 10.0 | ||
| 253 | wall_max_straightness_deviation_m: float = 0.35 | ||
| 254 | wall_straightness_max_length_m: float = 25.0 | ||
| 255 | # Sparse/occluded tail regions leave the wall polyline fit on banded, | ||
| 256 | # far-range evidence that meanders (segment_135); a stronger lateral | ||
| 257 | # smoothing window than the guardrail default (5) is needed to tame it. | ||
| 258 | wall_polyline_smooth_window: int = 9 | ||
| 259 | |||
| 260 | # Post-fit wall-only gates (crest profile, truck rejection, mandatory 3D | ||
| 261 | # PCA plane checks); not part of ``wall_view_config()``. | ||
| 262 | wall_profile_bin_m: float = 1.00 | ||
| 263 | # Real crest profiles ramp at their ends; a genuine structure was rejected | ||
| 264 | # by 0.005 m in production. Truck rejection is handled separately by the | ||
| 265 | # truck double-gate below. | ||
| 266 | wall_max_top_profile_spread_m: float = 1.50 | ||
| 267 | wall_truck_max_top_m: float = 4.20 | ||
| 268 | # EU max articulated truck length is ~18.75 m; 20.0 keeps the truck | ||
| 269 | # double-gate effective (top <= wall_truck_max_top_m AND length < this) | ||
| 270 | # while remaining just above that bound. | ||
| 271 | wall_truck_min_length_m: float = 20.0 | ||
| 272 | wall_min_planarity: float = 0.55 | ||
| 273 | wall_max_plane_normal_z_abs: float = 0.35 | ||
| 274 | # Grazing-angle MLS returns are height-banded (segment_135 row B: | ||
| 275 | # planarity=0.368, normal_z_abs=0.005): a clearly-vertical cell can sit | ||
| 276 | # just under the mandatory planarity ratio. Moderate planarity is | ||
| 277 | # accepted when the normal is unambiguously vertical. | ||
| 278 | wall_min_planarity_vertical: float = 0.25 | ||
| 279 | # Banded returns can also collapse to a line-degenerate (not plane-like) | ||
| 280 | # moment shape, making the plane normal numerically arbitrary | ||
| 281 | # (segment_135 row A: planarity=0.020, normal_z_abs=1.000, yet the | ||
| 282 | # moments are unambiguously line-like). A high linearity ratio plus a | ||
| 283 | # thin fitted width certifies a genuine vertical sheet without relying on | ||
| 284 | # that ill-conditioned normal. | ||
| 285 | wall_line_bypass_min_linearity: float = 0.75 | ||
| 286 | wall_line_bypass_max_width_m: float = 1.0 | ||
| 287 | |||
| 288 | # Carriageway rejection gate: a wall candidate between the carriageway | ||
| 289 | # edge-line guardrails is a vehicle (or bridge-deck returns sharing its | ||
| 290 | # cells), not a genuine noise wall (see README "Carriageway rejection | ||
| 291 | # gate"; production segment_135 false positive at offset -4.544 m). | ||
| 292 | wall_reject_inside_carriageway: bool = True | ||
| 293 | # Fallback minimum |mean_offset_m| for a wall when no same-side guardrail | ||
| 294 | # exists to compare against. | ||
| 295 | wall_min_abs_offset_m: float = 6.0 | ||
| 296 | # A wall may interleave up to this much inside the outermost same-side | ||
| 297 | # guardrail before being treated as inside the carriageway. | ||
| 298 | wall_outside_rail_margin_m: float = 0.5 | ||
| 299 | |||
| 300 | # A ground-standing wall's first returns start near the ground; a bottom-height | ||
| 301 | # profile starting above this is an elevated bridge parapet/deck structure | ||
| 302 | # measured from the wrong base. | ||
| 303 | wall_max_bottom_height_m: float = 2.0 | ||
| 304 | |||
| 305 | |||
| 306 | class OverlayFields(config_loader.ConfigModel): | ||
| 307 | """Overlay kill switches shared with the perspective CLI.""" | ||
| 308 | |||
| 309 | # Overlay kill switches (also mirrored in ``PerspectiveConfig`` so the | ||
| 310 | # independent perspective CLI shares the same rollback behavior). | ||
| 311 | overlay_extent_enabled: bool = True | ||
| 312 | overlay_ground_model_diff_enabled: bool = False | ||
| 0 |
| 1 | """Field declarations for :class:`guardrails.config.DetectorConfig` (part 2). | ||
| 2 | |||
| 3 | The guardrail rail-vs-support decomposition levers (post cadence, beam | ||
| 4 | underside, 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 | |||
| 8 | from typing import Literal | ||
| 9 | |||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | |||
| 13 | class 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 |
| 4 | (``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a | 4 | (``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a |
| 5 | ``guardrails.default.json`` algorithm config, and a typed params object | 5 | ``guardrails.default.json`` algorithm config, and a typed params object |
| 6 | (:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are | 6 | (:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are |
| 7 | applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON. | 7 | applied 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 | ||
| 9 | every field default is kept identical to ``guardrails.default.json`` (guarded by | ||
| 10 | a unit test), so ``DetectorConfig()`` and ``load_config()`` agree. | ||
| 11 | """ | ||
| 12 | 8 | ||
| 9 | The schema is the pydantic model :class:`DetectorConfig`, derived from | ||
| 10 | :class:`iolabs.common.config_loader.ConfigModel`: unknown keys are rejected and | ||
| 11 | raw JSON / ``--set`` values are coerced to the declared field types by the | ||
| 12 | shared layer. Every field default is kept identical to | ||
| 13 | ``guardrails.default.json`` (guarded by a unit test), so ``DetectorConfig()`` | ||
| 14 | and :func:`load_config` agree. Adding a config key means adding the field (in | ||
| 15 | :mod:`guardrails._config_fields` or :mod:`guardrails._config_fields_posts`) and | ||
| 16 | the matching entry in ``guardrails.default.json`` โ nothing else. | ||
| 17 | """ | ||
| 13 | 18 | ||
| 14 | import copy | ||
| 15 | import logging | 19 | import logging |
| 16 | from dataclasses import dataclass, field, replace | ||
| 17 | from typing import Any | 20 | from typing import Any |
| 18 | 21 | ||
| 19 | from iolabs.common.config_loader import ( | 22 | import pydantic |
| 20 | ConfigError, | 23 | from iolabs.common import config_loader |
| 21 | dataclass_from_mapping, | ||
| 22 | load_packaged_json, | ||
| 23 | ) | ||
| 24 | from iolabs.common.config_loader import parse_set_overrides as _parse_set_overrides | ||
| 25 | |||
| 26 | logger = logging.getLogger(__name__) | ||
| 27 | |||
| 28 | 24 | ||
| 29 | @dataclass(frozen=True) | 25 | from . import _config_fields, _config_fields_posts |
| 30 | class 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 | |||
| 515 | class DetectorConfigError(ConfigError): | ||
| 516 | """Raised when the guardrails config contains unsupported keys.""" | ||
| 517 | 26 | ||
| 27 | logger = logging.getLogger(__name__) | ||
| 518 | 28 | ||
| 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" |
| 523 | 33 | ||
| 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 | |||
| 58 | class DetectorConfig( | ||
| 59 | _config_fields.CoreFields, | ||
| 60 | _config_fields.WallFields, | ||
| 61 | _config_fields_posts.PostFields, | ||
| 62 | _config_fields.OverlayFields, | ||
| 63 | ): | ||
| 64 | """Spatial and geometric thresholds, in metres unless stated otherwise. | ||
| 65 | |||
| 66 | The field set is declared by the mixins in | ||
| 67 | :mod:`guardrails._config_fields` / :mod:`guardrails._config_fields_posts` | ||
| 68 | and mirrors ``guardrails.default.json`` key for key; this class only adds | ||
| 69 | the cross-value checks that a declared field type cannot express. | ||
| 70 | """ | ||
| 71 | |||
| 72 | @pydantic.field_validator("residue_lever_band_m") | ||
| 73 | @classmethod | ||
| 74 | def _check_residue_band(cls, value: list[float]) -> list[float]: | ||
| 75 | """Reject a residue lever band that is not a ``[min_m, max_m]`` pair.""" | ||
| 76 | if len(value) != 2: | ||
| 77 | raise ValueError( | ||
| 78 | "residue_lever_band_m must contain exactly 2 values: [min_m, max_m]" | ||
| 79 | ) | ||
| 80 | return value | ||
| 81 | |||
| 82 | |||
| 83 | class DetectorConfigError(config_loader.ConfigError): | ||
| 84 | """Raised when the guardrails config contains unsupported keys.""" | ||
| 85 | |||
| 524 | 86 | ||
| 525 | def load_default_config_dict() -> dict[str, Any]: | 87 | def 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. |
| 527 | 89 | ||
| 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 | ) | ||
| 532 | 96 | ||
| 533 | 97 | ||
| 534 | def config_from_dict(raw: dict[str, Any]) -> DetectorConfig: | 98 | def 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. |
| 536 | 100 | ||
| 537 | Unknown keys and values that do not fit their declared field type are | 101 | 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 the | 103 | ``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. |
| 541 | 105 | ||
| 542 | Args: | 106 | Args: |
| 543 | raw: Merged config mapping (packaged defaults plus overrides). | 107 | raw: Merged config mapping (packaged defaults plus overrides). |
| 580 | def wall_view_config(config: DetectorConfig) -> DetectorConfig: | 153 | def 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. |
| 582 | 155 | ||
| 583 | Maps every ``wall_*`` clustering/merge/fit override onto the matching | 156 | Maps every ``wall_*`` clustering/merge/fit override onto the matching |
| 584 | guardrail-named field via ``dataclasses.replace``. No other field | 157 | 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 for | 159 | ``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 | ) |
| 610 | 173 | ||
| 611 | 174 | ||
| 612 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: | 175 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: |
| 620 | 183 | ||
| 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 | ) |
| 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 | continue | 452 | 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") |
| 7 | """ | 7 | """ |
| 8 | 8 | ||
| 9 | import gc | 9 | import gc |
| 10 | import logging | 10 | import logging |
| 11 | from dataclasses import replace | ||
| 12 | from pathlib import Path | 11 | from pathlib import Path |
| 13 | 12 | ||
| 14 | import numpy as np | 13 | import numpy as np |
| 15 | from iolabs.common.point_masks_io import write_point_masks | 14 | from iolabs.common.point_masks_io import write_point_masks |
| 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_m | 83 | 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_band | 87 | if widen_low_band |
| 89 | else config | 88 | else config |
| 90 | ) | 89 | ) |
| 91 | min_height_m = replay_config.min_height_m | 90 | min_height_m = replay_config.min_height_m |
| 1 | import argparse | 1 | import argparse |
| 2 | from dataclasses import asdict, fields, replace | ||
| 3 | from pathlib import Path | 2 | from pathlib import Path |
| 4 | 3 | ||
| 5 | import pytest | 4 | import pytest |
| 6 | 5 |
| 37 | "polyline_smooth_window": "wall_polyline_smooth_window", | 36 | "polyline_smooth_window": "wall_polyline_smooth_window", |
| 38 | } | 37 | } |
| 39 | 38 | ||
| 40 | 39 | ||
| 41 | def test_default_json_matches_dataclass_defaults() -> None: | 40 | def 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, key | 46 | assert json_config[key] == value, key |
| 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) |
| 141 | 140 | ||
| 142 | # Every mapped field changed in the returned config to the nondefault | 141 | # 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. |
| 145 | expected = nondefault_wall_values[wall_field] | 144 | expected = nondefault_wall_values[wall_field] |
| 146 | assert getattr(result, target_field) == expected, target_field | 145 | assert getattr(result, target_field) == expected, target_field |
| 147 | assert getattr(result, target_field) != getattr(source, target_field), target_field | 146 | assert getattr(result, target_field) != getattr(source, target_field), target_field |
| 148 | 147 | ||
| 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) == value | 150 | assert getattr(source, wall_field) == value |
| 152 | 151 | ||
| 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 | continue | 156 | continue |
| 158 | assert getattr(result, field.name) == getattr(source, field.name), field.name | 157 | assert getattr(result, field_name) == getattr(source, field_name), field_name |
| 159 | 158 |
| 1 | 1 | ||
| 2 | from dataclasses import fields, replace | 2 | from dataclasses import fields |
| 3 | from pathlib import Path | 3 | from pathlib import Path |
| 4 | from unittest.mock import call | 4 | from unittest.mock import call |
| 5 | 5 | ||
| 6 | import numpy as np | 6 | import numpy as np |
| 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 | ) |
| 74 | 74 |
| 77 | 77 | ||
| 78 | def test_wall_accumulator_is_chunk_independent_and_grid_bounded(tmp_path: Path) -> None: | 78 | def 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 | ) |
| 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_evidence | 94 | ).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_evidence | 102 | ).wall_evidence |
| 104 | 103 |
| 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() |
| 225 | 224 | ||
| 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 | ) |
| 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() |
| 349 | 347 | ||
| 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() |
| 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() |
| 457 | 454 | ||
| 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() |
| 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() |
| 558 | 554 | ||
| 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() |
| 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"] |
| 736 | 731 | ||
| 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"), |
| 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) |
| 6 | The spine is the straight x-axis at y=0, so spine offset == world y. | 6 | The spine is the straight x-axis at y=0, so spine offset == world y. |
| 7 | """ | 7 | """ |
| 8 | 8 | ||
| 9 | from dataclasses import replace | ||
| 10 | from pathlib import Path | 9 | from pathlib import Path |
| 11 | 10 | ||
| 12 | import numpy as np | 11 | import numpy as np |
| 13 | import pytest | 12 | import pytest |
| 116 | assert index.tree.n == len(index.edge_xy) | 115 | assert index.tree.n == len(index.edge_xy) |
| 117 | 116 | ||
| 118 | 117 | ||
| 119 | def test_build_edge_index_none_when_no_edge_intersects_bbox() -> None: | 118 | def 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) |
| 121 | 120 | ||
| 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), |
| 229 | assert exclusions == [] | 228 | assert exclusions == [] |
| 230 | 229 | ||
| 231 | 230 | ||
| 232 | def test_e1_threshold_is_config_driven() -> None: | 231 | def 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) |
| 234 | 233 | ||
| 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, |
| 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() |
| 251 | 250 | ||
| 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()) |
| 255 | 254 | ||
| 256 | assert len(kept_lenient) == 1 | 255 | assert len(kept_lenient) == 1 |
| 290 | assert exclusions == [] | 289 | assert exclusions == [] |
| 291 | 290 | ||
| 292 | 291 | ||
| 293 | def test_disabled_gate_keeps_every_run_untouched() -> None: | 292 | def 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)] |
| 296 | 295 | ||
| 297 | kept, exclusions = _apply(runs, config=config) | 296 | kept, exclusions = _apply(runs, config=config) |
| 298 | 297 |
| 7 | 7 | ||
| 8 | import json | 8 | import json |
| 9 | import logging | 9 | import logging |
| 10 | import math | 10 | import math |
| 11 | from dataclasses import replace | ||
| 12 | from types import SimpleNamespace | 11 | from types import SimpleNamespace |
| 13 | 12 | ||
| 14 | import numpy as np | 13 | import numpy as np |
| 15 | import pytest | 14 | import pytest |
| 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)) |
| 1718 | 1717 | ||
| 1719 | 1718 | ||
| 1720 | def test_build_support_claim_honours_the_behind_beam_kill_switch() -> None: | 1719 | def 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]), config | 1722 | _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 None | 1724 | assert claim.behind_beam_min_dist_m is None |
| 1 | import copy | 1 | import copy |
| 2 | import json | 2 | import json |
| 3 | from dataclasses import replace | ||
| 4 | from pathlib import Path | 3 | from pathlib import Path |
| 5 | 4 | ||
| 6 | import numpy as np | 5 | import numpy as np |
| 7 | import pytest | 6 | import pytest |
| 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)) |
| 358 | 357 |
| 363 | assert detect._precision_rule_for_metrics(run, miss_metrics, config, kind=kind) is None | 362 | assert detect._precision_rule_for_metrics(run, miss_metrics, config, kind=kind) is None |
| 364 | 363 | ||
| 365 | 364 | ||
| 366 | def test_precision_walls_only_use_g0() -> None: | 365 | def 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) |
| 370 | 369 | ||
| 371 | assert ( | 370 | assert ( |
| 423 | return rails, walls | 422 | return rails, walls |
| 424 | 423 | ||
| 425 | 424 | ||
| 426 | def test_precision_gate_integration_attribution_schema_and_stable_ids() -> None: | 425 | def 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]] = [] |
| 430 | 429 | ||
| 431 | kept_rails, kept_walls = detect._apply_precision_gate( | 430 | kept_rails, kept_walls = detect._apply_precision_gate( |
| 489 | assert index is None | 488 | assert index is None |
| 490 | 489 | ||
| 491 | 490 | ||
| 492 | def test_precision_gate_enabled_defaults_keep_without_evidence_index() -> None: | 491 | def 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)) |
| 497 | 496 |
| 510 | assert exclusions == [] | 509 | assert exclusions == [] |
| 511 | 510 | ||
| 512 | 511 | ||
| 513 | def test_precision_gate_disabled_is_byte_identical_and_skips_evidence() -> None: | 512 | def 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}, |
| 5 | pattern as ``tests/test_point_masks.py::test_collect_point_masks_bounded_filtering``), | 5 | pattern as ``tests/test_point_masks.py::test_collect_point_masks_bounded_filtering``), |
| 6 | so a point's (station, offset, height) is simply its (x, y, z). | 6 | so a point's (station, offset, height) is simply its (x, y, z). |
| 7 | """ | 7 | """ |
| 8 | 8 | ||
| 9 | from dataclasses import replace | ||
| 10 | from types import SimpleNamespace | 9 | from types import SimpleNamespace |
| 11 | 10 | ||
| 12 | import numpy as np | 11 | import numpy as np |
| 13 | import pytest | 12 | import pytest |
| 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 | ) | ||
| 583 | 580 | ||
| 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=True | 583 | config, collect_height_station=True |
| 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] |
| 1039 | 1036 | ||
| 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 | ) |
| 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], |
| 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() |
| 1212 | 1207 | ||
| 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, |
| 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" |
| 1564 | 1558 | ||
| 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) |
| 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) |
| 1585 | 1582 | ||
| 1586 | 1583 |
| 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) |
| 1599 | 1599 | ||
| 1600 | 1600 |
| 238 | } | 238 | } |
| 239 | 239 | ||
| 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 | ) | ||
| 243 | 245 | ||
| 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 True | 247 | 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] |
| 261 | would leave ``detect_top_member`` and the shaft cap working off a number | 263 | 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 to | 264 | 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) |
| 504 | 506 | ||
| 505 | 507 | ||
| 506 | def test_top_member_is_gated_off_by_its_flag() -> None: | 508 | def 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( |
| 559 | assert behind is not None and bool(behind[0]) is True | 561 | 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 False | 562 | assert bool(claim.behind_beam(post_xy - np.array([0.0, 0.50]), 0)[0]) is False |
| 561 | 563 | ||
| 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) |
| 566 | 570 | ||
| 567 | 571 |
| 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 line | 630 | 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 True | 631 | 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 False | 636 | assert bool(old.behind_beam(column[None, :], 0)[0]) is False |
| 633 | 637 |
| 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_m | 656 | 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_m | 664 | config.post_behind_beam_offset_m |
| 659 | ) | 665 | ) |
| 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 True | 868 | 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 True | 872 | assert posts.detect_top_member(evidence, beam, 0.25, 4, strict).present is True |
| 867 | 873 |
| 876 | 882 | ||
| 877 | 883 | ||
| 878 | def test_top_member_routing_emits_no_companion_but_still_claims() -> None: | 884 | def 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, config | 889 | [rail], [support], {0: 0}, {0: _member()}, 9, config |
| 884 | ) | 890 | ) |
| 1 | 1 | ||
| 2 | from collections.abc import Callable | 2 | from collections.abc import Callable |
| 3 | from dataclasses import fields, replace | 3 | from dataclasses import fields |
| 4 | 4 | ||
| 5 | import numpy as np | 5 | import numpy as np |
| 6 | 6 | ||
| 7 | from guardrails.config import DetectorConfig, wall_view_config | 7 | from guardrails.config import DetectorConfig, wall_view_config |
| 156 | # A finer height bin (0.19 m) makes the banded-fill arithmetic below land | 156 | # A finer height bin (0.19 m) makes the banded-fill arithmetic below land |
| 157 | # on realistic production numbers (segment_135 wall cells: p50 fill | 157 | # on realistic production numbers (segment_135 wall cells: p50 fill |
| 158 | # 0.071) while still exercising the default wall_min_vertical_fill=0.05 | 158 | # 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] |
| 164 | 164 |
| 227 | assert detect_wall_instances(evidence, config=config) == [] | 227 | assert detect_wall_instances(evidence, config=config) == [] |
| 228 | 228 | ||
| 229 | 229 | ||
| 230 | def test_fit_instance_persists_fitted_width() -> None: | 230 | def 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), config | 235 | np.column_stack((x, y)), np.ones(len(x)), np.full(len(x), 0.6), config |
| 609 | assert rejected == walls | 609 | assert rejected == walls |
| 610 | 610 | ||
| 611 | 611 | ||
| 612 | def test_wall_carriageway_gate_disabled_keeps_everything() -> None: | 612 | def 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)] |
| 615 | 615 | ||
| 616 | kept, rejected = filter_walls_outside_carriageway(walls, [_rail(-7.0)], config) | 616 | kept, rejected = filter_walls_outside_carriageway(walls, [_rail(-7.0)], config) |
| 617 | 617 |
| 1 | [project] | 1 | [project] |
| 2 | name = "guardrails" | 2 | name = "guardrails" |
| 3 | version = "0.4.0" | 3 | version = "0.4.1" |
| 4 | description = "Classical geometric guardrail detection in MLS LiDAR point clouds" | 4 | description = "Classical geometric guardrail detection in MLS LiDAR point clouds" |
| 5 | readme = "README.md" | 5 | readme = "README.md" |
| 6 | requires-python = ">=3.11" | 6 | requires-python = ">=3.11" |
| 7 | dependencies = [ | 7 | dependencies = [ |
| 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", |
| 24 | 24 | ||
| 25 | Following the other iolabs point-cloud packages | 25 | Following the other iolabs point-cloud packages |
| 26 | (`iolabs_point_cloud_segmentation_trajectory` etc.), the package owns an | 26 | (`iolabs_point_cloud_segmentation_trajectory` etc.), the package owns an |
| 27 | algorithm config `guardrails/guardrails.default.json`. `guardrails/config.py` is | 27 | algorithm config `guardrails/guardrails.default.json`. `guardrails/config.py` is |
| 28 | the loader/schema: the frozen `DetectorConfig` dataclass is the typed params | 28 | the loader/schema: `DetectorConfig` is a frozen pydantic model derived from |
| 29 | object and its field set is the schema. Every dataclass default is kept | 29 | `iolabs.common.config_loader.ConfigModel` (the fleet SSOT: it rejects unknown |
| 30 | keys and coerces raw JSON / `--set` values to the declared field types), and its | ||
| 31 | field set is the schema. The field declarations live in the two mixins | ||
| 32 | `guardrails/_config_fields.py` and `guardrails/_config_fields_posts.py` (split | ||
| 33 | only to keep every module under 500 lines). Every model default is kept | ||
| 30 | identical to the JSON (asserted by `tests/test_config.py`). | 34 | identical to the JSON (asserted by `tests/test_config.py`). |
| 31 | 35 | ||
| 36 | **Adding a config key:** declare the field on the matching mixin and add the | ||
| 37 | same key/default to `guardrails.default.json`. Nothing else โ there is no | ||
| 38 | allowed-key list and no coercion helper to update. | ||
| 39 | |||
| 32 | Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are | 40 | Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are |
| 33 | JSON-decoded), never repo-local JSON files: | 41 | JSON-decoded), never repo-local JSON files: |
| 34 | 42 | ||
| 35 | ```bash | 43 | ```bash |
| 1 | """Field declarations for :class:`guardrails.config.DetectorConfig` (part 1). | ||
| 2 | |||
| 3 | Split out of ``config.py`` only to keep both modules under the 500-line limit: | ||
| 4 | the mixins here carry no behaviour, and the config schema is still the flat | ||
| 5 | key set of ``guardrails.default.json``. Part 2 (the post / beam / top-member | ||
| 6 | levers) lives in :mod:`guardrails._config_fields_posts`. | ||
| 7 | """ | ||
| 8 | |||
| 9 | from iolabs.common import config_loader | ||
| 10 | |||
| 11 | |||
| 12 | class CoreFields(config_loader.ConfigModel): | ||
| 13 | """Ground, corridor, candidate, cluster, fit and memory levers.""" | ||
| 14 | |||
| 15 | # Ground model | ||
| 16 | ground_cell_m: float = 0.75 | ||
| 17 | ground_percentile: float = 8.0 | ||
| 18 | |||
| 19 | # Corridor crop (station / offset frame) | ||
| 20 | corridor_offset_min_m: float = 1.5 | ||
| 21 | corridor_offset_max_m: float = 10.0 | ||
| 22 | corridor_include_median_zone: bool = True | ||
| 23 | median_corridor_offset_min_m: float = 0.8 | ||
| 24 | median_corridor_offset_max_m: float = 3.8 | ||
| 25 | corridor_max_height_m: float = 2.0 | ||
| 26 | station_window_m: float = 5.0 | ||
| 27 | median_side_max_offset_m: float = 3.5 | ||
| 28 | |||
| 29 | # Optional lane-XML carriageway / rail-zone scoping | ||
| 30 | lane_xml_zones_enabled: bool = True | ||
| 31 | lane_xml_path: str | None = None | ||
| 32 | rail_zone_margin_m: float = 10.0 | ||
| 33 | outer_rail_band_m: float = 20.0 | ||
| 34 | single_edge_rail_margin_m: float = 15.0 | ||
| 35 | max_carriageway_width_m: float = 15.0 | ||
| 36 | zone_bbox_margin_m: float = 140.0 | ||
| 37 | interior_rejection_depth_m: float = 2.0 | ||
| 38 | |||
| 39 | # Optional late edge gate: instance-level distance filters against the | ||
| 40 | # lane-XML edge lines (rules E1/E2), applied after the precision gate. | ||
| 41 | # edge_gate_max_rail_distance_m was calibrated on A1 segments 060/066/085: | ||
| 42 | # real rails measure <= 3.7 m from an XML edge, noise >= 5.4 m. | ||
| 43 | edge_gate_enabled: bool = True | ||
| 44 | edge_gate_max_rail_distance_m: float = 5.0 | ||
| 45 | edge_gate_interior_depth_m: float = 0.5 | ||
| 46 | edge_gate_interior_max_frac: float = 0.5 | ||
| 47 | edge_gate_apply_to_walls: bool = False | ||
| 48 | |||
| 49 | # Optional late precision gate over final rail/wall runs. | ||
| 50 | precision_gate_enabled: bool = True | ||
| 51 | precision_deep_interior_depth_m: float = 2.0 | ||
| 52 | precision_deep_interior_frac_min: float = 0.50 | ||
| 53 | precision_vehicle_max_length_m: float = 15.0 | ||
| 54 | precision_vehicle_min_density_per_m: float = 750.0 | ||
| 55 | precision_vehicle_min_mean_height_m: float = 0.80 | ||
| 56 | precision_low_max_mean_height_m: float = 0.35 | ||
| 57 | precision_sparse_max_density_per_m: float = 300.0 | ||
| 58 | precision_sparse_min_outboard_gap_m: float = 6.0 | ||
| 59 | precision_curve_min_line_rmse_m: float = 0.010 | ||
| 60 | precision_far_min_axis_dist_m: float = 18.0 | ||
| 61 | precision_long_low_min_length_m: float = 25.0 | ||
| 62 | precision_edge_beyond_frac_min: float = 0.25 | ||
| 63 | precision_dense_low_min_density_per_m: float = 2500.0 | ||
| 64 | precision_parallel_min_inboard_gap_m: float = 3.0 | ||
| 65 | precision_parallel_min_overlap_frac: float = 0.75 | ||
| 66 | precision_unknown_far_min_axis_dist_m: float = 20.0 | ||
| 67 | precision_very_far_min_outboard_gap_m: float = 12.0 | ||
| 68 | precision_very_far_min_axis_dist_m: float = 25.0 | ||
| 69 | precision_edge_abeam_window_m: float = 15.0 | ||
| 70 | precision_edge_outboard_epsilon_m: float = 0.30 | ||
| 71 | |||
| 72 | # Occupancy grid for candidate cells | ||
| 73 | occupancy_cell_m: float = 0.10 | ||
| 74 | |||
| 75 | # Height band for initial point candidates (also drives candidates overlay) | ||
| 76 | min_height_m: float = 0.20 | ||
| 77 | max_height_m: float = 1.30 | ||
| 78 | |||
| 79 | # Per-cell rail-band fraction and mean-height gates | ||
| 80 | rail_band_min_m: float = 0.35 | ||
| 81 | rail_band_max_m: float = 0.85 | ||
| 82 | min_cell_points: int = 3 | ||
| 83 | min_rail_points: int = 2 | ||
| 84 | min_rail_fraction: float = 0.40 | ||
| 85 | min_mean_height_m: float = 0.42 | ||
| 86 | max_mean_height_m: float = 0.78 | ||
| 87 | |||
| 88 | # Optional tablecloth-residue candidate lever | ||
| 89 | tablecloth_masks_dir: str | None = None | ||
| 90 | residue_union_enabled: bool = True | ||
| 91 | residue_cell_frac: float = 0.8 | ||
| 92 | residue_lever_band_m: list[float] = [0.30, 1.20] | ||
| 93 | |||
| 94 | # Vegetation rejection: compact height-above-ground spread within a cell | ||
| 95 | max_cell_height_spread_m: float = 0.50 | ||
| 96 | |||
| 97 | # Tall-object fraction per cell (trees, poles) | ||
| 98 | tall_min_m: float = 1.30 | ||
| 99 | tall_max_m: float = 4.50 | ||
| 100 | max_tall_fraction: float = 0.12 | ||
| 101 | |||
| 102 | # Local covariance / eigenvector candidate filter (cell-level) | ||
| 103 | eigen_neighborhood_radius_m: float = 0.40 | ||
| 104 | eigen_min_neighbors: int = 5 | ||
| 105 | min_linearity: float = 0.30 | ||
| 106 | min_verticality: float = 0.15 | ||
| 107 | use_eigen_cell_filter: bool = False | ||
| 108 | |||
| 109 | # DBSCAN clustering on selected occupancy cells | ||
| 110 | cluster_eps_m: float = 0.20 | ||
| 111 | cluster_min_samples: int = 3 | ||
| 112 | |||
| 113 | # Post-cluster merge of collinear fragments | ||
| 114 | merge_gap_m: float = 4.5 | ||
| 115 | merge_angle_deg: float = 15.0 | ||
| 116 | merge_lateral_max_m: float = 0.50 | ||
| 117 | |||
| 118 | # Occlusion bridging: join collinear fragments across a parked-vehicle / | ||
| 119 | # occlusion shadow when heading and offset stay continuous (defect 4). The | ||
| 120 | # bridged station interval is recorded in ``gap_spans`` (never interpolated | ||
| 121 | # silently). | ||
| 122 | # Default is conservative (8 m) so bridging never fuses two distinct | ||
| 123 | # barriers into one instance; raise via --set occlusion_bridge_max_m=15 for | ||
| 124 | # datasets with longer occlusion shadows. | ||
| 125 | occlusion_bridge_max_m: float = 8.0 | ||
| 126 | occlusion_bridge_max_angle_deg: float = 4.0 | ||
| 127 | occlusion_bridge_max_lateral_m: float = 0.40 | ||
| 128 | |||
| 129 | # Parallel-face deduplication (two faces of one physical rail). | ||
| 130 | # ``dedupe_*`` are retained for backward compatibility; the active policy is | ||
| 131 | # driven by ``merge_face_*`` (see README "Face / barrier merge policy"). | ||
| 132 | dedupe_face_max_sep_m: float = 1.0 | ||
| 133 | dedupe_max_angle_deg: float = 12.0 | ||
| 134 | merge_face_max_spacing_m: float = 1.3 | ||
| 135 | merge_face_max_heading_deg: float = 5.0 | ||
| 136 | merge_face_min_station_overlap: float = 0.5 | ||
| 137 | merge_face_max_faces: int = 2 | ||
| 138 | |||
| 139 | # Instance acceptance (applied after merge) | ||
| 140 | min_length_m: float = 12.0 | ||
| 141 | max_local_width_m: float = 0.75 | ||
| 142 | min_longitudinal_coverage: float = 0.35 | ||
| 143 | |||
| 144 | # Ordered-walk polyline construction | ||
| 145 | polyline_bin_m: float = 1.0 | ||
| 146 | polyline_smooth_window: int = 5 | ||
| 147 | walk_max_step_m: float = 0.30 | ||
| 148 | |||
| 149 | # Gap recording along station | ||
| 150 | gap_min_span_m: float = 2.0 | ||
| 151 | |||
| 152 | # Vehicle / occlusion-shadow rejection on cluster height distribution | ||
| 153 | max_cluster_height_spread_m: float = 0.80 | ||
| 154 | max_cluster_p95_height_m: float = 1.15 | ||
| 155 | |||
| 156 | # Straightness check along sliding window (short clusters only) | ||
| 157 | straightness_window_m: float = 10.0 | ||
| 158 | max_straightness_deviation_m: float = 0.50 | ||
| 159 | straightness_max_length_m: float = 25.0 | ||
| 160 | |||
| 161 | # Heuristic type classification thresholds | ||
| 162 | w_beam_min_height_m: float = 0.40 | ||
| 163 | w_beam_max_height_m: float = 0.90 | ||
| 164 | w_beam_max_height_spread_m: float = 0.55 | ||
| 165 | concrete_min_height_m: float = 0.80 | ||
| 166 | concrete_max_height_spread_m: float = 0.45 | ||
| 167 | cable_suspect_max_spread_m: float = 0.25 | ||
| 168 | |||
| 169 | # Per-run confidence heuristic (0-1); see README "Run confidence". | ||
| 170 | # confidence = 0.35*support + 0.25*continuity + 0.25*extent + 0.15*height | ||
| 171 | confidence_density_norm_pts_per_m: float = 500.0 | ||
| 172 | confidence_full_extent_m: float = 40.0 | ||
| 173 | confidence_max_height_std_m: float = 0.2 | ||
| 174 | |||
| 175 | # Memory hardening (deployment target is a 32 GB RAM Azure node). | ||
| 176 | memory_budget_gb: float = 10.0 | ||
| 177 | station_process_window_m: float = 5.0 | ||
| 178 | decimation_enabled: bool = False | ||
| 179 | decimation_voxel_m: float = 0.05 | ||
| 180 | decimation_density_cap: int = 400000 | ||
| 181 | # Records larger than this stream through the corridor crop in chunks of | ||
| 182 | # this many points instead of being materialized whole (byte-identical | ||
| 183 | # results for records at or below the threshold, which use the old path). | ||
| 184 | record_chunk_points: int = 4000000 | ||
| 185 | # Exclusion clustering guard: DBSCAN memory scales with the number of | ||
| 186 | # eps-neighbour pairs. When a cheap grid estimate of that count exceeds | ||
| 187 | # this cap the exclusion candidates are voxel-decimated first (auto-trigger | ||
| 188 | # only; sparse segments are untouched). segment_134's dense record | ||
| 189 | # estimated 4.0e9 pairs (25 GB RSS); curated segments peak at 6.3e8. | ||
| 190 | exclusion_pair_estimate_max: float = 1000000000.0 | ||
| 191 | exclusion_decimation_cell_m: float = 0.10 | ||
| 192 | # After the density trigger decimates, the residual DBSCAN runs under the | ||
| 193 | # shared iolabs.common.memory_guard watchdog (subprocess + psutil RSS | ||
| 194 | # monitor, hard kill above the limit) as a second line of defense. Mirrors | ||
| 195 | # the subcluster_dbscan_memory_guard wiring in | ||
| 196 | # iolabs_point_cloud_modelling_lines / iolabs_geometry_geometry.fit_spline. | ||
| 197 | exclusion_use_shared_watchdog: bool = True | ||
| 198 | exclusion_dbscan_mem_limit_gb: float = 6.0 | ||
| 199 | exclusion_dbscan_timeout_s: float = 120.0 | ||
| 200 | |||
| 201 | |||
| 202 | class WallFields(config_loader.ConfigModel): | ||
| 203 | """Noise-wall detection, wall-view fit overrides and wall-only gates.""" | ||
| 204 | |||
| 205 | # Wall detection: independent evidence/fitting channel (see README "Noise | ||
| 206 | # walls"). ``wall_detection_enabled=False`` is a process-level kill switch; | ||
| 207 | # it emits ``"walls": []`` and allocates no wall grids. | ||
| 208 | wall_detection_enabled: bool = True | ||
| 209 | wall_cell_m: float = 0.25 | ||
| 210 | wall_height_bin_m: float = 0.25 | ||
| 211 | wall_min_height_m: float = 0.30 | ||
| 212 | wall_max_height_m: float = 8.00 | ||
| 213 | wall_offset_min_m: float = 1.50 | ||
| 214 | # Dataset ground truth (segments 133-137; segment_135 confirmed walls near | ||
| 215 | # offset ~23 m) puts walls at spine offsets 21-25 m; 20.0 would miss them. | ||
| 216 | wall_offset_max_m: float = 26.00 | ||
| 217 | wall_min_cell_points: int = 6 | ||
| 218 | wall_min_top_height_m: float = 2.50 | ||
| 219 | wall_max_top_height_m: float = 8.00 | ||
| 220 | # Grazing-angle MLS returns are banded, not continuous: production | ||
| 221 | # segment_135 wall cells measured occupied-bin fill p10=0.040/p50=0.071. | ||
| 222 | wall_min_vertical_fill: float = 0.05 | ||
| 223 | # Per-cell minimum distinct occupied height bins; rejects single-scanline | ||
| 224 | # artifacts. | ||
| 225 | wall_min_occupied_bins: int = 2 | ||
| 226 | # Per-cell occupied-bin span (last - first occupied bin, inclusive) in | ||
| 227 | # metres: separates vertical-sheet wall cells (bins spread over metres) | ||
| 228 | # from grazing-angle surface/embankment cells banded within ~0.5 m. | ||
| 229 | wall_min_cell_height_span_m: float = 1.5 | ||
| 230 | |||
| 231 | # Wall-view overrides of the shared clustering/merge/fit config (see | ||
| 232 | # ``wall_view_config()``). | ||
| 233 | wall_cluster_eps_m: float = 0.40 | ||
| 234 | wall_cluster_min_samples: int = 3 | ||
| 235 | wall_merge_gap_m: float = 4.50 | ||
| 236 | wall_merge_angle_deg: float = 8.0 | ||
| 237 | wall_merge_lateral_max_m: float = 1.00 | ||
| 238 | # Real occluded walls (segment_135) show raw-data voids up to ~13.8 m; | ||
| 239 | # 14.0 keeps that structure bridgeable while the 4deg/0.4 m collinearity | ||
| 240 | # guards below still block unrelated fragments from fusing. | ||
| 241 | wall_occlusion_bridge_max_m: float = 14.00 | ||
| 242 | wall_occlusion_bridge_max_angle_deg: float = 4.0 | ||
| 243 | wall_occlusion_bridge_max_lateral_m: float = 0.40 | ||
| 244 | # Staggered noise-wall rows fit as separate ~14 m instances after polyline | ||
| 245 | # smoothing (segment_135: 14.86 m / 13.92 m); vegetation rejection is | ||
| 246 | # carried by the width/straightness/planarity/crest gates, not length. | ||
| 247 | wall_min_length_m: float = 13.0 | ||
| 248 | wall_max_local_width_m: float = 1.80 | ||
| 249 | wall_min_longitudinal_coverage: float = 0.60 | ||
| 250 | wall_max_cluster_height_spread_m: float = 12.0 | ||
| 251 | wall_max_cluster_p95_height_m: float = 12.0 | ||
| 252 | wall_straightness_window_m: float = 10.0 | ||
| 253 | wall_max_straightness_deviation_m: float = 0.35 | ||
| 254 | wall_straightness_max_length_m: float = 25.0 | ||
| 255 | # Sparse/occluded tail regions leave the wall polyline fit on banded, | ||
| 256 | # far-range evidence that meanders (segment_135); a stronger lateral | ||
| 257 | # smoothing window than the guardrail default (5) is needed to tame it. | ||
| 258 | wall_polyline_smooth_window: int = 9 | ||
| 259 | |||
| 260 | # Post-fit wall-only gates (crest profile, truck rejection, mandatory 3D | ||
| 261 | # PCA plane checks); not part of ``wall_view_config()``. | ||
| 262 | wall_profile_bin_m: float = 1.00 | ||
| 263 | # Real crest profiles ramp at their ends; a genuine structure was rejected | ||
| 264 | # by 0.005 m in production. Truck rejection is handled separately by the | ||
| 265 | # truck double-gate below. | ||
| 266 | wall_max_top_profile_spread_m: float = 1.50 | ||
| 267 | wall_truck_max_top_m: float = 4.20 | ||
| 268 | # EU max articulated truck length is ~18.75 m; 20.0 keeps the truck | ||
| 269 | # double-gate effective (top <= wall_truck_max_top_m AND length < this) | ||
| 270 | # while remaining just above that bound. | ||
| 271 | wall_truck_min_length_m: float = 20.0 | ||
| 272 | wall_min_planarity: float = 0.55 | ||
| 273 | wall_max_plane_normal_z_abs: float = 0.35 | ||
| 274 | # Grazing-angle MLS returns are height-banded (segment_135 row B: | ||
| 275 | # planarity=0.368, normal_z_abs=0.005): a clearly-vertical cell can sit | ||
| 276 | # just under the mandatory planarity ratio. Moderate planarity is | ||
| 277 | # accepted when the normal is unambiguously vertical. | ||
| 278 | wall_min_planarity_vertical: float = 0.25 | ||
| 279 | # Banded returns can also collapse to a line-degenerate (not plane-like) | ||
| 280 | # moment shape, making the plane normal numerically arbitrary | ||
| 281 | # (segment_135 row A: planarity=0.020, normal_z_abs=1.000, yet the | ||
| 282 | # moments are unambiguously line-like). A high linearity ratio plus a | ||
| 283 | # thin fitted width certifies a genuine vertical sheet without relying on | ||
| 284 | # that ill-conditioned normal. | ||
| 285 | wall_line_bypass_min_linearity: float = 0.75 | ||
| 286 | wall_line_bypass_max_width_m: float = 1.0 | ||
| 287 | |||
| 288 | # Carriageway rejection gate: a wall candidate between the carriageway | ||
| 289 | # edge-line guardrails is a vehicle (or bridge-deck returns sharing its | ||
| 290 | # cells), not a genuine noise wall (see README "Carriageway rejection | ||
| 291 | # gate"; production segment_135 false positive at offset -4.544 m). | ||
| 292 | wall_reject_inside_carriageway: bool = True | ||
| 293 | # Fallback minimum |mean_offset_m| for a wall when no same-side guardrail | ||
| 294 | # exists to compare against. | ||
| 295 | wall_min_abs_offset_m: float = 6.0 | ||
| 296 | # A wall may interleave up to this much inside the outermost same-side | ||
| 297 | # guardrail before being treated as inside the carriageway. | ||
| 298 | wall_outside_rail_margin_m: float = 0.5 | ||
| 299 | |||
| 300 | # A ground-standing wall's first returns start near the ground; a bottom-height | ||
| 301 | # profile starting above this is an elevated bridge parapet/deck structure | ||
| 302 | # measured from the wrong base. | ||
| 303 | wall_max_bottom_height_m: float = 2.0 | ||
| 304 | |||
| 305 | |||
| 306 | class OverlayFields(config_loader.ConfigModel): | ||
| 307 | """Overlay kill switches shared with the perspective CLI.""" | ||
| 308 | |||
| 309 | # Overlay kill switches (also mirrored in ``PerspectiveConfig`` so the | ||
| 310 | # independent perspective CLI shares the same rollback behavior). | ||
| 311 | overlay_extent_enabled: bool = True | ||
| 312 | overlay_ground_model_diff_enabled: bool = False | ||
| 0 |
| 1 | """Field declarations for :class:`guardrails.config.DetectorConfig` (part 2). | ||
| 2 | |||
| 3 | The guardrail rail-vs-support decomposition levers (post cadence, beam | ||
| 4 | underside, 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 | |||
| 8 | from typing import Literal | ||
| 9 | |||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | |||
| 13 | class 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 |
| 4 | (``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a | 4 | (``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a |
| 5 | ``guardrails.default.json`` algorithm config, and a typed params object | 5 | ``guardrails.default.json`` algorithm config, and a typed params object |
| 6 | (:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are | 6 | (:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are |
| 7 | applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON. | 7 | applied 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 | ||
| 9 | every field default is kept identical to ``guardrails.default.json`` (guarded by | ||
| 10 | a unit test), so ``DetectorConfig()`` and ``load_config()`` agree. | ||
| 11 | """ | ||
| 12 | 8 | ||
| 9 | The schema is the pydantic model :class:`DetectorConfig`, derived from | ||
| 10 | :class:`iolabs.common.config_loader.ConfigModel`: unknown keys are rejected and | ||
| 11 | raw JSON / ``--set`` values are coerced to the declared field types by the | ||
| 12 | shared layer. Every field default is kept identical to | ||
| 13 | ``guardrails.default.json`` (guarded by a unit test), so ``DetectorConfig()`` | ||
| 14 | and :func:`load_config` agree. Adding a config key means adding the field (in | ||
| 15 | :mod:`guardrails._config_fields` or :mod:`guardrails._config_fields_posts`) and | ||
| 16 | the matching entry in ``guardrails.default.json`` โ nothing else. | ||
| 17 | """ | ||
| 13 | 18 | ||
| 14 | import copy | ||
| 15 | import logging | 19 | import logging |
| 16 | from dataclasses import dataclass, field, replace | ||
| 17 | from typing import Any | 20 | from typing import Any |
| 18 | 21 | ||
| 19 | from iolabs.common.config_loader import ( | 22 | import pydantic |
| 20 | ConfigError, | 23 | from iolabs.common import config_loader |
| 21 | dataclass_from_mapping, | ||
| 22 | load_packaged_json, | ||
| 23 | ) | ||
| 24 | from iolabs.common.config_loader import parse_set_overrides as _parse_set_overrides | ||
| 25 | |||
| 26 | logger = logging.getLogger(__name__) | ||
| 27 | |||
| 28 | 24 | ||
| 29 | @dataclass(frozen=True) | 25 | from . import _config_fields, _config_fields_posts |
| 30 | class 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 | |||
| 515 | class DetectorConfigError(ConfigError): | ||
| 516 | """Raised when the guardrails config contains unsupported keys.""" | ||
| 517 | 26 | ||
| 27 | logger = logging.getLogger(__name__) | ||
| 518 | 28 | ||
| 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" |
| 523 | 33 | ||
| 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 | |||
| 58 | class DetectorConfig( | ||
| 59 | _config_fields.CoreFields, | ||
| 60 | _config_fields.WallFields, | ||
| 61 | _config_fields_posts.PostFields, | ||
| 62 | _config_fields.OverlayFields, | ||
| 63 | ): | ||
| 64 | """Spatial and geometric thresholds, in metres unless stated otherwise. | ||
| 65 | |||
| 66 | The field set is declared by the mixins in | ||
| 67 | :mod:`guardrails._config_fields` / :mod:`guardrails._config_fields_posts` | ||
| 68 | and mirrors ``guardrails.default.json`` key for key; this class only adds | ||
| 69 | the cross-value checks that a declared field type cannot express. | ||
| 70 | """ | ||
| 71 | |||
| 72 | @pydantic.field_validator("residue_lever_band_m") | ||
| 73 | @classmethod | ||
| 74 | def _check_residue_band(cls, value: list[float]) -> list[float]: | ||
| 75 | """Reject a residue lever band that is not a ``[min_m, max_m]`` pair.""" | ||
| 76 | if len(value) != 2: | ||
| 77 | raise ValueError( | ||
| 78 | "residue_lever_band_m must contain exactly 2 values: [min_m, max_m]" | ||
| 79 | ) | ||
| 80 | return value | ||
| 81 | |||
| 82 | |||
| 83 | class DetectorConfigError(config_loader.ConfigError): | ||
| 84 | """Raised when the guardrails config contains unsupported keys.""" | ||
| 85 | |||
| 524 | 86 | ||
| 525 | def load_default_config_dict() -> dict[str, Any]: | 87 | def 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. |
| 527 | 89 | ||
| 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 | ) | ||
| 532 | 96 | ||
| 533 | 97 | ||
| 534 | def config_from_dict(raw: dict[str, Any]) -> DetectorConfig: | 98 | def 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. |
| 536 | 100 | ||
| 537 | Unknown keys and values that do not fit their declared field type are | 101 | 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 the | 103 | ``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. |
| 541 | 105 | ||
| 542 | Args: | 106 | Args: |
| 543 | raw: Merged config mapping (packaged defaults plus overrides). | 107 | raw: Merged config mapping (packaged defaults plus overrides). |
| 549 | DetectorConfigError: ``raw`` holds an unknown key, a value that is not | 113 | 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 | ||
| 564 | 123 | ||
| 565 | 124 | ||
| 566 | def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig: | 125 | def 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. |
| 568 | 127 | ||
| 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] = value | 142 | 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 config | 150 | return config |
| 578 | 151 |
| 580 | def wall_view_config(config: DetectorConfig) -> DetectorConfig: | 153 | def 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. |
| 582 | 155 | ||
| 583 | Maps every ``wall_*`` clustering/merge/fit override onto the matching | 156 | Maps every ``wall_*`` clustering/merge/fit override onto the matching |
| 584 | guardrail-named field via ``dataclasses.replace``. No other field | 157 | 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 for | 159 | ``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 | ) |
| 610 | 173 | ||
| 611 | 174 | ||
| 612 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: | 175 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: |
| 620 | 183 | ||
| 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 | ) |
| 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 | continue | 452 | 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") |
| 7 | """ | 7 | """ |
| 8 | 8 | ||
| 9 | import gc | 9 | import gc |
| 10 | import logging | 10 | import logging |
| 11 | from dataclasses import replace | ||
| 12 | from pathlib import Path | 11 | from pathlib import Path |
| 13 | 12 | ||
| 14 | import numpy as np | 13 | import numpy as np |
| 15 | from iolabs.common.point_masks_io import write_point_masks | 14 | from iolabs.common.point_masks_io import write_point_masks |
| 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_m | 83 | 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_band | 87 | if widen_low_band |
| 89 | else config | 88 | else config |
| 90 | ) | 89 | ) |
| 91 | min_height_m = replay_config.min_height_m | 90 | min_height_m = replay_config.min_height_m |
| 1 | [project] | 1 | [project] |
| 2 | name = "guardrails" | 2 | name = "guardrails" |
| 3 | version = "0.4.0" | 3 | version = "0.4.1" |
| 4 | description = "Classical geometric guardrail detection in MLS LiDAR point clouds" | 4 | description = "Classical geometric guardrail detection in MLS LiDAR point clouds" |
| 5 | readme = "README.md" | 5 | readme = "README.md" |
| 6 | requires-python = ">=3.11" | 6 | requires-python = ">=3.11" |
| 7 | dependencies = [ | 7 | dependencies = [ |
| 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", |
| 1 | import argparse | 1 | import argparse |
| 2 | from dataclasses import asdict, fields, replace | ||
| 3 | from pathlib import Path | 2 | from pathlib import Path |
| 4 | 3 | ||
| 5 | import pytest | 4 | import pytest |
| 6 | 5 |
| 37 | "polyline_smooth_window": "wall_polyline_smooth_window", | 36 | "polyline_smooth_window": "wall_polyline_smooth_window", |
| 38 | } | 37 | } |
| 39 | 38 | ||
| 40 | 39 | ||
| 41 | def test_default_json_matches_dataclass_defaults() -> None: | 40 | def 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, key | 46 | assert json_config[key] == value, key |
| 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) |
| 141 | 140 | ||
| 142 | # Every mapped field changed in the returned config to the nondefault | 141 | # 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. |
| 145 | expected = nondefault_wall_values[wall_field] | 144 | expected = nondefault_wall_values[wall_field] |
| 146 | assert getattr(result, target_field) == expected, target_field | 145 | assert getattr(result, target_field) == expected, target_field |
| 147 | assert getattr(result, target_field) != getattr(source, target_field), target_field | 146 | assert getattr(result, target_field) != getattr(source, target_field), target_field |
| 148 | 147 | ||
| 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) == value | 150 | assert getattr(source, wall_field) == value |
| 152 | 151 | ||
| 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 | continue | 156 | continue |
| 158 | assert getattr(result, field.name) == getattr(source, field.name), field.name | 157 | assert getattr(result, field_name) == getattr(source, field_name), field_name |
| 159 | 158 |
| 1 | 1 | ||
| 2 | from dataclasses import fields, replace | 2 | from dataclasses import fields |
| 3 | from pathlib import Path | 3 | from pathlib import Path |
| 4 | from unittest.mock import call | 4 | from unittest.mock import call |
| 5 | 5 | ||
| 6 | import numpy as np | 6 | import numpy as np |
| 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 | ) |
| 74 | 74 |
| 77 | 77 | ||
| 78 | def test_wall_accumulator_is_chunk_independent_and_grid_bounded(tmp_path: Path) -> None: | 78 | def 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 | ) |
| 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_evidence | 94 | ).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_evidence | 102 | ).wall_evidence |
| 104 | 103 |
| 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() |
| 225 | 224 | ||
| 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 | ) |
| 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() |
| 349 | 347 | ||
| 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() |
| 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() |
| 457 | 454 | ||
| 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() |
| 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() |
| 558 | 554 | ||
| 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() |
| 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"] |
| 736 | 731 | ||
| 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"), |
| 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) |
| 6 | The spine is the straight x-axis at y=0, so spine offset == world y. | 6 | The spine is the straight x-axis at y=0, so spine offset == world y. |
| 7 | """ | 7 | """ |
| 8 | 8 | ||
| 9 | from dataclasses import replace | ||
| 10 | from pathlib import Path | 9 | from pathlib import Path |
| 11 | 10 | ||
| 12 | import numpy as np | 11 | import numpy as np |
| 13 | import pytest | 12 | import pytest |
| 116 | assert index.tree.n == len(index.edge_xy) | 115 | assert index.tree.n == len(index.edge_xy) |
| 117 | 116 | ||
| 118 | 117 | ||
| 119 | def test_build_edge_index_none_when_no_edge_intersects_bbox() -> None: | 118 | def 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) |
| 121 | 120 | ||
| 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), |
| 229 | assert exclusions == [] | 228 | assert exclusions == [] |
| 230 | 229 | ||
| 231 | 230 | ||
| 232 | def test_e1_threshold_is_config_driven() -> None: | 231 | def 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) |
| 234 | 233 | ||
| 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, |
| 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() |
| 251 | 250 | ||
| 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()) |
| 255 | 254 | ||
| 256 | assert len(kept_lenient) == 1 | 255 | assert len(kept_lenient) == 1 |
| 290 | assert exclusions == [] | 289 | assert exclusions == [] |
| 291 | 290 | ||
| 292 | 291 | ||
| 293 | def test_disabled_gate_keeps_every_run_untouched() -> None: | 292 | def 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)] |
| 296 | 295 | ||
| 297 | kept, exclusions = _apply(runs, config=config) | 296 | kept, exclusions = _apply(runs, config=config) |
| 298 | 297 |
| 7 | 7 | ||
| 8 | import json | 8 | import json |
| 9 | import logging | 9 | import logging |
| 10 | import math | 10 | import math |
| 11 | from dataclasses import replace | ||
| 12 | from types import SimpleNamespace | 11 | from types import SimpleNamespace |
| 13 | 12 | ||
| 14 | import numpy as np | 13 | import numpy as np |
| 15 | import pytest | 14 | import pytest |
| 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)) |
| 1718 | 1717 | ||
| 1719 | 1718 | ||
| 1720 | def test_build_support_claim_honours_the_behind_beam_kill_switch() -> None: | 1719 | def 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]), config | 1722 | _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 None | 1724 | assert claim.behind_beam_min_dist_m is None |
| 1 | import copy | 1 | import copy |
| 2 | import json | 2 | import json |
| 3 | from dataclasses import replace | ||
| 4 | from pathlib import Path | 3 | from pathlib import Path |
| 5 | 4 | ||
| 6 | import numpy as np | 5 | import numpy as np |
| 7 | import pytest | 6 | import pytest |
| 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)) |
| 358 | 357 |
| 363 | assert detect._precision_rule_for_metrics(run, miss_metrics, config, kind=kind) is None | 362 | assert detect._precision_rule_for_metrics(run, miss_metrics, config, kind=kind) is None |
| 364 | 363 | ||
| 365 | 364 | ||
| 366 | def test_precision_walls_only_use_g0() -> None: | 365 | def 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) |
| 370 | 369 | ||
| 371 | assert ( | 370 | assert ( |
| 423 | return rails, walls | 422 | return rails, walls |
| 424 | 423 | ||
| 425 | 424 | ||
| 426 | def test_precision_gate_integration_attribution_schema_and_stable_ids() -> None: | 425 | def 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]] = [] |
| 430 | 429 | ||
| 431 | kept_rails, kept_walls = detect._apply_precision_gate( | 430 | kept_rails, kept_walls = detect._apply_precision_gate( |
| 489 | assert index is None | 488 | assert index is None |
| 490 | 489 | ||
| 491 | 490 | ||
| 492 | def test_precision_gate_enabled_defaults_keep_without_evidence_index() -> None: | 491 | def 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)) |
| 497 | 496 |
| 510 | assert exclusions == [] | 509 | assert exclusions == [] |
| 511 | 510 | ||
| 512 | 511 | ||
| 513 | def test_precision_gate_disabled_is_byte_identical_and_skips_evidence() -> None: | 512 | def 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}, |
| 5 | pattern as ``tests/test_point_masks.py::test_collect_point_masks_bounded_filtering``), | 5 | pattern as ``tests/test_point_masks.py::test_collect_point_masks_bounded_filtering``), |
| 6 | so a point's (station, offset, height) is simply its (x, y, z). | 6 | so a point's (station, offset, height) is simply its (x, y, z). |
| 7 | """ | 7 | """ |
| 8 | 8 | ||
| 9 | from dataclasses import replace | ||
| 10 | from types import SimpleNamespace | 9 | from types import SimpleNamespace |
| 11 | 10 | ||
| 12 | import numpy as np | 11 | import numpy as np |
| 13 | import pytest | 12 | import pytest |
| 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 | ) | ||
| 583 | 580 | ||
| 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=True | 583 | config, collect_height_station=True |
| 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] |
| 1039 | 1036 | ||
| 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 | ) |
| 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], |
| 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() |
| 1212 | 1207 | ||
| 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, |
| 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" |
| 1564 | 1558 | ||
| 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) |
| 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) |
| 1585 | 1582 | ||
| 1586 | 1583 |
| 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) |
| 1599 | 1599 | ||
| 1600 | 1600 |
| 238 | } | 238 | } |
| 239 | 239 | ||
| 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 | ) | ||
| 243 | 245 | ||
| 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 True | 247 | 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] |
| 261 | would leave ``detect_top_member`` and the shaft cap working off a number | 263 | 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 to | 264 | 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) |
| 504 | 506 | ||
| 505 | 507 | ||
| 506 | def test_top_member_is_gated_off_by_its_flag() -> None: | 508 | def 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( |
| 559 | assert behind is not None and bool(behind[0]) is True | 561 | 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 False | 562 | assert bool(claim.behind_beam(post_xy - np.array([0.0, 0.50]), 0)[0]) is False |
| 561 | 563 | ||
| 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) |
| 566 | 570 | ||
| 567 | 571 |
| 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 line | 630 | 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 True | 631 | 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 False | 636 | assert bool(old.behind_beam(column[None, :], 0)[0]) is False |
| 633 | 637 |
| 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_m | 656 | 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_m | 664 | config.post_behind_beam_offset_m |
| 659 | ) | 665 | ) |
| 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 True | 868 | 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 True | 872 | assert posts.detect_top_member(evidence, beam, 0.25, 4, strict).present is True |
| 867 | 873 |
| 876 | 882 | ||
| 877 | 883 | ||
| 878 | def test_top_member_routing_emits_no_companion_but_still_claims() -> None: | 884 | def 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, config | 889 | [rail], [support], {0: 0}, {0: _member()}, 9, config |
| 884 | ) | 890 | ) |
| 1 | 1 | ||
| 2 | from collections.abc import Callable | 2 | from collections.abc import Callable |
| 3 | from dataclasses import fields, replace | 3 | from dataclasses import fields |
| 4 | 4 | ||
| 5 | import numpy as np | 5 | import numpy as np |
| 6 | 6 | ||
| 7 | from guardrails.config import DetectorConfig, wall_view_config | 7 | from guardrails.config import DetectorConfig, wall_view_config |
| 156 | # A finer height bin (0.19 m) makes the banded-fill arithmetic below land | 156 | # A finer height bin (0.19 m) makes the banded-fill arithmetic below land |
| 157 | # on realistic production numbers (segment_135 wall cells: p50 fill | 157 | # on realistic production numbers (segment_135 wall cells: p50 fill |
| 158 | # 0.071) while still exercising the default wall_min_vertical_fill=0.05 | 158 | # 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] |
| 164 | 164 |
| 227 | assert detect_wall_instances(evidence, config=config) == [] | 227 | assert detect_wall_instances(evidence, config=config) == [] |
| 228 | 228 | ||
| 229 | 229 | ||
| 230 | def test_fit_instance_persists_fitted_width() -> None: | 230 | def 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), config | 235 | np.column_stack((x, y)), np.ones(len(x)), np.full(len(x), 0.6), config |
| 609 | assert rejected == walls | 609 | assert rejected == walls |
| 610 | 610 | ||
| 611 | 611 | ||
| 612 | def test_wall_carriageway_gate_disabled_keeps_everything() -> None: | 612 | def 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)] |
| 615 | 615 | ||
| 616 | kept, rejected = filter_walls_outside_carriageway(walls, [_rail(-7.0)], config) | 616 | kept, rejected = filter_walls_outside_carriageway(walls, [_rail(-7.0)], config) |
| 617 | 617 |
ConfigModel: nested section models mirror the packaged*.default.jsonkey for key; whitelist sets and hand-rolled coercion deleted; loader built onconfig_loader.load_config. Public entry-point names and return types unchanged so lanefinder wrappers keep working.pydantic>=2.7dependency.