Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:44:31+02:00
Commit #51 ยท 37 snippets
README.md | 36 ++ .../__init__.py | 6 + .../asphalt_edge.default.json | 102 +++++- .../config.py | 383 +++++++++++++-------- .../detector.py | 9 +- .../image_edge.py | 2 +- tests/test_config.py | 102 ++++-- 7 files changed, 451 insertions(+), 189 deletions(-)
| 29 | from .image_edge import ImageEdgeConfig | 32 | from .image_edge import ImageEdgeConfig |
| 30 | 33 | ||
| 31 | logger = logging.getLogger(__name__) | 34 | logger = logging.getLogger(__name__) |
| 32 | 35 | ||
| 33 | _PACKAGE = "iolabs_point_cloud_detection_asphaltedge" | 36 | _PACKAGE_NAME = "iolabs_point_cloud_detection_asphaltedge" |
| 34 | _DEFAULT_CONFIG_FILENAME = "asphalt_edge.default.json" | 37 | _DEFAULT_FILENAME = "asphalt_edge.default.json" |
| 35 | _CONTEXT = "asphalt edge config" | 38 | _CONTEXT = "asphalt edge config" |
| 36 | 39 | ||
| 37 | 40 | ||
| 38 | class AsphaltEdgeConfigError(config_loader.ConfigError): | 41 | class AsphaltEdgeConfigError(config_loader.ConfigError): |
| 39 | """Raised when the asphalt edge config contains unsupported keys or values.""" | 42 | """Raised when asphalt edge config contains unsupported keys or values.""" |
| 43 | |||
| 44 | |||
| 45 | def _check_interval(name: str, interval: tuple[float, float]) -> None: | ||
| 46 | """Raise when a ``(low, high)`` interval is not strictly increasing.""" | ||
| 47 | low, high = interval | ||
| 48 | if low >= high: | ||
| 49 | raise ValueError(f"{name} must be (low, high) with low < high, got ({low}, {high})") | ||
| 40 | 50 | ||
| 41 | 51 | ||
| 42 | class FeatureWeightsConfig(config_loader.ConfigModel): | 52 | class AsphaltEdgeWeightsConfig(config_loader.ConfigModel): |
| 43 | """``weights`` block of ``asphalt_edge.default.json``.""" | 53 | """``weights`` block: cross-profile material-edge feature weights.""" |
| 44 | 54 | ||
| 45 | intensity_step: float = 2.5 | 55 | intensity_step: float = pydantic.Field(default=2.5, ge=0.0) |
| 46 | density_drop: float = 2.0 | 56 | density_drop: float = pydantic.Field(default=2.0, ge=0.0) |
| 47 | z_rise: float = 1.0 | 57 | z_rise: float = pydantic.Field(default=1.0, ge=0.0) |
| 48 | roughness_rise: float = 0.8 | 58 | roughness_rise: float = pydantic.Field(default=0.8, ge=0.0) |
| 49 | surface_notch: float = 1.5 | 59 | surface_notch: float = pydantic.Field(default=1.5, ge=0.0) |
| 50 | dog_step: float = 1.0 | 60 | dog_step: float = pydantic.Field(default=1.0, ge=0.0) |
| 51 | 61 | ||
| 52 | 62 | ||
| 53 | class ImageEdgeConfigModel(config_loader.ConfigModel): | 63 | class AsphaltEdgeImageConfig(config_loader.ConfigModel): |
| 54 | """``image`` block plus the dataclass knobs accepted as overrides.""" | 64 | """``image`` block: tunables of the rectified-image edge extractor.""" |
| 55 | 65 | ||
| 56 | station_step: float = 0.25 | 66 | # rectification grid |
| 57 | offset_step: float = 0.05 | 67 | station_step: float = pydantic.Field(default=0.25, gt=0.0) |
| 68 | offset_step: float = pydantic.Field(default=0.05, gt=0.0) | ||
| 58 | offset_min: float = -18.0 | 69 | offset_min: float = -18.0 |
| 59 | offset_max: float = 18.0 | 70 | offset_max: float = 18.0 |
| 71 | # side search bands (signed offset metres; +left / -right) | ||
| 60 | left_band: tuple[float, float] = (1.0, 17.0) | 72 | left_band: tuple[float, float] = (1.0, 17.0) |
| 61 | right_band: tuple[float, float] = (-17.0, -1.0) | 73 | right_band: tuple[float, float] = (-17.0, -1.0) |
| 62 | inner_left_band: tuple[float, float] = (0.3, 6.0) | 74 | inner_left_band: tuple[float, float] = (0.3, 6.0) |
| 63 | inner_right_band: tuple[float, float] = (-6.0, -0.3) | 75 | inner_right_band: tuple[float, float] = (-6.0, -0.3) |
| 64 | inner_min_valid_frac: float = 0.2 | 76 | # negative means "reuse min_valid_frac", so negatives stay legal here |
| 65 | inner_bound_margin_m: float = 1.25 | 77 | inner_min_valid_frac: float = pydantic.Field(default=0.2, le=1.0) |
| 66 | step_win_m: float = 1.2 | 78 | inner_bound_margin_m: float = pydantic.Field(default=1.25, ge=0.0) |
| 67 | tex_win_m: float = 0.5 | 79 | # step-filter windows (metres) |
| 68 | smooth_offset_m: float = 0.1 | 80 | step_win_m: float = pydantic.Field(default=1.2, gt=0.0) |
| 69 | intensity_weight: float = 1.0 | 81 | tex_win_m: float = pydantic.Field(default=0.5, gt=0.0) |
| 70 | texture_weight: float = 0.6 | 82 | smooth_offset_m: float = pydantic.Field(default=0.1, ge=0.0) |
| 71 | density_weight: float = 0.0 | 83 | # channel fusion |
| 72 | roughness_weight: float = 0.0 | 84 | intensity_weight: float = pydantic.Field(default=1.0, ge=0.0) |
| 73 | z_step_weight: float = 0.0 | 85 | texture_weight: float = pydantic.Field(default=0.6, ge=0.0) |
| 74 | zrange_weight: float = 1.5 | 86 | density_weight: float = pydantic.Field(default=0.0, ge=0.0) |
| 75 | zrange_win_m: float = 0.4 | 87 | roughness_weight: float = pydantic.Field(default=0.4, ge=0.0) |
| 76 | zrange_cap_m: float = 0.15 | 88 | z_step_weight: float = pydantic.Field(default=0.0, ge=0.0) |
| 89 | # z-range (max-min z per pixel) channel | ||
| 90 | zrange_weight: float = pydantic.Field(default=1.5, ge=0.0) | ||
| 91 | zrange_win_m: float = pydantic.Field(default=0.4, gt=0.0) | ||
| 92 | zrange_cap_m: float = pydantic.Field(default=0.15, gt=0.0) | ||
| 77 | zrange_snap: bool = True | 93 | zrange_snap: bool = True |
| 78 | zrange_snap_window_m: float = 0.4 | 94 | zrange_snap_window_m: float = pydantic.Field(default=0.4, ge=0.0) |
| 79 | zrange_snap_max_shift_m: float = 0.25 | 95 | zrange_snap_max_shift_m: float = pydantic.Field(default=0.25, ge=0.0) |
| 80 | zrange_snap_thresh_min_m: float = 0.012 | 96 | zrange_snap_thresh_min_m: float = pydantic.Field(default=0.012, ge=0.0) |
| 81 | zrange_snap_mad_k: float = 4.0 | 97 | zrange_snap_mad_k: float = pydantic.Field(default=4.0, ge=0.0) |
| 82 | zrange_snap_rel_h: float = 0.10 | 98 | zrange_snap_rel_h: float = pydantic.Field(default=0.10, ge=0.0, le=1.0) |
| 83 | zrange_snap_regularize: bool = True | 99 | zrange_snap_regularize: bool = True |
| 84 | zrange_snap_reg_max_dev_m: float = 0.30 | 100 | zrange_snap_reg_max_dev_m: float = pydantic.Field(default=0.30, ge=0.0) |
| 101 | # intensity-snap fallback | ||
| 85 | zrange_intensity_snap: bool = True | 102 | zrange_intensity_snap: bool = True |
| 86 | zrange_isnap_grad_k: float = 3.0 | 103 | zrange_isnap_grad_k: float = pydantic.Field(default=3.0, ge=0.0) |
| 87 | zrange_isnap_min_step: float = 8.0 | 104 | zrange_isnap_min_step: float = pydantic.Field(default=8.0, ge=0.0) |
| 88 | zrange_isnap_paint_guard: bool = True | 105 | zrange_isnap_paint_guard: bool = True |
| 89 | zrange_onset_bonus: float = 1.2 | 106 | # innermost-onset preference |
| 107 | zrange_onset_bonus: float = pydantic.Field(default=1.2, ge=0.0) | ||
| 90 | zrange_onset_smooth: bool = True | 108 | zrange_onset_smooth: bool = True |
| 91 | zrange_guardrail_z_m: float = 0.30 | 109 | zrange_guardrail_z_m: float = pydantic.Field(default=0.30, ge=0.0) |
| 92 | zrange_guardrail_lookback_m: float = 2.0 | 110 | zrange_guardrail_lookback_m: float = pydantic.Field(default=2.0, ge=0.0) |
| 111 | # guardrail-repo hard veto | ||
| 93 | zrange_guardrail_veto: bool = False | 112 | zrange_guardrail_veto: bool = False |
| 94 | zrange_guardrail_veto_conf: float = 0.7 | 113 | zrange_guardrail_veto_conf: float = pydantic.Field(default=0.7, ge=0.0, le=1.0) |
| 95 | zrange_guardrail_veto_margin_m: float = 0.5 | 114 | zrange_guardrail_veto_margin_m: float = pydantic.Field(default=0.5, ge=0.0) |
| 115 | # no Literal: the accepted instance types are the guardrail repo's | ||
| 116 | # vocabulary, carried in its JSON, not a choice this schema owns | ||
| 96 | zrange_guardrail_veto_types: tuple[str, ...] = ("w_beam",) | 117 | zrange_guardrail_veto_types: tuple[str, ...] = ("w_beam",) |
| 97 | zrange_guardrail_veto_walls: bool = False | 118 | zrange_guardrail_veto_walls: bool = False |
| 98 | zrange_resume_weight: float = 3.0 | 119 | # asphalt-resumes-outward penalty |
| 120 | zrange_resume_weight: float = pydantic.Field(default=3.0, ge=0.0) | ||
| 99 | zrange_resume_zone_m: tuple[float, float] = (0.5, 2.5) | 121 | zrange_resume_zone_m: tuple[float, float] = (0.5, 2.5) |
| 100 | zrange_flat_thresh_m: float = 0.015 | 122 | zrange_flat_thresh_m: float = pydantic.Field(default=0.015, ge=0.0) |
| 101 | zrange_resume_tex_k: float = 1.5 | 123 | zrange_resume_tex_k: float = pydantic.Field(default=1.5, ge=0.0) |
| 124 | # shoulder rule | ||
| 102 | zrange_shoulder_rule: bool = True | 125 | zrange_shoulder_rule: bool = True |
| 103 | zrange_shoulder_lookback_m: float = 2.0 | 126 | zrange_shoulder_lookback_m: float = pydantic.Field(default=2.0, ge=0.0) |
| 104 | zrange_shoulder_min_gap_m: float = 0.3 | 127 | zrange_shoulder_min_gap_m: float = pydantic.Field(default=0.3, ge=0.0) |
| 105 | zrange_shoulder_corridor_anchor: bool = True | 128 | zrange_shoulder_corridor_anchor: bool = True |
| 106 | zrange_shoulder_sustain_m: float = 1.5 | 129 | zrange_shoulder_sustain_m: float = pydantic.Field(default=1.5, ge=0.0) |
| 107 | zrange_shoulder_max_search_m: float = 8.0 | 130 | zrange_shoulder_max_search_m: float = pydantic.Field(default=8.0, ge=0.0) |
| 108 | zrange_shoulder_grad_k: float = 2.0 | 131 | zrange_shoulder_grad_k: float = pydantic.Field(default=2.0, ge=0.0) |
| 109 | zrange_shoulder_min_step: float = 6.0 | 132 | zrange_shoulder_min_step: float = pydantic.Field(default=6.0, ge=0.0) |
| 110 | zrange_shoulder_min_width_m: float = 3.0 | 133 | zrange_shoulder_min_width_m: float = pydantic.Field(default=3.0, ge=0.0) |
| 111 | zrange_shoulder_support_m: float = 1.5 | 134 | zrange_shoulder_support_m: float = pydantic.Field(default=1.5, ge=0.0) |
| 112 | zrange_shoulder_support_tol_m: float = 0.6 | 135 | zrange_shoulder_support_tol_m: float = pydantic.Field(default=0.6, ge=0.0) |
| 113 | zrange_shoulder_support_frac: float = 0.5 | 136 | zrange_shoulder_support_frac: float = pydantic.Field(default=0.5, ge=0.0, le=1.0) |
| 114 | zrange_shoulder_consolidate: bool = True | 137 | zrange_shoulder_consolidate: bool = True |
| 115 | zrange_shoulder_revert_isolated: bool = True | 138 | zrange_shoulder_revert_isolated: bool = True |
| 116 | zrange_shoulder_run_smoothness_guard: bool = False | 139 | zrange_shoulder_run_smoothness_guard: bool = False |
| 117 | zrange_shoulder_run_max_step_m: float = 0.046 | 140 | zrange_shoulder_run_max_step_m: float = pydantic.Field(default=0.046, ge=0.0) |
| 141 | # paint veto | ||
| 118 | zrange_paint_veto: bool = True | 142 | zrange_paint_veto: bool = True |
| 119 | zrange_paint_quantile: float = 0.92 | 143 | zrange_paint_quantile: float = pydantic.Field(default=0.92, gt=0.0, lt=1.0) |
| 120 | zrange_paint_max_width_m: float = 0.5 | 144 | zrange_paint_max_width_m: float = pydantic.Field(default=0.5, ge=0.0) |
| 121 | zrange_paint_corridor_margin_m: float = 1.5 | 145 | zrange_paint_corridor_margin_m: float = pydantic.Field(default=1.5, ge=0.0) |
| 146 | # DP inference | ||
| 122 | robust: bool = True | 147 | robust: bool = True |
| 123 | edge_smooth_window_m: float = 2.0 | 148 | edge_smooth_window_m: float = pydantic.Field(default=2.0, ge=0.0) |
| 124 | slope_cheap_m: float = 0.15 | 149 | slope_cheap_m: float = pydantic.Field(default=0.15, ge=0.0) |
| 125 | trans_lin: float = 9.0 | 150 | trans_lin: float = pydantic.Field(default=9.0, ge=0.0) |
| 126 | trans_quad: float = 220.0 | 151 | trans_quad: float = pydantic.Field(default=220.0, ge=0.0) |
| 127 | max_jump_m: float = 0.9 | 152 | max_jump_m: float = pydantic.Field(default=0.9, ge=0.0) |
| 128 | bridge_stiffness: float = 3.0 | 153 | bridge_stiffness: float = pydantic.Field(default=3.0, ge=0.0) |
| 129 | bridge_anchor_min_conf: float = 0.75 | 154 | bridge_anchor_min_conf: float = pydantic.Field(default=0.75, ge=0.0, le=1.0) |
| 130 | invalid_penalty: float = 2.5 | 155 | invalid_penalty: float = pydantic.Field(default=2.5, ge=0.0) |
| 131 | min_valid_frac: float = 0.35 | 156 | min_valid_frac: float = pydantic.Field(default=0.35, ge=0.0, le=1.0) |
| 132 | conf_gain: float = 0.7 | 157 | conf_gain: float = pydantic.Field(default=0.7, ge=0.0) |
| 133 | outer_bound_margin_m: float = 1.5 | 158 | outer_bound_margin_m: float = pydantic.Field(default=1.5, ge=0.0) |
| 134 | outer_median_bound: bool = False | 159 | outer_median_bound: bool = False |
| 135 | outer_median_bound_margin_m: float = 0.5 | 160 | outer_median_bound_margin_m: float = pydantic.Field(default=0.5, ge=0.0) |
| 136 | outer_median_bound_hard: bool = False | 161 | outer_median_bound_hard: bool = False |
| 162 | # vehicle mask | ||
| 137 | vehicle_mask: bool = True | 163 | vehicle_mask: bool = True |
| 138 | vehicle_z_thresh_m: float = 0.02 | 164 | vehicle_z_thresh_m: float = pydantic.Field(default=0.02, ge=0.0) |
| 139 | vehicle_corridor_margin_m: float = 0.5 | 165 | vehicle_corridor_margin_m: float = pydantic.Field(default=0.5, ge=0.0) |
| 140 | vehicle_mask_z_only: bool = False | 166 | vehicle_mask_z_only: bool = False |
| 141 | vehicle_mask_neutral: bool = True | 167 | vehicle_mask_neutral: bool = True |
| 142 | vehicle_mask_seed_guard: bool = False | 168 | vehicle_mask_seed_guard: bool = False |
| 143 | vehicle_mask_baseline_fill: bool = False | 169 | vehicle_mask_baseline_fill: bool = False |
| 144 | edge_support_gate: bool = False | 170 | edge_support_gate: bool = False |
| 171 | # CRF inference | ||
| 145 | use_crf: bool = False | 172 | use_crf: bool = False |
| 146 | crf_second_order: bool = True | 173 | crf_second_order: bool = True |
| 147 | crf_lambda_parallel: float = 4.0 | 174 | crf_lambda_parallel: float = pydantic.Field(default=4.0, ge=0.0) |
| 148 | crf_lambda_curvature: float = 8.0 | 175 | crf_lambda_curvature: float = pydantic.Field(default=8.0, ge=0.0) |
| 149 | crf_lambda_prior: float = 1.5 | 176 | crf_lambda_prior: float = pydantic.Field(default=1.5, ge=0.0) |
| 150 | crf_huber_delta_m: float = 0.10 | 177 | crf_huber_delta_m: float = pydantic.Field(default=0.10, gt=0.0) |
| 151 | crf_delta_cap_m: float = 0.6 | 178 | crf_delta_cap_m: float = pydantic.Field(default=0.6, ge=0.0) |
| 152 | crf_conf_ref: float = 1.0 | 179 | crf_conf_ref: float = pydantic.Field(default=1.0, gt=0.0) |
| 153 | crf_not_observable_support: float = 0.15 | 180 | crf_not_observable_support: float = pydantic.Field(default=0.15, ge=0.0) |
| 154 | crf_support_intensity_ref_dn: float = 40.0 | 181 | crf_support_intensity_ref_dn: float = pydantic.Field(default=40.0, gt=0.0) |
| 155 | crf_support_zrange_ref_m: float = 0.05 | 182 | crf_support_zrange_ref_m: float = pydantic.Field(default=0.05, gt=0.0) |
| 156 | 183 | ||
| 184 | @pydantic.model_validator(mode="after") | ||
| 185 | def _check_intervals(self) -> AsphaltEdgeImageConfig: | ||
| 186 | """Every ``(low, high)`` interval field must be ordered.""" | ||
| 187 | _check_interval("offset_min/offset_max", (self.offset_min, self.offset_max)) | ||
| 188 | for name in ( | ||
| 189 | "left_band", | ||
| 190 | "right_band", | ||
| 191 | "inner_left_band", | ||
| 192 | "inner_right_band", | ||
| 193 | "zrange_resume_zone_m", | ||
| 194 | ): | ||
| 195 | _check_interval(name, getattr(self, name)) | ||
| 196 | return self | ||
| 157 | 197 | ||
| 158 | class GutterConfigModel(config_loader.ConfigModel): | 198 | |
| 159 | """``gutter`` block of ``asphalt_edge.default.json``.""" | 199 | class AsphaltEdgeGutterConfig(config_loader.ConfigModel): |
| 200 | """``gutter`` block: thresholds of the gutter-lip cross-section scan.""" | ||
| 160 | 201 | ||
| 161 | enabled: bool = True | 202 | enabled: bool = True |
| 162 | station_step: float = 1.0 | 203 | station_step: float = pydantic.Field(default=1.0, gt=0.0) |
| 163 | half_window_m: float = 0.75 | 204 | half_window_m: float = pydantic.Field(default=0.75, gt=0.0) |
| 164 | step_thr: float = 4000.0 | 205 | step_thr: float = pydantic.Field(default=4000.0, ge=0.0) |
| 165 | rough_mult: float = 2.5 | 206 | rough_mult: float = pydantic.Field(default=2.5, ge=0.0) |
| 166 | max_gutter_m: float = 1.5 | 207 | max_gutter_m: float = pydantic.Field(default=1.5, gt=0.0) |
| 167 | min_conf: float = 0.35 | 208 | min_conf: float = pydantic.Field(default=0.35, ge=0.0, le=1.0) |
| 168 | 209 | ||
| 169 | 210 | ||
| 170 | class AsphaltEdgeConfig(config_loader.ConfigModel): | 211 | class AsphaltEdgeConfig(config_loader.ConfigModel): |
| 171 | """Root schema mirroring ``asphalt_edge.default.json``.""" | 212 | """Root schema mirroring ``asphalt_edge.default.json``.""" |
| 172 | 213 | ||
| 173 | station_step: float = 0.5 | 214 | station_step: float = pydantic.Field(default=0.5, gt=0.0) |
| 174 | half_thickness: float = 0.5 | 215 | half_thickness: float = pydantic.Field(default=0.5, gt=0.0) |
| 175 | bin_width: float = 0.1 | 216 | bin_width: float = pydantic.Field(default=0.1, gt=0.0) |
| 176 | off_range: tuple[float, float] = (-15.0, 15.0) | 217 | off_range: tuple[float, float] = (-20.0, 20.0) |
| 177 | search_in: float = 4.5 | 218 | search_in: float = pydantic.Field(default=4.5, ge=0.0) |
| 178 | search_out: float = 0.3 | 219 | search_out: float = pydantic.Field(default=0.3, ge=0.0) |
| 179 | band_m: float = 1.0 | 220 | band_m: float = pydantic.Field(default=1.0, gt=0.0) |
| 180 | weights: FeatureWeightsConfig = FeatureWeightsConfig() | 221 | weights: AsphaltEdgeWeightsConfig = AsphaltEdgeWeightsConfig() |
| 181 | image: ImageEdgeConfigModel = ImageEdgeConfigModel() | 222 | image: AsphaltEdgeImageConfig = AsphaltEdgeImageConfig() |
| 182 | gutter: GutterConfigModel = GutterConfigModel() | 223 | gutter: AsphaltEdgeGutterConfig = AsphaltEdgeGutterConfig() |
| 183 | min_confidence: float = 0.25 | 224 | min_confidence: float = pydantic.Field(default=0.25, ge=0.0, le=1.0) |
| 184 | median_window_m: float = 15.0 | 225 | median_window_m: float = pydantic.Field(default=15.0, ge=0.0) |
| 185 | mad_k: float = 3.5 | 226 | mad_k: float = pydantic.Field(default=3.0, gt=0.0) |
| 186 | max_interp_gap_m: float = 5.0 | 227 | max_interp_gap_m: float = pydantic.Field(default=5.0, ge=0.0) |
| 187 | max_interp_offset_jump_m: float = 0.5 | 228 | max_interp_offset_jump_m: float = pydantic.Field(default=0.5, ge=0.0) |
| 188 | max_offset_rate: float = 0.6 | 229 | max_offset_rate: float = pydantic.Field(default=0.6, ge=0.0) |
| 189 | min_run_m: float = 3.0 | 230 | min_run_m: float = pydantic.Field(default=3.0, ge=0.0) |
| 190 | end_stub_m: float = 5.0 | 231 | end_stub_m: float = pydantic.Field(default=5.0, ge=0.0) |
| 191 | savgol_window_m: float = 11.0 | 232 | savgol_window_m: float = pydantic.Field(default=11.0, ge=0.0) |
| 192 | savgol_order: int = 2 | 233 | savgol_order: int = pydantic.Field(default=2, ge=1) |
| 193 | max_points: int = 7_000_000 | 234 | max_points: int = pydantic.Field(default=7_000_000, gt=0) |
| 194 | hash_round_units_per_m: float = 1000.0 | 235 | hash_round_units_per_m: float = pydantic.Field(default=1000.0, gt=0.0) |
| 195 | min_carriageway_m: float = 2.0 | 236 | min_carriageway_m: float = pydantic.Field(default=2.0, ge=0.0) |
| 237 | |||
| 238 | @pydantic.model_validator(mode="after") | ||
| 239 | def _check_off_range(self) -> AsphaltEdgeConfig: | ||
| 240 | """``off_range`` must be ordered.""" | ||
| 241 | _check_interval("off_range", self.off_range) | ||
| 242 | return self | ||
| 196 | 243 | ||
| 197 | 244 | ||
| 198 | def _to_runtime(model: AsphaltEdgeConfig) -> Config: | 245 | def _to_runtime(model: AsphaltEdgeConfig) -> Config: |
| 199 | """Copy a validated model onto the mutable runtime dataclasses.""" | 246 | """Copy a validated model onto the mutable runtime dataclasses.""" |
| 205 | **data, | 252 | **data, |
| 206 | ) | 253 | ) |
| 207 | 254 | ||
| 208 | 255 | ||
| 209 | def config_from_dict(raw_config: dict[str, Any]) -> Config: | 256 | def _load_model( |
| 210 | """Build a typed :class:`Config` from a (possibly partial) mapping.""" | 257 | *, |
| 258 | overrides: Mapping[str, Any] | None = None, | ||
| 259 | config_path: str | Path | None = None, | ||
| 260 | ) -> AsphaltEdgeConfig: | ||
| 261 | """Load the packaged defaults (or ``config_path``) and apply ``overrides``.""" | ||
| 262 | model = config_loader.load_config( | ||
| 263 | AsphaltEdgeConfig, | ||
| 264 | package=_PACKAGE_NAME, | ||
| 265 | filename=_DEFAULT_FILENAME, | ||
| 266 | overrides=overrides, | ||
| 267 | config_path=config_path, | ||
| 268 | context=_CONTEXT, | ||
| 269 | error_cls=AsphaltEdgeConfigError, | ||
| 270 | ) | ||
| 271 | if config_path is not None: | ||
| 272 | logger.info("Config file applied: %s", config_path) | ||
| 273 | if overrides: | ||
| 274 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | ||
| 275 | return model | ||
| 276 | |||
| 277 | |||
| 278 | def load_default_config_dict() -> dict[str, Any]: | ||
| 279 | """Return the packaged default config as a plain nested ``dict``.""" | ||
| 280 | return _load_model().model_dump() | ||
| 281 | |||
| 282 | |||
| 283 | def config_from_dict(raw_config: Mapping[str, Any]) -> Config: | ||
| 284 | """Build a typed :class:`~.detector.Config` from a (possibly partial) mapping. | ||
| 285 | |||
| 286 | Keys the mapping omits fall back to the model defaults, which are identical | ||
| 287 | to the packaged JSON. | ||
| 288 | """ | ||
| 211 | model = config_loader.validate_config( | 289 | model = config_loader.validate_config( |
| 212 | AsphaltEdgeConfig, | 290 | AsphaltEdgeConfig, |
| 213 | raw_config, | 291 | raw_config, |
| 214 | context=_CONTEXT, | 292 | context=_CONTEXT, |
| 219 | 297 | ||
| 220 | def load_asphalt_edge_config( | 298 | def load_asphalt_edge_config( |
| 221 | config_path: str | Path | None = None, | 299 | config_path: str | Path | None = None, |
| 222 | *, | 300 | *, |
| 223 | overrides: dict[str, Any] | None = None, | 301 | overrides: Mapping[str, Any] | None = None, |
| 224 | ) -> Config: | 302 | ) -> Config: |
| 225 | """Load the packaged default config, apply optional overrides, return a Config.""" | 303 | """Load the packaged default config, apply optional overrides, return a Config. |
| 226 | model = config_loader.load_config( | 304 | |
| 227 | AsphaltEdgeConfig, | 305 | ``config_path`` REPLACES the packaged defaults; keys it omits fall back to |
| 228 | package=_PACKAGE, | 306 | the model defaults. |
| 229 | filename=_DEFAULT_CONFIG_FILENAME, | 307 | """ |
| 230 | overrides=overrides, | 308 | return _to_runtime(_load_model(overrides=overrides, config_path=config_path)) |
| 231 | config_path=config_path, | 309 | |
| 232 | context=_CONTEXT, | 310 | |
| 233 | error_cls=AsphaltEdgeConfigError, | 311 | def parse_set_overrides(raw_overrides: Sequence[str] | None) -> dict[str, Any]: |
| 312 | """Parse repeatable ``--set KEY=VALUE`` strings into an override mapping. | ||
| 313 | |||
| 314 | Thin binding of :func:`iolabs.common.config_loader.parse_set_overrides` to | ||
| 315 | the asphalt edge error class. This config has both top-level keys and | ||
| 316 | sections, so both ``--set station_step=0.5`` and ``--set image.robust=false`` | ||
| 317 | are accepted; dotted keys expand into nested mappings. | ||
| 318 | |||
| 319 | Args: | ||
| 320 | raw_overrides: Raw ``KEY=VALUE`` strings from ``--set``, or ``None``. | ||
| 321 | |||
| 322 | Returns: | ||
| 323 | A mapping ready to pass as ``overrides``; later duplicates win. | ||
| 324 | |||
| 325 | Raises: | ||
| 326 | AsphaltEdgeConfigError: An override is missing its ``=``, or two | ||
| 327 | overrides disagree about whether a path segment is a section. | ||
| 328 | """ | ||
| 329 | dotted = [item for item in raw_overrides or [] if "." in item.split("=", 1)[0]] | ||
| 330 | flat = [item for item in raw_overrides or [] if "." not in item.split("=", 1)[0]] | ||
| 331 | return config_loader.deep_merge_dicts( | ||
| 332 | config_loader.parse_set_overrides(flat, error_cls=AsphaltEdgeConfigError), | ||
| 333 | config_loader.parse_set_overrides( | ||
| 334 | dotted, nested=True, error_cls=AsphaltEdgeConfigError | ||
| 335 | ), | ||
| 234 | ) | 336 | ) |
| 235 | if overrides: | ||
| 236 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | ||
| 237 | return _to_runtime(model) |
| 1 | """Packaged-default configuration loader for asphalt edge detection. | 1 | """Packaged-default configuration for asphalt edge detection. |
| 2 | 2 | ||
| 3 | Defaults live in the force-included ``asphalt_edge.default.json``. A pydantic | 3 | The schema is `AsphaltEdgeConfig` (a `config_loader.ConfigModel`), mirroring |
| 4 | model tree derived from :class:`iolabs.common.config_loader.ConfigModel` is | 4 | `asphalt_edge.default.json` key for key. |
| 5 | the schema: nested JSON sections are nested models, field names are JSON keys, | 5 | |
| 6 | and unknown keys are rejected. Validated values are copied onto the runtime | 6 | Adding a config key means adding the field to the model and the same key to |
| 7 | dataclasses (:class:`~.detector.Config`, :class:`~.features.FeatureWeights`, | 7 | `asphalt_edge.default.json` โ plus, here, the same field to the matching |
| 8 | :class:`~.image_edge.ImageEdgeConfig`, :class:`~.gutter.GutterConfig`) because | 8 | runtime dataclass, because :func:`_to_runtime` copies every model field onto |
| 9 | the algorithm, tests and scripts mutate those carriers. | 9 | the mutable carriers the algorithm, scripts and tests pass around |
| 10 | 10 | (:class:`~.detector.Config`, :class:`~.features.FeatureWeights`, | |
| 11 | To add a config key: add the field to the runtime dataclass *and* to the | 11 | :class:`~.image_edge.ImageEdgeConfig`, :class:`~.gutter.GutterConfig`). |
| 12 | matching ``ConfigModel`` subclass here, with the same name, type and default | 12 | Unknown keys are rejected. |
| 13 | (``_to_runtime`` passes every model field on to the dataclass, so a field on | 13 | |
| 14 | only one side raises). Add it to the packaged JSON only when the shipped | 14 | :func:`load_asphalt_edge_config` and :func:`config_from_dict` return that |
| 15 | default differs from the field default; ``test_config`` guards the parity. | 15 | runtime :class:`~.detector.Config` dataclass; :func:`load_default_config_dict` |
| 16 | returns the packaged defaults as a plain ``dict``. | ||
| 16 | """ | 17 | """ |
| 17 | 18 | ||
| 18 | from __future__ import annotations | 19 | from __future__ import annotations |
| 19 | 20 | ||
| 20 | import logging | 21 | import logging |
| 22 | from collections.abc import Mapping, Sequence | ||
| 21 | from pathlib import Path | 23 | from pathlib import Path |
| 22 | from typing import Any | 24 | from typing import Any |
| 23 | 25 | ||
| 26 | import pydantic | ||
| 24 | from iolabs.common import config_loader | 27 | from iolabs.common import config_loader |
| 25 | 28 | ||
| 26 | from .detector import Config | 29 | from .detector import Config |
| 27 | from .features import FeatureWeights | 30 | from .features import FeatureWeights |
| 10 | 10 | ||
| 11 | from importlib.metadata import PackageNotFoundError, version | 11 | from importlib.metadata import PackageNotFoundError, version |
| 12 | 12 | ||
| 13 | from .config import ( | 13 | from .config import ( |
| 14 | AsphaltEdgeConfig, | ||
| 14 | AsphaltEdgeConfigError, | 15 | AsphaltEdgeConfigError, |
| 15 | config_from_dict, | 16 | config_from_dict, |
| 16 | load_asphalt_edge_config, | 17 | load_asphalt_edge_config, |
| 18 | load_default_config_dict, | ||
| 19 | parse_set_overrides, | ||
| 17 | ) | 20 | ) |
| 18 | from .detector import Config, EdgeResult, detect_edges | 21 | from .detector import Config, EdgeResult, detect_edges |
| 19 | from .polyline import FLAG_GAP, FLAG_INTERPOLATED, FLAG_MEASURED | 22 | from .polyline import FLAG_GAP, FLAG_INTERPOLATED, FLAG_MEASURED |
| 20 | 23 |
| 26 | __all__ = [ | 29 | __all__ = [ |
| 27 | "FLAG_GAP", | 30 | "FLAG_GAP", |
| 28 | "FLAG_INTERPOLATED", | 31 | "FLAG_INTERPOLATED", |
| 29 | "FLAG_MEASURED", | 32 | "FLAG_MEASURED", |
| 33 | "AsphaltEdgeConfig", | ||
| 30 | "AsphaltEdgeConfigError", | 34 | "AsphaltEdgeConfigError", |
| 31 | "Config", | 35 | "Config", |
| 32 | "EdgeResult", | 36 | "EdgeResult", |
| 33 | "__version__", | 37 | "__version__", |
| 34 | "config_from_dict", | 38 | "config_from_dict", |
| 35 | "detect_edges", | 39 | "detect_edges", |
| 36 | "load_asphalt_edge_config", | 40 | "load_asphalt_edge_config", |
| 41 | "load_default_config_dict", | ||
| 42 | "parse_set_overrides", | ||
| 37 | ] | 43 | ] |
| 37 | 37 | ||
| 38 | Defaults of a loaded config come from ``asphalt_edge.default.json`` via | 38 | Defaults of a loaded config come from ``asphalt_edge.default.json`` via |
| 39 | :func:`.config_from_dict`. Nested rectified-image, gutter and weight | 39 | :func:`.config_from_dict`. Nested rectified-image, gutter and weight |
| 40 | blocks live in their own dataclasses reachable from here. To add a key, | 40 | blocks live in their own dataclasses reachable from here. To add a key, |
| 41 | add the field here *and* to the matching ``ConfigModel`` in ``config.py`` | 41 | add the field here *and* to `AsphaltEdgeConfig` in ``config.py`` *and* to |
| 42 | with the same name, type and default; the packaged JSON only needs it when | 42 | the packaged JSON, with the same name, type and default. |
| 43 | the shipped default differs. | ||
| 44 | """ | 43 | """ |
| 45 | 44 | ||
| 46 | station_step: float = 0.5 | 45 | station_step: float = 0.5 |
| 47 | half_thickness: float = 0.5 | 46 | half_thickness: float = 0.5 |
| 48 | bin_width: float = 0.1 | 47 | bin_width: float = 0.1 |
| 49 | off_range: tuple[float, float] = (-15.0, 15.0) | 48 | off_range: tuple[float, float] = (-20.0, 20.0) |
| 50 | # Search band relative to the surface-extent outer bound: the material edge | 49 | # Search band relative to the surface-extent outer bound: the material edge |
| 51 | # lies up to ``search_in`` metres inside the data extent (dark shoulder + | 50 | # lies up to ``search_in`` metres inside the data extent (dark shoulder + |
| 52 | # bright verge), with a small ``search_out`` slack for prior jitter. | 51 | # bright verge), with a small ``search_out`` slack for prior jitter. |
| 53 | # (Fallback point scorer, used only when no tile is supplied.) | 52 | # (Fallback point scorer, used only when no tile is supplied.) |
| 62 | # this dataset (no confirmed concrete gutter); never drives the edge. | 61 | # this dataset (no confirmed concrete gutter); never drives the edge. |
| 63 | gutter: GutterConfig = field(default_factory=GutterConfig) | 62 | gutter: GutterConfig = field(default_factory=GutterConfig) |
| 64 | min_confidence: float = 0.25 | 63 | min_confidence: float = 0.25 |
| 65 | median_window_m: float = 15.0 | 64 | median_window_m: float = 15.0 |
| 66 | mad_k: float = 3.5 | 65 | mad_k: float = 3.0 |
| 67 | max_interp_gap_m: float = 5.0 | 66 | max_interp_gap_m: float = 5.0 |
| 68 | max_interp_offset_jump_m: float = 0.5 | 67 | max_interp_offset_jump_m: float = 0.5 |
| 69 | max_offset_rate: float = 0.6 | 68 | max_offset_rate: float = 0.6 |
| 70 | min_run_m: float = 3.0 | 69 | min_run_m: float = 3.0 |
| 98 | # density/roughness change and a detrended-z kink rather than a bright verge. | 98 | # density/roughness change and a detrended-z kink rather than a bright verge. |
| 99 | intensity_weight: float = 1.0 | 99 | intensity_weight: float = 1.0 |
| 100 | texture_weight: float = 0.6 | 100 | texture_weight: float = 0.6 |
| 101 | density_weight: float = 0.0 # point density drop (asphalt dense -> sparse) | 101 | density_weight: float = 0.0 # point density drop (asphalt dense -> sparse) |
| 102 | roughness_weight: float = 0.0 # outward rise in per-cell z-roughness | 102 | roughness_weight: float = 0.4 # outward rise in per-cell z-roughness |
| 103 | z_step_weight: float = 0.0 # outward rise in per-cell mean elevation (kink) | 103 | z_step_weight: float = 0.0 # outward rise in per-cell mean elevation (kink) |
| 104 | # z-range (maxโmin z per pixel) channel: sharp pavement-edge ridge. | 104 | # z-range (maxโmin z per pixel) channel: sharp pavement-edge ridge. |
| 105 | zrange_weight: float = 1.5 | 105 | zrange_weight: float = 1.5 |
| 106 | zrange_win_m: float = 0.4 # outward-step window; z transition is sharp | 106 | zrange_win_m: float = 0.4 # outward-step window; z transition is sharp |
| 5 | "off_range": [-20.0, 20.0], | 5 | "off_range": [-20.0, 20.0], |
| 6 | "search_in": 4.5, | 6 | "search_in": 4.5, |
| 7 | "search_out": 0.3, | 7 | "search_out": 0.3, |
| 8 | "band_m": 1.0, | 8 | "band_m": 1.0, |
| 9 | "min_confidence": 0.25, | ||
| 10 | "median_window_m": 15.0, | ||
| 11 | "mad_k": 3.0, | ||
| 12 | "max_interp_gap_m": 5.0, | ||
| 13 | "max_interp_offset_jump_m": 0.5, | ||
| 14 | "max_offset_rate": 0.6, | ||
| 15 | "min_run_m": 3.0, | ||
| 16 | "end_stub_m": 5.0, | ||
| 17 | "savgol_window_m": 11.0, | ||
| 18 | "savgol_order": 2, | ||
| 19 | "max_points": 7000000, | ||
| 20 | "hash_round_units_per_m": 1000.0, | ||
| 21 | "min_carriageway_m": 2.0, | ||
| 22 | "weights": { | 9 | "weights": { |
| 23 | "intensity_step": 2.5, | 10 | "intensity_step": 2.5, |
| 24 | "density_drop": 2.0, | 11 | "density_drop": 2.0, |
| 25 | "z_rise": 1.0, | 12 | "z_rise": 1.0, |
| 45 | "texture_weight": 0.6, | 32 | "texture_weight": 0.6, |
| 46 | "density_weight": 0.0, | 33 | "density_weight": 0.0, |
| 47 | "roughness_weight": 0.4, | 34 | "roughness_weight": 0.4, |
| 48 | "z_step_weight": 0.0, | 35 | "z_step_weight": 0.0, |
| 36 | "zrange_weight": 1.5, | ||
| 37 | "zrange_win_m": 0.4, | ||
| 38 | "zrange_cap_m": 0.15, | ||
| 39 | "zrange_snap": true, | ||
| 40 | "zrange_snap_window_m": 0.4, | ||
| 41 | "zrange_snap_max_shift_m": 0.25, | ||
| 42 | "zrange_snap_thresh_min_m": 0.012, | ||
| 43 | "zrange_snap_mad_k": 4.0, | ||
| 44 | "zrange_snap_rel_h": 0.1, | ||
| 45 | "zrange_snap_regularize": true, | ||
| 46 | "zrange_snap_reg_max_dev_m": 0.3, | ||
| 47 | "zrange_intensity_snap": true, | ||
| 48 | "zrange_isnap_grad_k": 3.0, | ||
| 49 | "zrange_isnap_min_step": 8.0, | ||
| 50 | "zrange_isnap_paint_guard": true, | ||
| 51 | "zrange_onset_bonus": 1.2, | ||
| 52 | "zrange_onset_smooth": true, | ||
| 53 | "zrange_guardrail_z_m": 0.3, | ||
| 54 | "zrange_guardrail_lookback_m": 2.0, | ||
| 55 | "zrange_guardrail_veto": false, | ||
| 56 | "zrange_guardrail_veto_conf": 0.7, | ||
| 57 | "zrange_guardrail_veto_margin_m": 0.5, | ||
| 58 | "zrange_guardrail_veto_types": ["w_beam"], | ||
| 59 | "zrange_guardrail_veto_walls": false, | ||
| 60 | "zrange_resume_weight": 3.0, | ||
| 61 | "zrange_resume_zone_m": [0.5, 2.5], | ||
| 62 | "zrange_flat_thresh_m": 0.015, | ||
| 63 | "zrange_resume_tex_k": 1.5, | ||
| 64 | "zrange_shoulder_rule": true, | ||
| 65 | "zrange_shoulder_lookback_m": 2.0, | ||
| 66 | "zrange_shoulder_min_gap_m": 0.3, | ||
| 67 | "zrange_shoulder_corridor_anchor": true, | ||
| 68 | "zrange_shoulder_sustain_m": 1.5, | ||
| 69 | "zrange_shoulder_max_search_m": 8.0, | ||
| 70 | "zrange_shoulder_grad_k": 2.0, | ||
| 71 | "zrange_shoulder_min_step": 6.0, | ||
| 72 | "zrange_shoulder_min_width_m": 3.0, | ||
| 73 | "zrange_shoulder_support_m": 1.5, | ||
| 74 | "zrange_shoulder_support_tol_m": 0.6, | ||
| 75 | "zrange_shoulder_support_frac": 0.5, | ||
| 76 | "zrange_shoulder_consolidate": true, | ||
| 77 | "zrange_shoulder_revert_isolated": true, | ||
| 78 | "zrange_shoulder_run_smoothness_guard": false, | ||
| 79 | "zrange_shoulder_run_max_step_m": 0.046, | ||
| 80 | "zrange_paint_veto": true, | ||
| 81 | "zrange_paint_quantile": 0.92, | ||
| 82 | "zrange_paint_max_width_m": 0.5, | ||
| 83 | "zrange_paint_corridor_margin_m": 1.5, | ||
| 49 | "robust": true, | 84 | "robust": true, |
| 50 | "edge_smooth_window_m": 2.0, | 85 | "edge_smooth_window_m": 2.0, |
| 51 | "slope_cheap_m": 0.15, | 86 | "slope_cheap_m": 0.15, |
| 52 | "trans_lin": 9.0, | 87 | "trans_lin": 9.0, |
| 53 | "trans_quad": 220.0, | 88 | "trans_quad": 220.0, |
| 54 | "max_jump_m": 0.9, | 89 | "max_jump_m": 0.9, |
| 90 | "bridge_stiffness": 3.0, | ||
| 91 | "bridge_anchor_min_conf": 0.75, | ||
| 55 | "invalid_penalty": 2.5, | 92 | "invalid_penalty": 2.5, |
| 56 | "min_valid_frac": 0.35, | 93 | "min_valid_frac": 0.35, |
| 57 | "conf_gain": 0.7, | 94 | "conf_gain": 0.7, |
| 58 | "outer_bound_margin_m": 1.5 | 95 | "outer_bound_margin_m": 1.5, |
| 96 | "outer_median_bound": false, | ||
| 97 | "outer_median_bound_margin_m": 0.5, | ||
| 98 | "outer_median_bound_hard": false, | ||
| 99 | "vehicle_mask": true, | ||
| 100 | "vehicle_z_thresh_m": 0.02, | ||
| 101 | "vehicle_corridor_margin_m": 0.5, | ||
| 102 | "vehicle_mask_z_only": false, | ||
| 103 | "vehicle_mask_neutral": true, | ||
| 104 | "vehicle_mask_seed_guard": false, | ||
| 105 | "vehicle_mask_baseline_fill": false, | ||
| 106 | "edge_support_gate": false, | ||
| 107 | "use_crf": false, | ||
| 108 | "crf_second_order": true, | ||
| 109 | "crf_lambda_parallel": 4.0, | ||
| 110 | "crf_lambda_curvature": 8.0, | ||
| 111 | "crf_lambda_prior": 1.5, | ||
| 112 | "crf_huber_delta_m": 0.1, | ||
| 113 | "crf_delta_cap_m": 0.6, | ||
| 114 | "crf_conf_ref": 1.0, | ||
| 115 | "crf_not_observable_support": 0.15, | ||
| 116 | "crf_support_intensity_ref_dn": 40.0, | ||
| 117 | "crf_support_zrange_ref_m": 0.05 | ||
| 59 | }, | 118 | }, |
| 60 | "gutter": { | 119 | "gutter": { |
| 61 | "enabled": true, | 120 | "enabled": true, |
| 62 | "station_step": 1.0, | 121 | "station_step": 1.0, |
| 64 | "step_thr": 4000.0, | 123 | "step_thr": 4000.0, |
| 65 | "rough_mult": 2.5, | 124 | "rough_mult": 2.5, |
| 66 | "max_gutter_m": 1.5, | 125 | "max_gutter_m": 1.5, |
| 67 | "min_conf": 0.35 | 126 | "min_conf": 0.35 |
| 68 | } | 127 | }, |
| 128 | "min_confidence": 0.25, | ||
| 129 | "median_window_m": 15.0, | ||
| 130 | "mad_k": 3.0, | ||
| 131 | "max_interp_gap_m": 5.0, | ||
| 132 | "max_interp_offset_jump_m": 0.5, | ||
| 133 | "max_offset_rate": 0.6, | ||
| 134 | "min_run_m": 3.0, | ||
| 135 | "end_stub_m": 5.0, | ||
| 136 | "savgol_window_m": 11.0, | ||
| 137 | "savgol_order": 2, | ||
| 138 | "max_points": 7000000, | ||
| 139 | "hash_round_units_per_m": 1000.0, | ||
| 140 | "min_carriageway_m": 2.0 | ||
| 69 | } | 141 | } |
| 2 | 2 | ||
| 3 | from __future__ import annotations | 3 | from __future__ import annotations |
| 4 | 4 | ||
| 5 | import dataclasses | 5 | import dataclasses |
| 6 | import json | ||
| 6 | import typing | 7 | import typing |
| 7 | from pathlib import Path | 8 | from pathlib import Path |
| 8 | 9 | ||
| 9 | import pytest | 10 | import pytest |
| 11 | 12 | ||
| 12 | from iolabs_point_cloud_detection_asphaltedge.config import ( | 13 | from iolabs_point_cloud_detection_asphaltedge.config import ( |
| 13 | AsphaltEdgeConfig, | 14 | AsphaltEdgeConfig, |
| 14 | AsphaltEdgeConfigError, | 15 | AsphaltEdgeConfigError, |
| 15 | FeatureWeightsConfig, | 16 | AsphaltEdgeGutterConfig, |
| 16 | GutterConfigModel, | 17 | AsphaltEdgeImageConfig, |
| 17 | ImageEdgeConfigModel, | 18 | AsphaltEdgeWeightsConfig, |
| 18 | config_from_dict, | 19 | config_from_dict, |
| 19 | load_asphalt_edge_config, | 20 | load_asphalt_edge_config, |
| 21 | load_default_config_dict, | ||
| 22 | parse_set_overrides, | ||
| 20 | ) | 23 | ) |
| 21 | from iolabs_point_cloud_detection_asphaltedge.detector import Config | 24 | from iolabs_point_cloud_detection_asphaltedge.detector import Config |
| 22 | from iolabs_point_cloud_detection_asphaltedge.features import FeatureWeights | 25 | from iolabs_point_cloud_detection_asphaltedge.features import FeatureWeights |
| 23 | from iolabs_point_cloud_detection_asphaltedge.gutter import GutterConfig | 26 | from iolabs_point_cloud_detection_asphaltedge.gutter import GutterConfig |
| 24 | from iolabs_point_cloud_detection_asphaltedge.image_edge import ImageEdgeConfig | 27 | from iolabs_point_cloud_detection_asphaltedge.image_edge import ImageEdgeConfig |
| 25 | 28 | ||
| 26 | 29 | ||
| 27 | def test_error_class_is_config_error_and_value_error() -> None: | 30 | def _packaged_json() -> dict: |
| 28 | assert issubclass(AsphaltEdgeConfigError, config_loader.ConfigError) | 31 | path = config_loader.default_config_path( |
| 29 | assert issubclass(AsphaltEdgeConfigError, ValueError) | 32 | "iolabs_point_cloud_detection_asphaltedge", "asphalt_edge.default.json" |
| 33 | ) | ||
| 34 | return json.loads(path.read_text(encoding="utf-8")) | ||
| 35 | |||
| 30 | 36 | ||
| 37 | def _as_json(config: dict) -> dict: | ||
| 38 | """Normalise a ``model_dump`` (tuples) to JSON types (lists).""" | ||
| 39 | return json.loads(json.dumps(config)) | ||
| 31 | 40 | ||
| 32 | def test_load_packaged_defaults_with_zero_overrides() -> None: | 41 | |
| 42 | def test_model_defaults_match_packaged_json() -> None: | ||
| 43 | assert _as_json(AsphaltEdgeConfig().model_dump()) == _packaged_json() | ||
| 44 | |||
| 45 | |||
| 46 | def test_load_asphalt_edge_config_returns_packaged_defaults() -> None: | ||
| 33 | cfg = load_asphalt_edge_config() | 47 | cfg = load_asphalt_edge_config() |
| 34 | assert isinstance(cfg, Config) | 48 | assert isinstance(cfg, Config) |
| 49 | assert dataclasses.asdict(cfg) == AsphaltEdgeConfig().model_dump() | ||
| 35 | assert cfg.station_step == 0.5 | 50 | assert cfg.station_step == 0.5 |
| 36 | assert cfg.off_range == (-20.0, 20.0) | 51 | assert cfg.off_range == (-20.0, 20.0) |
| 37 | assert cfg.mad_k == 3.0 | 52 | assert cfg.mad_k == 3.0 |
| 38 | assert cfg.weights.intensity_step == 2.5 | 53 | assert cfg.weights.intensity_step == 2.5 |
| 39 | assert cfg.image.left_band == (1.0, 17.0) | 54 | assert cfg.image.left_band == (1.0, 17.0) |
| 40 | assert cfg.image.roughness_weight == 0.4 | 55 | assert cfg.image.roughness_weight == 0.4 |
| 56 | assert cfg.image.zrange_weight == 1.5 | ||
| 41 | assert cfg.image.robust is True | 57 | assert cfg.image.robust is True |
| 42 | assert cfg.gutter.enabled is True | 58 | assert cfg.gutter.enabled is True |
| 43 | assert cfg.gutter.step_thr == 4000.0 | 59 | assert cfg.gutter.step_thr == 4000.0 |
| 44 | # Dataclass-only knobs (not in the packaged JSON) keep their class defaults. | ||
| 45 | assert cfg.image.zrange_weight == 1.5 | ||
| 46 | assert cfg.polyline.min_confidence == cfg.min_confidence | 60 | assert cfg.polyline.min_confidence == cfg.min_confidence |
| 47 | 61 | ||
| 48 | 62 | ||
| 49 | def test_overrides_deep_merge_nested_sections() -> None: | 63 | def test_load_default_config_dict_matches_packaged_json() -> None: |
| 64 | assert _as_json(load_default_config_dict()) == _packaged_json() | ||
| 65 | |||
| 66 | |||
| 67 | def test_error_class_is_config_error() -> None: | ||
| 68 | assert issubclass(AsphaltEdgeConfigError, config_loader.ConfigError) | ||
| 69 | assert issubclass(AsphaltEdgeConfigError, ValueError) | ||
| 70 | |||
| 71 | |||
| 72 | def test_overrides_deep_merge_onto_defaults() -> None: | ||
| 50 | cfg = load_asphalt_edge_config( | 73 | cfg = load_asphalt_edge_config( |
| 51 | overrides={ | 74 | overrides={ |
| 52 | "min_confidence": 0.4, | 75 | "min_confidence": 0.4, |
| 53 | "weights": {"z_rise": 9.0}, | 76 | "weights": {"z_rise": 9.0}, |
| 64 | assert cfg.gutter.enabled is False | 87 | assert cfg.gutter.enabled is False |
| 65 | assert cfg.gutter.min_conf == 0.35 | 88 | assert cfg.gutter.min_conf == 0.35 |
| 66 | 89 | ||
| 67 | 90 | ||
| 68 | def test_unknown_top_level_key_rejected() -> None: | 91 | def test_unknown_top_level_key_is_rejected() -> None: |
| 69 | with pytest.raises(AsphaltEdgeConfigError, match="not_a_key") as excinfo: | 92 | with pytest.raises(AsphaltEdgeConfigError, match="not_a_key") as excinfo: |
| 70 | load_asphalt_edge_config(overrides={"not_a_key": 1}) | 93 | load_asphalt_edge_config(overrides={"not_a_key": 1}) |
| 71 | message = str(excinfo.value) | 94 | message = str(excinfo.value) |
| 72 | assert "Unknown" in message | 95 | assert "Unknown" in message |
| 73 | assert "station_step" in message | 96 | assert "station_step" in message |
| 74 | 97 | ||
| 75 | 98 | ||
| 76 | def test_unknown_nested_key_rejected() -> None: | 99 | def test_unknown_nested_key_is_rejected() -> None: |
| 77 | with pytest.raises(AsphaltEdgeConfigError, match="typo_band") as excinfo: | 100 | with pytest.raises(AsphaltEdgeConfigError, match="typo_band") as excinfo: |
| 78 | load_asphalt_edge_config(overrides={"image": {"typo_band": [0.0, 1.0]}}) | 101 | load_asphalt_edge_config(overrides={"image": {"typo_band": [0.0, 1.0]}}) |
| 79 | message = str(excinfo.value) | 102 | message = str(excinfo.value) |
| 80 | assert "Unknown" in message | 103 | assert "Unknown" in message |
| 81 | assert "left_band" in message | 104 | assert "left_band" in message |
| 82 | 105 | ||
| 83 | 106 | ||
| 107 | def test_set_override_coercion_and_rejection() -> None: | ||
| 108 | overrides = parse_set_overrides( | ||
| 109 | ["station_step=1e3", "image.robust=on", "gutter.min_conf=0.5"] | ||
| 110 | ) | ||
| 111 | assert overrides == { | ||
| 112 | "station_step": 1000.0, | ||
| 113 | "image": {"robust": "on"}, | ||
| 114 | "gutter": {"min_conf": 0.5}, | ||
| 115 | } | ||
| 116 | cfg = load_asphalt_edge_config(overrides=overrides) | ||
| 117 | assert cfg.station_step == 1000.0 | ||
| 118 | assert cfg.image.robust is True | ||
| 119 | assert cfg.gutter.min_conf == 0.5 | ||
| 120 | with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"): | ||
| 121 | load_asphalt_edge_config(overrides=parse_set_overrides(["image.robust=flase"])) | ||
| 122 | with pytest.raises(AsphaltEdgeConfigError, match="Expected"): | ||
| 123 | parse_set_overrides(["station_step"]) | ||
| 124 | |||
| 125 | |||
| 84 | def test_config_from_dict_unknown_key_rejected() -> None: | 126 | def test_config_from_dict_unknown_key_rejected() -> None: |
| 85 | with pytest.raises(AsphaltEdgeConfigError, match="bogus"): | 127 | with pytest.raises(AsphaltEdgeConfigError, match="bogus"): |
| 86 | config_from_dict({"station_step": 0.5, "bogus": 1}) | 128 | config_from_dict({"station_step": 0.5, "bogus": 1}) |
| 87 | 129 | ||
| 88 | 130 | ||
| 89 | def test_bool_string_overrides_coerce() -> None: | ||
| 90 | cfg = load_asphalt_edge_config( | ||
| 91 | overrides={"image": {"robust": "false"}, "gutter": {"enabled": "off"}} | ||
| 92 | ) | ||
| 93 | assert cfg.image.robust is False | ||
| 94 | assert cfg.gutter.enabled is False | ||
| 95 | |||
| 96 | |||
| 97 | def test_invalid_bool_token_rejected() -> None: | 131 | def test_invalid_bool_token_rejected() -> None: |
| 98 | with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"): | 132 | with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"): |
| 99 | load_asphalt_edge_config(overrides={"gutter": {"enabled": "flase"}}) | 133 | load_asphalt_edge_config(overrides={"gutter": {"enabled": "flase"}}) |
| 100 | 134 |
| 103 | with pytest.raises(AsphaltEdgeConfigError, match="Invalid float"): | 137 | with pytest.raises(AsphaltEdgeConfigError, match="Invalid float"): |
| 104 | load_asphalt_edge_config(overrides={"station_step": "abc"}) | 138 | load_asphalt_edge_config(overrides={"station_step": "abc"}) |
| 105 | 139 | ||
| 106 | 140 | ||
| 141 | def test_out_of_range_value_rejected() -> None: | ||
| 142 | with pytest.raises(AsphaltEdgeConfigError, match="station_step"): | ||
| 143 | load_asphalt_edge_config(overrides={"station_step": 0.0}) | ||
| 144 | with pytest.raises(AsphaltEdgeConfigError, match="min_confidence"): | ||
| 145 | load_asphalt_edge_config(overrides={"min_confidence": 1.5}) | ||
| 146 | with pytest.raises(AsphaltEdgeConfigError, match="roughness_weight"): | ||
| 147 | load_asphalt_edge_config(overrides={"image": {"roughness_weight": -1.0}}) | ||
| 148 | |||
| 149 | |||
| 150 | def test_unordered_interval_rejected() -> None: | ||
| 151 | with pytest.raises(AsphaltEdgeConfigError, match="off_range"): | ||
| 152 | config_from_dict({"off_range": [5.0, -5.0]}) | ||
| 153 | with pytest.raises(AsphaltEdgeConfigError, match="left_band"): | ||
| 154 | config_from_dict({"image": {"left_band": [17.0, 1.0]}}) | ||
| 155 | |||
| 156 | |||
| 107 | def test_off_range_list_becomes_float_tuple() -> None: | 157 | def test_off_range_list_becomes_float_tuple() -> None: |
| 108 | cfg = config_from_dict({"off_range": ["-3", "4"]}) | 158 | cfg = config_from_dict({"off_range": ["-3", "4"]}) |
| 109 | assert cfg.off_range == (-3.0, 4.0) | 159 | assert cfg.off_range == (-3.0, 4.0) |
| 110 | 160 |
| 120 | assert isinstance(cfg.weights, FeatureWeights) | 170 | assert isinstance(cfg.weights, FeatureWeights) |
| 121 | assert isinstance(cfg.image, ImageEdgeConfig) | 171 | assert isinstance(cfg.image, ImageEdgeConfig) |
| 122 | assert isinstance(cfg.gutter, GutterConfig) | 172 | assert isinstance(cfg.gutter, GutterConfig) |
| 123 | assert cfg.min_confidence == 0.1 | 173 | assert cfg.min_confidence == 0.1 |
| 124 | # Partial mappings keep dataclass defaults for omitted keys. | 174 | # Partial mappings keep the shipped defaults for omitted keys. |
| 125 | assert cfg.off_range == (-15.0, 15.0) | 175 | assert cfg.off_range == (-20.0, 20.0) |
| 126 | assert cfg.image.roughness_weight == 0.0 | 176 | assert cfg.image.roughness_weight == 0.4 |
| 127 | 177 | ||
| 128 | 178 | ||
| 129 | def test_load_from_config_path(tmp_path: Path) -> None: | 179 | def test_load_from_config_path(tmp_path: Path) -> None: |
| 130 | path = tmp_path / "custom.json" | 180 | path = tmp_path / "custom.json" |
| 143 | @pytest.mark.parametrize( | 193 | @pytest.mark.parametrize( |
| 144 | ("dataclass_cls", "model_cls"), | 194 | ("dataclass_cls", "model_cls"), |
| 145 | [ | 195 | [ |
| 146 | (Config, AsphaltEdgeConfig), | 196 | (Config, AsphaltEdgeConfig), |
| 147 | (FeatureWeights, FeatureWeightsConfig), | 197 | (FeatureWeights, AsphaltEdgeWeightsConfig), |
| 148 | (ImageEdgeConfig, ImageEdgeConfigModel), | 198 | (ImageEdgeConfig, AsphaltEdgeImageConfig), |
| 149 | (GutterConfig, GutterConfigModel), | 199 | (GutterConfig, AsphaltEdgeGutterConfig), |
| 150 | ], | 200 | ], |
| 151 | ) | 201 | ) |
| 152 | def test_model_mirrors_runtime_dataclass(dataclass_cls: type, model_cls: type) -> None: | 202 | def test_model_mirrors_runtime_dataclass(dataclass_cls: type, model_cls: type) -> None: |
| 153 | """Every model field must exist on the dataclass with the same type and default.""" | 203 | """Every model field must exist on the dataclass with the same type and default.""" |
| 1 | # iolabs-point-cloud-detection-asphaltedge | ||
| 2 | |||
| 3 | Asphalt edge detection from highway LIDAR road-surface point clouds: per segment | ||
| 4 | it rectifies the run7 top-down tile into a `(station, offset)` image, tracks the | ||
| 5 | pavementโverge edge with a DP/CRF localizer over the intensity, texture and | ||
| 6 | z-range channels, and returns cleaned per-side polylines with per-station flags. | ||
| 7 | |||
| 8 | - `detect_edges` โ the detector entry point (`detector.py`) | ||
| 9 | - `image_edge.py` โ rectification, evidence channels, DP/CRF edge tracking | ||
| 10 | - `features.py` / `gutter.py` / `polyline.py` โ cross-profile scoring, optional | ||
| 11 | gutter-lip annotation, polyline hygiene | ||
| 12 | - `io.py` / `overlay.py` โ segment/tile/zrange loading, QC overlays | ||
| 13 | - `scripts/run_segments.py` โ the standalone multi-segment runner; the pipeline | ||
| 14 | wrapper is lanefinder's `scripts/pipeline/run_8_asphalt_edges.py` | ||
| 15 | |||
| 16 | Tests: `uv run --extra dev pytest tests/`. | ||
| 17 | |||
| 18 | ## Configuration | ||
| 19 | |||
| 20 | Defaults live in `src/iolabs_point_cloud_detection_asphaltedge/asphalt_edge.default.json`. | ||
| 21 | The schema is `AsphaltEdgeConfig` in `iolabs_point_cloud_detection_asphaltedge.config` | ||
| 22 | (a `config_loader.ConfigModel`); nested JSON sections (`weights`, `image`, | ||
| 23 | `gutter`) are nested models and unknown keys are rejected. **To add a config key: | ||
| 24 | add the field (with its type, default and any `Field` range) to the model, the | ||
| 25 | same key with the same default to the JSON, and the same field to the matching | ||
| 26 | runtime dataclass โ nothing else.** The third step is specific to this package: | ||
| 27 | `config._to_runtime` copies every model field onto the mutable carriers the | ||
| 28 | algorithm passes around (`detector.Config`, `features.FeatureWeights`, | ||
| 29 | `image_edge.ImageEdgeConfig`, `gutter.GutterConfig`), so a field present on only | ||
| 30 | one side raises, and `tests/test_config.py` guards both parities. | ||
| 31 | |||
| 32 | `load_asphalt_edge_config()` and `config_from_dict()` return the runtime | ||
| 33 | `detector.Config` dataclass; `load_default_config_dict()` returns the packaged | ||
| 34 | defaults as a plain `dict`. Runtime overrides come from repeatable | ||
| 35 | `--set KEY=VALUE` (`parse_set_overrides`, which accepts both top-level keys and | ||
| 36 | dotted `section.key` paths), never repo-local JSON. | ||
| 0 |
| 10 | 10 | ||
| 11 | from importlib.metadata import PackageNotFoundError, version | 11 | from importlib.metadata import PackageNotFoundError, version |
| 12 | 12 | ||
| 13 | from .config import ( | 13 | from .config import ( |
| 14 | AsphaltEdgeConfig, | ||
| 14 | AsphaltEdgeConfigError, | 15 | AsphaltEdgeConfigError, |
| 15 | config_from_dict, | 16 | config_from_dict, |
| 16 | load_asphalt_edge_config, | 17 | load_asphalt_edge_config, |
| 18 | load_default_config_dict, | ||
| 19 | parse_set_overrides, | ||
| 17 | ) | 20 | ) |
| 18 | from .detector import Config, EdgeResult, detect_edges | 21 | from .detector import Config, EdgeResult, detect_edges |
| 19 | from .polyline import FLAG_GAP, FLAG_INTERPOLATED, FLAG_MEASURED | 22 | from .polyline import FLAG_GAP, FLAG_INTERPOLATED, FLAG_MEASURED |
| 20 | 23 |
| 26 | __all__ = [ | 29 | __all__ = [ |
| 27 | "FLAG_GAP", | 30 | "FLAG_GAP", |
| 28 | "FLAG_INTERPOLATED", | 31 | "FLAG_INTERPOLATED", |
| 29 | "FLAG_MEASURED", | 32 | "FLAG_MEASURED", |
| 33 | "AsphaltEdgeConfig", | ||
| 30 | "AsphaltEdgeConfigError", | 34 | "AsphaltEdgeConfigError", |
| 31 | "Config", | 35 | "Config", |
| 32 | "EdgeResult", | 36 | "EdgeResult", |
| 33 | "__version__", | 37 | "__version__", |
| 34 | "config_from_dict", | 38 | "config_from_dict", |
| 35 | "detect_edges", | 39 | "detect_edges", |
| 36 | "load_asphalt_edge_config", | 40 | "load_asphalt_edge_config", |
| 41 | "load_default_config_dict", | ||
| 42 | "parse_set_overrides", | ||
| 37 | ] | 43 | ] |
| 5 | "off_range": [-20.0, 20.0], | 5 | "off_range": [-20.0, 20.0], |
| 6 | "search_in": 4.5, | 6 | "search_in": 4.5, |
| 7 | "search_out": 0.3, | 7 | "search_out": 0.3, |
| 8 | "band_m": 1.0, | 8 | "band_m": 1.0, |
| 9 | "min_confidence": 0.25, | ||
| 10 | "median_window_m": 15.0, | ||
| 11 | "mad_k": 3.0, | ||
| 12 | "max_interp_gap_m": 5.0, | ||
| 13 | "max_interp_offset_jump_m": 0.5, | ||
| 14 | "max_offset_rate": 0.6, | ||
| 15 | "min_run_m": 3.0, | ||
| 16 | "end_stub_m": 5.0, | ||
| 17 | "savgol_window_m": 11.0, | ||
| 18 | "savgol_order": 2, | ||
| 19 | "max_points": 7000000, | ||
| 20 | "hash_round_units_per_m": 1000.0, | ||
| 21 | "min_carriageway_m": 2.0, | ||
| 22 | "weights": { | 9 | "weights": { |
| 23 | "intensity_step": 2.5, | 10 | "intensity_step": 2.5, |
| 24 | "density_drop": 2.0, | 11 | "density_drop": 2.0, |
| 25 | "z_rise": 1.0, | 12 | "z_rise": 1.0, |
| 45 | "texture_weight": 0.6, | 32 | "texture_weight": 0.6, |
| 46 | "density_weight": 0.0, | 33 | "density_weight": 0.0, |
| 47 | "roughness_weight": 0.4, | 34 | "roughness_weight": 0.4, |
| 48 | "z_step_weight": 0.0, | 35 | "z_step_weight": 0.0, |
| 36 | "zrange_weight": 1.5, | ||
| 37 | "zrange_win_m": 0.4, | ||
| 38 | "zrange_cap_m": 0.15, | ||
| 39 | "zrange_snap": true, | ||
| 40 | "zrange_snap_window_m": 0.4, | ||
| 41 | "zrange_snap_max_shift_m": 0.25, | ||
| 42 | "zrange_snap_thresh_min_m": 0.012, | ||
| 43 | "zrange_snap_mad_k": 4.0, | ||
| 44 | "zrange_snap_rel_h": 0.1, | ||
| 45 | "zrange_snap_regularize": true, | ||
| 46 | "zrange_snap_reg_max_dev_m": 0.3, | ||
| 47 | "zrange_intensity_snap": true, | ||
| 48 | "zrange_isnap_grad_k": 3.0, | ||
| 49 | "zrange_isnap_min_step": 8.0, | ||
| 50 | "zrange_isnap_paint_guard": true, | ||
| 51 | "zrange_onset_bonus": 1.2, | ||
| 52 | "zrange_onset_smooth": true, | ||
| 53 | "zrange_guardrail_z_m": 0.3, | ||
| 54 | "zrange_guardrail_lookback_m": 2.0, | ||
| 55 | "zrange_guardrail_veto": false, | ||
| 56 | "zrange_guardrail_veto_conf": 0.7, | ||
| 57 | "zrange_guardrail_veto_margin_m": 0.5, | ||
| 58 | "zrange_guardrail_veto_types": ["w_beam"], | ||
| 59 | "zrange_guardrail_veto_walls": false, | ||
| 60 | "zrange_resume_weight": 3.0, | ||
| 61 | "zrange_resume_zone_m": [0.5, 2.5], | ||
| 62 | "zrange_flat_thresh_m": 0.015, | ||
| 63 | "zrange_resume_tex_k": 1.5, | ||
| 64 | "zrange_shoulder_rule": true, | ||
| 65 | "zrange_shoulder_lookback_m": 2.0, | ||
| 66 | "zrange_shoulder_min_gap_m": 0.3, | ||
| 67 | "zrange_shoulder_corridor_anchor": true, | ||
| 68 | "zrange_shoulder_sustain_m": 1.5, | ||
| 69 | "zrange_shoulder_max_search_m": 8.0, | ||
| 70 | "zrange_shoulder_grad_k": 2.0, | ||
| 71 | "zrange_shoulder_min_step": 6.0, | ||
| 72 | "zrange_shoulder_min_width_m": 3.0, | ||
| 73 | "zrange_shoulder_support_m": 1.5, | ||
| 74 | "zrange_shoulder_support_tol_m": 0.6, | ||
| 75 | "zrange_shoulder_support_frac": 0.5, | ||
| 76 | "zrange_shoulder_consolidate": true, | ||
| 77 | "zrange_shoulder_revert_isolated": true, | ||
| 78 | "zrange_shoulder_run_smoothness_guard": false, | ||
| 79 | "zrange_shoulder_run_max_step_m": 0.046, | ||
| 80 | "zrange_paint_veto": true, | ||
| 81 | "zrange_paint_quantile": 0.92, | ||
| 82 | "zrange_paint_max_width_m": 0.5, | ||
| 83 | "zrange_paint_corridor_margin_m": 1.5, | ||
| 49 | "robust": true, | 84 | "robust": true, |
| 50 | "edge_smooth_window_m": 2.0, | 85 | "edge_smooth_window_m": 2.0, |
| 51 | "slope_cheap_m": 0.15, | 86 | "slope_cheap_m": 0.15, |
| 52 | "trans_lin": 9.0, | 87 | "trans_lin": 9.0, |
| 53 | "trans_quad": 220.0, | 88 | "trans_quad": 220.0, |
| 54 | "max_jump_m": 0.9, | 89 | "max_jump_m": 0.9, |
| 90 | "bridge_stiffness": 3.0, | ||
| 91 | "bridge_anchor_min_conf": 0.75, | ||
| 55 | "invalid_penalty": 2.5, | 92 | "invalid_penalty": 2.5, |
| 56 | "min_valid_frac": 0.35, | 93 | "min_valid_frac": 0.35, |
| 57 | "conf_gain": 0.7, | 94 | "conf_gain": 0.7, |
| 58 | "outer_bound_margin_m": 1.5 | 95 | "outer_bound_margin_m": 1.5, |
| 96 | "outer_median_bound": false, | ||
| 97 | "outer_median_bound_margin_m": 0.5, | ||
| 98 | "outer_median_bound_hard": false, | ||
| 99 | "vehicle_mask": true, | ||
| 100 | "vehicle_z_thresh_m": 0.02, | ||
| 101 | "vehicle_corridor_margin_m": 0.5, | ||
| 102 | "vehicle_mask_z_only": false, | ||
| 103 | "vehicle_mask_neutral": true, | ||
| 104 | "vehicle_mask_seed_guard": false, | ||
| 105 | "vehicle_mask_baseline_fill": false, | ||
| 106 | "edge_support_gate": false, | ||
| 107 | "use_crf": false, | ||
| 108 | "crf_second_order": true, | ||
| 109 | "crf_lambda_parallel": 4.0, | ||
| 110 | "crf_lambda_curvature": 8.0, | ||
| 111 | "crf_lambda_prior": 1.5, | ||
| 112 | "crf_huber_delta_m": 0.1, | ||
| 113 | "crf_delta_cap_m": 0.6, | ||
| 114 | "crf_conf_ref": 1.0, | ||
| 115 | "crf_not_observable_support": 0.15, | ||
| 116 | "crf_support_intensity_ref_dn": 40.0, | ||
| 117 | "crf_support_zrange_ref_m": 0.05 | ||
| 59 | }, | 118 | }, |
| 60 | "gutter": { | 119 | "gutter": { |
| 61 | "enabled": true, | 120 | "enabled": true, |
| 62 | "station_step": 1.0, | 121 | "station_step": 1.0, |
| 64 | "step_thr": 4000.0, | 123 | "step_thr": 4000.0, |
| 65 | "rough_mult": 2.5, | 124 | "rough_mult": 2.5, |
| 66 | "max_gutter_m": 1.5, | 125 | "max_gutter_m": 1.5, |
| 67 | "min_conf": 0.35 | 126 | "min_conf": 0.35 |
| 68 | } | 127 | }, |
| 128 | "min_confidence": 0.25, | ||
| 129 | "median_window_m": 15.0, | ||
| 130 | "mad_k": 3.0, | ||
| 131 | "max_interp_gap_m": 5.0, | ||
| 132 | "max_interp_offset_jump_m": 0.5, | ||
| 133 | "max_offset_rate": 0.6, | ||
| 134 | "min_run_m": 3.0, | ||
| 135 | "end_stub_m": 5.0, | ||
| 136 | "savgol_window_m": 11.0, | ||
| 137 | "savgol_order": 2, | ||
| 138 | "max_points": 7000000, | ||
| 139 | "hash_round_units_per_m": 1000.0, | ||
| 140 | "min_carriageway_m": 2.0 | ||
| 69 | } | 141 | } |
| 1 | """Packaged-default configuration loader for asphalt edge detection. | 1 | """Packaged-default configuration for asphalt edge detection. |
| 2 | 2 | ||
| 3 | Defaults live in the force-included ``asphalt_edge.default.json``. A pydantic | 3 | The schema is `AsphaltEdgeConfig` (a `config_loader.ConfigModel`), mirroring |
| 4 | model tree derived from :class:`iolabs.common.config_loader.ConfigModel` is | 4 | `asphalt_edge.default.json` key for key. |
| 5 | the schema: nested JSON sections are nested models, field names are JSON keys, | 5 | |
| 6 | and unknown keys are rejected. Validated values are copied onto the runtime | 6 | Adding a config key means adding the field to the model and the same key to |
| 7 | dataclasses (:class:`~.detector.Config`, :class:`~.features.FeatureWeights`, | 7 | `asphalt_edge.default.json` โ plus, here, the same field to the matching |
| 8 | :class:`~.image_edge.ImageEdgeConfig`, :class:`~.gutter.GutterConfig`) because | 8 | runtime dataclass, because :func:`_to_runtime` copies every model field onto |
| 9 | the algorithm, tests and scripts mutate those carriers. | 9 | the mutable carriers the algorithm, scripts and tests pass around |
| 10 | 10 | (:class:`~.detector.Config`, :class:`~.features.FeatureWeights`, | |
| 11 | To add a config key: add the field to the runtime dataclass *and* to the | 11 | :class:`~.image_edge.ImageEdgeConfig`, :class:`~.gutter.GutterConfig`). |
| 12 | matching ``ConfigModel`` subclass here, with the same name, type and default | 12 | Unknown keys are rejected. |
| 13 | (``_to_runtime`` passes every model field on to the dataclass, so a field on | 13 | |
| 14 | only one side raises). Add it to the packaged JSON only when the shipped | 14 | :func:`load_asphalt_edge_config` and :func:`config_from_dict` return that |
| 15 | default differs from the field default; ``test_config`` guards the parity. | 15 | runtime :class:`~.detector.Config` dataclass; :func:`load_default_config_dict` |
| 16 | returns the packaged defaults as a plain ``dict``. | ||
| 16 | """ | 17 | """ |
| 17 | 18 | ||
| 18 | from __future__ import annotations | 19 | from __future__ import annotations |
| 19 | 20 | ||
| 20 | import logging | 21 | import logging |
| 22 | from collections.abc import Mapping, Sequence | ||
| 21 | from pathlib import Path | 23 | from pathlib import Path |
| 22 | from typing import Any | 24 | from typing import Any |
| 23 | 25 | ||
| 26 | import pydantic | ||
| 24 | from iolabs.common import config_loader | 27 | from iolabs.common import config_loader |
| 25 | 28 | ||
| 26 | from .detector import Config | 29 | from .detector import Config |
| 27 | from .features import FeatureWeights | 30 | from .features import FeatureWeights |
| 29 | from .image_edge import ImageEdgeConfig | 32 | from .image_edge import ImageEdgeConfig |
| 30 | 33 | ||
| 31 | logger = logging.getLogger(__name__) | 34 | logger = logging.getLogger(__name__) |
| 32 | 35 | ||
| 33 | _PACKAGE = "iolabs_point_cloud_detection_asphaltedge" | 36 | _PACKAGE_NAME = "iolabs_point_cloud_detection_asphaltedge" |
| 34 | _DEFAULT_CONFIG_FILENAME = "asphalt_edge.default.json" | 37 | _DEFAULT_FILENAME = "asphalt_edge.default.json" |
| 35 | _CONTEXT = "asphalt edge config" | 38 | _CONTEXT = "asphalt edge config" |
| 36 | 39 | ||
| 37 | 40 | ||
| 38 | class AsphaltEdgeConfigError(config_loader.ConfigError): | 41 | class AsphaltEdgeConfigError(config_loader.ConfigError): |
| 39 | """Raised when the asphalt edge config contains unsupported keys or values.""" | 42 | """Raised when asphalt edge config contains unsupported keys or values.""" |
| 43 | |||
| 44 | |||
| 45 | def _check_interval(name: str, interval: tuple[float, float]) -> None: | ||
| 46 | """Raise when a ``(low, high)`` interval is not strictly increasing.""" | ||
| 47 | low, high = interval | ||
| 48 | if low >= high: | ||
| 49 | raise ValueError(f"{name} must be (low, high) with low < high, got ({low}, {high})") | ||
| 40 | 50 | ||
| 41 | 51 | ||
| 42 | class FeatureWeightsConfig(config_loader.ConfigModel): | 52 | class AsphaltEdgeWeightsConfig(config_loader.ConfigModel): |
| 43 | """``weights`` block of ``asphalt_edge.default.json``.""" | 53 | """``weights`` block: cross-profile material-edge feature weights.""" |
| 44 | 54 | ||
| 45 | intensity_step: float = 2.5 | 55 | intensity_step: float = pydantic.Field(default=2.5, ge=0.0) |
| 46 | density_drop: float = 2.0 | 56 | density_drop: float = pydantic.Field(default=2.0, ge=0.0) |
| 47 | z_rise: float = 1.0 | 57 | z_rise: float = pydantic.Field(default=1.0, ge=0.0) |
| 48 | roughness_rise: float = 0.8 | 58 | roughness_rise: float = pydantic.Field(default=0.8, ge=0.0) |
| 49 | surface_notch: float = 1.5 | 59 | surface_notch: float = pydantic.Field(default=1.5, ge=0.0) |
| 50 | dog_step: float = 1.0 | 60 | dog_step: float = pydantic.Field(default=1.0, ge=0.0) |
| 51 | 61 | ||
| 52 | 62 | ||
| 53 | class ImageEdgeConfigModel(config_loader.ConfigModel): | 63 | class AsphaltEdgeImageConfig(config_loader.ConfigModel): |
| 54 | """``image`` block plus the dataclass knobs accepted as overrides.""" | 64 | """``image`` block: tunables of the rectified-image edge extractor.""" |
| 55 | 65 | ||
| 56 | station_step: float = 0.25 | 66 | # rectification grid |
| 57 | offset_step: float = 0.05 | 67 | station_step: float = pydantic.Field(default=0.25, gt=0.0) |
| 68 | offset_step: float = pydantic.Field(default=0.05, gt=0.0) | ||
| 58 | offset_min: float = -18.0 | 69 | offset_min: float = -18.0 |
| 59 | offset_max: float = 18.0 | 70 | offset_max: float = 18.0 |
| 71 | # side search bands (signed offset metres; +left / -right) | ||
| 60 | left_band: tuple[float, float] = (1.0, 17.0) | 72 | left_band: tuple[float, float] = (1.0, 17.0) |
| 61 | right_band: tuple[float, float] = (-17.0, -1.0) | 73 | right_band: tuple[float, float] = (-17.0, -1.0) |
| 62 | inner_left_band: tuple[float, float] = (0.3, 6.0) | 74 | inner_left_band: tuple[float, float] = (0.3, 6.0) |
| 63 | inner_right_band: tuple[float, float] = (-6.0, -0.3) | 75 | inner_right_band: tuple[float, float] = (-6.0, -0.3) |
| 64 | inner_min_valid_frac: float = 0.2 | 76 | # negative means "reuse min_valid_frac", so negatives stay legal here |
| 65 | inner_bound_margin_m: float = 1.25 | 77 | inner_min_valid_frac: float = pydantic.Field(default=0.2, le=1.0) |
| 66 | step_win_m: float = 1.2 | 78 | inner_bound_margin_m: float = pydantic.Field(default=1.25, ge=0.0) |
| 67 | tex_win_m: float = 0.5 | 79 | # step-filter windows (metres) |
| 68 | smooth_offset_m: float = 0.1 | 80 | step_win_m: float = pydantic.Field(default=1.2, gt=0.0) |
| 69 | intensity_weight: float = 1.0 | 81 | tex_win_m: float = pydantic.Field(default=0.5, gt=0.0) |
| 70 | texture_weight: float = 0.6 | 82 | smooth_offset_m: float = pydantic.Field(default=0.1, ge=0.0) |
| 71 | density_weight: float = 0.0 | 83 | # channel fusion |
| 72 | roughness_weight: float = 0.0 | 84 | intensity_weight: float = pydantic.Field(default=1.0, ge=0.0) |
| 73 | z_step_weight: float = 0.0 | 85 | texture_weight: float = pydantic.Field(default=0.6, ge=0.0) |
| 74 | zrange_weight: float = 1.5 | 86 | density_weight: float = pydantic.Field(default=0.0, ge=0.0) |
| 75 | zrange_win_m: float = 0.4 | 87 | roughness_weight: float = pydantic.Field(default=0.4, ge=0.0) |
| 76 | zrange_cap_m: float = 0.15 | 88 | z_step_weight: float = pydantic.Field(default=0.0, ge=0.0) |
| 89 | # z-range (max-min z per pixel) channel | ||
| 90 | zrange_weight: float = pydantic.Field(default=1.5, ge=0.0) | ||
| 91 | zrange_win_m: float = pydantic.Field(default=0.4, gt=0.0) | ||
| 92 | zrange_cap_m: float = pydantic.Field(default=0.15, gt=0.0) | ||
| 77 | zrange_snap: bool = True | 93 | zrange_snap: bool = True |
| 78 | zrange_snap_window_m: float = 0.4 | 94 | zrange_snap_window_m: float = pydantic.Field(default=0.4, ge=0.0) |
| 79 | zrange_snap_max_shift_m: float = 0.25 | 95 | zrange_snap_max_shift_m: float = pydantic.Field(default=0.25, ge=0.0) |
| 80 | zrange_snap_thresh_min_m: float = 0.012 | 96 | zrange_snap_thresh_min_m: float = pydantic.Field(default=0.012, ge=0.0) |
| 81 | zrange_snap_mad_k: float = 4.0 | 97 | zrange_snap_mad_k: float = pydantic.Field(default=4.0, ge=0.0) |
| 82 | zrange_snap_rel_h: float = 0.10 | 98 | zrange_snap_rel_h: float = pydantic.Field(default=0.10, ge=0.0, le=1.0) |
| 83 | zrange_snap_regularize: bool = True | 99 | zrange_snap_regularize: bool = True |
| 84 | zrange_snap_reg_max_dev_m: float = 0.30 | 100 | zrange_snap_reg_max_dev_m: float = pydantic.Field(default=0.30, ge=0.0) |
| 101 | # intensity-snap fallback | ||
| 85 | zrange_intensity_snap: bool = True | 102 | zrange_intensity_snap: bool = True |
| 86 | zrange_isnap_grad_k: float = 3.0 | 103 | zrange_isnap_grad_k: float = pydantic.Field(default=3.0, ge=0.0) |
| 87 | zrange_isnap_min_step: float = 8.0 | 104 | zrange_isnap_min_step: float = pydantic.Field(default=8.0, ge=0.0) |
| 88 | zrange_isnap_paint_guard: bool = True | 105 | zrange_isnap_paint_guard: bool = True |
| 89 | zrange_onset_bonus: float = 1.2 | 106 | # innermost-onset preference |
| 107 | zrange_onset_bonus: float = pydantic.Field(default=1.2, ge=0.0) | ||
| 90 | zrange_onset_smooth: bool = True | 108 | zrange_onset_smooth: bool = True |
| 91 | zrange_guardrail_z_m: float = 0.30 | 109 | zrange_guardrail_z_m: float = pydantic.Field(default=0.30, ge=0.0) |
| 92 | zrange_guardrail_lookback_m: float = 2.0 | 110 | zrange_guardrail_lookback_m: float = pydantic.Field(default=2.0, ge=0.0) |
| 111 | # guardrail-repo hard veto | ||
| 93 | zrange_guardrail_veto: bool = False | 112 | zrange_guardrail_veto: bool = False |
| 94 | zrange_guardrail_veto_conf: float = 0.7 | 113 | zrange_guardrail_veto_conf: float = pydantic.Field(default=0.7, ge=0.0, le=1.0) |
| 95 | zrange_guardrail_veto_margin_m: float = 0.5 | 114 | zrange_guardrail_veto_margin_m: float = pydantic.Field(default=0.5, ge=0.0) |
| 115 | # no Literal: the accepted instance types are the guardrail repo's | ||
| 116 | # vocabulary, carried in its JSON, not a choice this schema owns | ||
| 96 | zrange_guardrail_veto_types: tuple[str, ...] = ("w_beam",) | 117 | zrange_guardrail_veto_types: tuple[str, ...] = ("w_beam",) |
| 97 | zrange_guardrail_veto_walls: bool = False | 118 | zrange_guardrail_veto_walls: bool = False |
| 98 | zrange_resume_weight: float = 3.0 | 119 | # asphalt-resumes-outward penalty |
| 120 | zrange_resume_weight: float = pydantic.Field(default=3.0, ge=0.0) | ||
| 99 | zrange_resume_zone_m: tuple[float, float] = (0.5, 2.5) | 121 | zrange_resume_zone_m: tuple[float, float] = (0.5, 2.5) |
| 100 | zrange_flat_thresh_m: float = 0.015 | 122 | zrange_flat_thresh_m: float = pydantic.Field(default=0.015, ge=0.0) |
| 101 | zrange_resume_tex_k: float = 1.5 | 123 | zrange_resume_tex_k: float = pydantic.Field(default=1.5, ge=0.0) |
| 124 | # shoulder rule | ||
| 102 | zrange_shoulder_rule: bool = True | 125 | zrange_shoulder_rule: bool = True |
| 103 | zrange_shoulder_lookback_m: float = 2.0 | 126 | zrange_shoulder_lookback_m: float = pydantic.Field(default=2.0, ge=0.0) |
| 104 | zrange_shoulder_min_gap_m: float = 0.3 | 127 | zrange_shoulder_min_gap_m: float = pydantic.Field(default=0.3, ge=0.0) |
| 105 | zrange_shoulder_corridor_anchor: bool = True | 128 | zrange_shoulder_corridor_anchor: bool = True |
| 106 | zrange_shoulder_sustain_m: float = 1.5 | 129 | zrange_shoulder_sustain_m: float = pydantic.Field(default=1.5, ge=0.0) |
| 107 | zrange_shoulder_max_search_m: float = 8.0 | 130 | zrange_shoulder_max_search_m: float = pydantic.Field(default=8.0, ge=0.0) |
| 108 | zrange_shoulder_grad_k: float = 2.0 | 131 | zrange_shoulder_grad_k: float = pydantic.Field(default=2.0, ge=0.0) |
| 109 | zrange_shoulder_min_step: float = 6.0 | 132 | zrange_shoulder_min_step: float = pydantic.Field(default=6.0, ge=0.0) |
| 110 | zrange_shoulder_min_width_m: float = 3.0 | 133 | zrange_shoulder_min_width_m: float = pydantic.Field(default=3.0, ge=0.0) |
| 111 | zrange_shoulder_support_m: float = 1.5 | 134 | zrange_shoulder_support_m: float = pydantic.Field(default=1.5, ge=0.0) |
| 112 | zrange_shoulder_support_tol_m: float = 0.6 | 135 | zrange_shoulder_support_tol_m: float = pydantic.Field(default=0.6, ge=0.0) |
| 113 | zrange_shoulder_support_frac: float = 0.5 | 136 | zrange_shoulder_support_frac: float = pydantic.Field(default=0.5, ge=0.0, le=1.0) |
| 114 | zrange_shoulder_consolidate: bool = True | 137 | zrange_shoulder_consolidate: bool = True |
| 115 | zrange_shoulder_revert_isolated: bool = True | 138 | zrange_shoulder_revert_isolated: bool = True |
| 116 | zrange_shoulder_run_smoothness_guard: bool = False | 139 | zrange_shoulder_run_smoothness_guard: bool = False |
| 117 | zrange_shoulder_run_max_step_m: float = 0.046 | 140 | zrange_shoulder_run_max_step_m: float = pydantic.Field(default=0.046, ge=0.0) |
| 141 | # paint veto | ||
| 118 | zrange_paint_veto: bool = True | 142 | zrange_paint_veto: bool = True |
| 119 | zrange_paint_quantile: float = 0.92 | 143 | zrange_paint_quantile: float = pydantic.Field(default=0.92, gt=0.0, lt=1.0) |
| 120 | zrange_paint_max_width_m: float = 0.5 | 144 | zrange_paint_max_width_m: float = pydantic.Field(default=0.5, ge=0.0) |
| 121 | zrange_paint_corridor_margin_m: float = 1.5 | 145 | zrange_paint_corridor_margin_m: float = pydantic.Field(default=1.5, ge=0.0) |
| 146 | # DP inference | ||
| 122 | robust: bool = True | 147 | robust: bool = True |
| 123 | edge_smooth_window_m: float = 2.0 | 148 | edge_smooth_window_m: float = pydantic.Field(default=2.0, ge=0.0) |
| 124 | slope_cheap_m: float = 0.15 | 149 | slope_cheap_m: float = pydantic.Field(default=0.15, ge=0.0) |
| 125 | trans_lin: float = 9.0 | 150 | trans_lin: float = pydantic.Field(default=9.0, ge=0.0) |
| 126 | trans_quad: float = 220.0 | 151 | trans_quad: float = pydantic.Field(default=220.0, ge=0.0) |
| 127 | max_jump_m: float = 0.9 | 152 | max_jump_m: float = pydantic.Field(default=0.9, ge=0.0) |
| 128 | bridge_stiffness: float = 3.0 | 153 | bridge_stiffness: float = pydantic.Field(default=3.0, ge=0.0) |
| 129 | bridge_anchor_min_conf: float = 0.75 | 154 | bridge_anchor_min_conf: float = pydantic.Field(default=0.75, ge=0.0, le=1.0) |
| 130 | invalid_penalty: float = 2.5 | 155 | invalid_penalty: float = pydantic.Field(default=2.5, ge=0.0) |
| 131 | min_valid_frac: float = 0.35 | 156 | min_valid_frac: float = pydantic.Field(default=0.35, ge=0.0, le=1.0) |
| 132 | conf_gain: float = 0.7 | 157 | conf_gain: float = pydantic.Field(default=0.7, ge=0.0) |
| 133 | outer_bound_margin_m: float = 1.5 | 158 | outer_bound_margin_m: float = pydantic.Field(default=1.5, ge=0.0) |
| 134 | outer_median_bound: bool = False | 159 | outer_median_bound: bool = False |
| 135 | outer_median_bound_margin_m: float = 0.5 | 160 | outer_median_bound_margin_m: float = pydantic.Field(default=0.5, ge=0.0) |
| 136 | outer_median_bound_hard: bool = False | 161 | outer_median_bound_hard: bool = False |
| 162 | # vehicle mask | ||
| 137 | vehicle_mask: bool = True | 163 | vehicle_mask: bool = True |
| 138 | vehicle_z_thresh_m: float = 0.02 | 164 | vehicle_z_thresh_m: float = pydantic.Field(default=0.02, ge=0.0) |
| 139 | vehicle_corridor_margin_m: float = 0.5 | 165 | vehicle_corridor_margin_m: float = pydantic.Field(default=0.5, ge=0.0) |
| 140 | vehicle_mask_z_only: bool = False | 166 | vehicle_mask_z_only: bool = False |
| 141 | vehicle_mask_neutral: bool = True | 167 | vehicle_mask_neutral: bool = True |
| 142 | vehicle_mask_seed_guard: bool = False | 168 | vehicle_mask_seed_guard: bool = False |
| 143 | vehicle_mask_baseline_fill: bool = False | 169 | vehicle_mask_baseline_fill: bool = False |
| 144 | edge_support_gate: bool = False | 170 | edge_support_gate: bool = False |
| 171 | # CRF inference | ||
| 145 | use_crf: bool = False | 172 | use_crf: bool = False |
| 146 | crf_second_order: bool = True | 173 | crf_second_order: bool = True |
| 147 | crf_lambda_parallel: float = 4.0 | 174 | crf_lambda_parallel: float = pydantic.Field(default=4.0, ge=0.0) |
| 148 | crf_lambda_curvature: float = 8.0 | 175 | crf_lambda_curvature: float = pydantic.Field(default=8.0, ge=0.0) |
| 149 | crf_lambda_prior: float = 1.5 | 176 | crf_lambda_prior: float = pydantic.Field(default=1.5, ge=0.0) |
| 150 | crf_huber_delta_m: float = 0.10 | 177 | crf_huber_delta_m: float = pydantic.Field(default=0.10, gt=0.0) |
| 151 | crf_delta_cap_m: float = 0.6 | 178 | crf_delta_cap_m: float = pydantic.Field(default=0.6, ge=0.0) |
| 152 | crf_conf_ref: float = 1.0 | 179 | crf_conf_ref: float = pydantic.Field(default=1.0, gt=0.0) |
| 153 | crf_not_observable_support: float = 0.15 | 180 | crf_not_observable_support: float = pydantic.Field(default=0.15, ge=0.0) |
| 154 | crf_support_intensity_ref_dn: float = 40.0 | 181 | crf_support_intensity_ref_dn: float = pydantic.Field(default=40.0, gt=0.0) |
| 155 | crf_support_zrange_ref_m: float = 0.05 | 182 | crf_support_zrange_ref_m: float = pydantic.Field(default=0.05, gt=0.0) |
| 156 | 183 | ||
| 184 | @pydantic.model_validator(mode="after") | ||
| 185 | def _check_intervals(self) -> AsphaltEdgeImageConfig: | ||
| 186 | """Every ``(low, high)`` interval field must be ordered.""" | ||
| 187 | _check_interval("offset_min/offset_max", (self.offset_min, self.offset_max)) | ||
| 188 | for name in ( | ||
| 189 | "left_band", | ||
| 190 | "right_band", | ||
| 191 | "inner_left_band", | ||
| 192 | "inner_right_band", | ||
| 193 | "zrange_resume_zone_m", | ||
| 194 | ): | ||
| 195 | _check_interval(name, getattr(self, name)) | ||
| 196 | return self | ||
| 157 | 197 | ||
| 158 | class GutterConfigModel(config_loader.ConfigModel): | 198 | |
| 159 | """``gutter`` block of ``asphalt_edge.default.json``.""" | 199 | class AsphaltEdgeGutterConfig(config_loader.ConfigModel): |
| 200 | """``gutter`` block: thresholds of the gutter-lip cross-section scan.""" | ||
| 160 | 201 | ||
| 161 | enabled: bool = True | 202 | enabled: bool = True |
| 162 | station_step: float = 1.0 | 203 | station_step: float = pydantic.Field(default=1.0, gt=0.0) |
| 163 | half_window_m: float = 0.75 | 204 | half_window_m: float = pydantic.Field(default=0.75, gt=0.0) |
| 164 | step_thr: float = 4000.0 | 205 | step_thr: float = pydantic.Field(default=4000.0, ge=0.0) |
| 165 | rough_mult: float = 2.5 | 206 | rough_mult: float = pydantic.Field(default=2.5, ge=0.0) |
| 166 | max_gutter_m: float = 1.5 | 207 | max_gutter_m: float = pydantic.Field(default=1.5, gt=0.0) |
| 167 | min_conf: float = 0.35 | 208 | min_conf: float = pydantic.Field(default=0.35, ge=0.0, le=1.0) |
| 168 | 209 | ||
| 169 | 210 | ||
| 170 | class AsphaltEdgeConfig(config_loader.ConfigModel): | 211 | class AsphaltEdgeConfig(config_loader.ConfigModel): |
| 171 | """Root schema mirroring ``asphalt_edge.default.json``.""" | 212 | """Root schema mirroring ``asphalt_edge.default.json``.""" |
| 172 | 213 | ||
| 173 | station_step: float = 0.5 | 214 | station_step: float = pydantic.Field(default=0.5, gt=0.0) |
| 174 | half_thickness: float = 0.5 | 215 | half_thickness: float = pydantic.Field(default=0.5, gt=0.0) |
| 175 | bin_width: float = 0.1 | 216 | bin_width: float = pydantic.Field(default=0.1, gt=0.0) |
| 176 | off_range: tuple[float, float] = (-15.0, 15.0) | 217 | off_range: tuple[float, float] = (-20.0, 20.0) |
| 177 | search_in: float = 4.5 | 218 | search_in: float = pydantic.Field(default=4.5, ge=0.0) |
| 178 | search_out: float = 0.3 | 219 | search_out: float = pydantic.Field(default=0.3, ge=0.0) |
| 179 | band_m: float = 1.0 | 220 | band_m: float = pydantic.Field(default=1.0, gt=0.0) |
| 180 | weights: FeatureWeightsConfig = FeatureWeightsConfig() | 221 | weights: AsphaltEdgeWeightsConfig = AsphaltEdgeWeightsConfig() |
| 181 | image: ImageEdgeConfigModel = ImageEdgeConfigModel() | 222 | image: AsphaltEdgeImageConfig = AsphaltEdgeImageConfig() |
| 182 | gutter: GutterConfigModel = GutterConfigModel() | 223 | gutter: AsphaltEdgeGutterConfig = AsphaltEdgeGutterConfig() |
| 183 | min_confidence: float = 0.25 | 224 | min_confidence: float = pydantic.Field(default=0.25, ge=0.0, le=1.0) |
| 184 | median_window_m: float = 15.0 | 225 | median_window_m: float = pydantic.Field(default=15.0, ge=0.0) |
| 185 | mad_k: float = 3.5 | 226 | mad_k: float = pydantic.Field(default=3.0, gt=0.0) |
| 186 | max_interp_gap_m: float = 5.0 | 227 | max_interp_gap_m: float = pydantic.Field(default=5.0, ge=0.0) |
| 187 | max_interp_offset_jump_m: float = 0.5 | 228 | max_interp_offset_jump_m: float = pydantic.Field(default=0.5, ge=0.0) |
| 188 | max_offset_rate: float = 0.6 | 229 | max_offset_rate: float = pydantic.Field(default=0.6, ge=0.0) |
| 189 | min_run_m: float = 3.0 | 230 | min_run_m: float = pydantic.Field(default=3.0, ge=0.0) |
| 190 | end_stub_m: float = 5.0 | 231 | end_stub_m: float = pydantic.Field(default=5.0, ge=0.0) |
| 191 | savgol_window_m: float = 11.0 | 232 | savgol_window_m: float = pydantic.Field(default=11.0, ge=0.0) |
| 192 | savgol_order: int = 2 | 233 | savgol_order: int = pydantic.Field(default=2, ge=1) |
| 193 | max_points: int = 7_000_000 | 234 | max_points: int = pydantic.Field(default=7_000_000, gt=0) |
| 194 | hash_round_units_per_m: float = 1000.0 | 235 | hash_round_units_per_m: float = pydantic.Field(default=1000.0, gt=0.0) |
| 195 | min_carriageway_m: float = 2.0 | 236 | min_carriageway_m: float = pydantic.Field(default=2.0, ge=0.0) |
| 237 | |||
| 238 | @pydantic.model_validator(mode="after") | ||
| 239 | def _check_off_range(self) -> AsphaltEdgeConfig: | ||
| 240 | """``off_range`` must be ordered.""" | ||
| 241 | _check_interval("off_range", self.off_range) | ||
| 242 | return self | ||
| 196 | 243 | ||
| 197 | 244 | ||
| 198 | def _to_runtime(model: AsphaltEdgeConfig) -> Config: | 245 | def _to_runtime(model: AsphaltEdgeConfig) -> Config: |
| 199 | """Copy a validated model onto the mutable runtime dataclasses.""" | 246 | """Copy a validated model onto the mutable runtime dataclasses.""" |
| 205 | **data, | 252 | **data, |
| 206 | ) | 253 | ) |
| 207 | 254 | ||
| 208 | 255 | ||
| 209 | def config_from_dict(raw_config: dict[str, Any]) -> Config: | 256 | def _load_model( |
| 210 | """Build a typed :class:`Config` from a (possibly partial) mapping.""" | 257 | *, |
| 258 | overrides: Mapping[str, Any] | None = None, | ||
| 259 | config_path: str | Path | None = None, | ||
| 260 | ) -> AsphaltEdgeConfig: | ||
| 261 | """Load the packaged defaults (or ``config_path``) and apply ``overrides``.""" | ||
| 262 | model = config_loader.load_config( | ||
| 263 | AsphaltEdgeConfig, | ||
| 264 | package=_PACKAGE_NAME, | ||
| 265 | filename=_DEFAULT_FILENAME, | ||
| 266 | overrides=overrides, | ||
| 267 | config_path=config_path, | ||
| 268 | context=_CONTEXT, | ||
| 269 | error_cls=AsphaltEdgeConfigError, | ||
| 270 | ) | ||
| 271 | if config_path is not None: | ||
| 272 | logger.info("Config file applied: %s", config_path) | ||
| 273 | if overrides: | ||
| 274 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | ||
| 275 | return model | ||
| 276 | |||
| 277 | |||
| 278 | def load_default_config_dict() -> dict[str, Any]: | ||
| 279 | """Return the packaged default config as a plain nested ``dict``.""" | ||
| 280 | return _load_model().model_dump() | ||
| 281 | |||
| 282 | |||
| 283 | def config_from_dict(raw_config: Mapping[str, Any]) -> Config: | ||
| 284 | """Build a typed :class:`~.detector.Config` from a (possibly partial) mapping. | ||
| 285 | |||
| 286 | Keys the mapping omits fall back to the model defaults, which are identical | ||
| 287 | to the packaged JSON. | ||
| 288 | """ | ||
| 211 | model = config_loader.validate_config( | 289 | model = config_loader.validate_config( |
| 212 | AsphaltEdgeConfig, | 290 | AsphaltEdgeConfig, |
| 213 | raw_config, | 291 | raw_config, |
| 214 | context=_CONTEXT, | 292 | context=_CONTEXT, |
| 219 | 297 | ||
| 220 | def load_asphalt_edge_config( | 298 | def load_asphalt_edge_config( |
| 221 | config_path: str | Path | None = None, | 299 | config_path: str | Path | None = None, |
| 222 | *, | 300 | *, |
| 223 | overrides: dict[str, Any] | None = None, | 301 | overrides: Mapping[str, Any] | None = None, |
| 224 | ) -> Config: | 302 | ) -> Config: |
| 225 | """Load the packaged default config, apply optional overrides, return a Config.""" | 303 | """Load the packaged default config, apply optional overrides, return a Config. |
| 226 | model = config_loader.load_config( | 304 | |
| 227 | AsphaltEdgeConfig, | 305 | ``config_path`` REPLACES the packaged defaults; keys it omits fall back to |
| 228 | package=_PACKAGE, | 306 | the model defaults. |
| 229 | filename=_DEFAULT_CONFIG_FILENAME, | 307 | """ |
| 230 | overrides=overrides, | 308 | return _to_runtime(_load_model(overrides=overrides, config_path=config_path)) |
| 231 | config_path=config_path, | 309 | |
| 232 | context=_CONTEXT, | 310 | |
| 233 | error_cls=AsphaltEdgeConfigError, | 311 | def parse_set_overrides(raw_overrides: Sequence[str] | None) -> dict[str, Any]: |
| 312 | """Parse repeatable ``--set KEY=VALUE`` strings into an override mapping. | ||
| 313 | |||
| 314 | Thin binding of :func:`iolabs.common.config_loader.parse_set_overrides` to | ||
| 315 | the asphalt edge error class. This config has both top-level keys and | ||
| 316 | sections, so both ``--set station_step=0.5`` and ``--set image.robust=false`` | ||
| 317 | are accepted; dotted keys expand into nested mappings. | ||
| 318 | |||
| 319 | Args: | ||
| 320 | raw_overrides: Raw ``KEY=VALUE`` strings from ``--set``, or ``None``. | ||
| 321 | |||
| 322 | Returns: | ||
| 323 | A mapping ready to pass as ``overrides``; later duplicates win. | ||
| 324 | |||
| 325 | Raises: | ||
| 326 | AsphaltEdgeConfigError: An override is missing its ``=``, or two | ||
| 327 | overrides disagree about whether a path segment is a section. | ||
| 328 | """ | ||
| 329 | dotted = [item for item in raw_overrides or [] if "." in item.split("=", 1)[0]] | ||
| 330 | flat = [item for item in raw_overrides or [] if "." not in item.split("=", 1)[0]] | ||
| 331 | return config_loader.deep_merge_dicts( | ||
| 332 | config_loader.parse_set_overrides(flat, error_cls=AsphaltEdgeConfigError), | ||
| 333 | config_loader.parse_set_overrides( | ||
| 334 | dotted, nested=True, error_cls=AsphaltEdgeConfigError | ||
| 335 | ), | ||
| 234 | ) | 336 | ) |
| 235 | if overrides: | ||
| 236 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | ||
| 237 | return _to_runtime(model) |
| 37 | 37 | ||
| 38 | Defaults of a loaded config come from ``asphalt_edge.default.json`` via | 38 | Defaults of a loaded config come from ``asphalt_edge.default.json`` via |
| 39 | :func:`.config_from_dict`. Nested rectified-image, gutter and weight | 39 | :func:`.config_from_dict`. Nested rectified-image, gutter and weight |
| 40 | blocks live in their own dataclasses reachable from here. To add a key, | 40 | blocks live in their own dataclasses reachable from here. To add a key, |
| 41 | add the field here *and* to the matching ``ConfigModel`` in ``config.py`` | 41 | add the field here *and* to `AsphaltEdgeConfig` in ``config.py`` *and* to |
| 42 | with the same name, type and default; the packaged JSON only needs it when | 42 | the packaged JSON, with the same name, type and default. |
| 43 | the shipped default differs. | ||
| 44 | """ | 43 | """ |
| 45 | 44 | ||
| 46 | station_step: float = 0.5 | 45 | station_step: float = 0.5 |
| 47 | half_thickness: float = 0.5 | 46 | half_thickness: float = 0.5 |
| 48 | bin_width: float = 0.1 | 47 | bin_width: float = 0.1 |
| 49 | off_range: tuple[float, float] = (-15.0, 15.0) | 48 | off_range: tuple[float, float] = (-20.0, 20.0) |
| 50 | # Search band relative to the surface-extent outer bound: the material edge | 49 | # Search band relative to the surface-extent outer bound: the material edge |
| 51 | # lies up to ``search_in`` metres inside the data extent (dark shoulder + | 50 | # lies up to ``search_in`` metres inside the data extent (dark shoulder + |
| 52 | # bright verge), with a small ``search_out`` slack for prior jitter. | 51 | # bright verge), with a small ``search_out`` slack for prior jitter. |
| 53 | # (Fallback point scorer, used only when no tile is supplied.) | 52 | # (Fallback point scorer, used only when no tile is supplied.) |
| 62 | # this dataset (no confirmed concrete gutter); never drives the edge. | 61 | # this dataset (no confirmed concrete gutter); never drives the edge. |
| 63 | gutter: GutterConfig = field(default_factory=GutterConfig) | 62 | gutter: GutterConfig = field(default_factory=GutterConfig) |
| 64 | min_confidence: float = 0.25 | 63 | min_confidence: float = 0.25 |
| 65 | median_window_m: float = 15.0 | 64 | median_window_m: float = 15.0 |
| 66 | mad_k: float = 3.5 | 65 | mad_k: float = 3.0 |
| 67 | max_interp_gap_m: float = 5.0 | 66 | max_interp_gap_m: float = 5.0 |
| 68 | max_interp_offset_jump_m: float = 0.5 | 67 | max_interp_offset_jump_m: float = 0.5 |
| 69 | max_offset_rate: float = 0.6 | 68 | max_offset_rate: float = 0.6 |
| 70 | min_run_m: float = 3.0 | 69 | min_run_m: float = 3.0 |
| 98 | # density/roughness change and a detrended-z kink rather than a bright verge. | 98 | # density/roughness change and a detrended-z kink rather than a bright verge. |
| 99 | intensity_weight: float = 1.0 | 99 | intensity_weight: float = 1.0 |
| 100 | texture_weight: float = 0.6 | 100 | texture_weight: float = 0.6 |
| 101 | density_weight: float = 0.0 # point density drop (asphalt dense -> sparse) | 101 | density_weight: float = 0.0 # point density drop (asphalt dense -> sparse) |
| 102 | roughness_weight: float = 0.0 # outward rise in per-cell z-roughness | 102 | roughness_weight: float = 0.4 # outward rise in per-cell z-roughness |
| 103 | z_step_weight: float = 0.0 # outward rise in per-cell mean elevation (kink) | 103 | z_step_weight: float = 0.0 # outward rise in per-cell mean elevation (kink) |
| 104 | # z-range (maxโmin z per pixel) channel: sharp pavement-edge ridge. | 104 | # z-range (maxโmin z per pixel) channel: sharp pavement-edge ridge. |
| 105 | zrange_weight: float = 1.5 | 105 | zrange_weight: float = 1.5 |
| 106 | zrange_win_m: float = 0.4 # outward-step window; z transition is sharp | 106 | zrange_win_m: float = 0.4 # outward-step window; z transition is sharp |
| 2 | 2 | ||
| 3 | from __future__ import annotations | 3 | from __future__ import annotations |
| 4 | 4 | ||
| 5 | import dataclasses | 5 | import dataclasses |
| 6 | import json | ||
| 6 | import typing | 7 | import typing |
| 7 | from pathlib import Path | 8 | from pathlib import Path |
| 8 | 9 | ||
| 9 | import pytest | 10 | import pytest |
| 11 | 12 | ||
| 12 | from iolabs_point_cloud_detection_asphaltedge.config import ( | 13 | from iolabs_point_cloud_detection_asphaltedge.config import ( |
| 13 | AsphaltEdgeConfig, | 14 | AsphaltEdgeConfig, |
| 14 | AsphaltEdgeConfigError, | 15 | AsphaltEdgeConfigError, |
| 15 | FeatureWeightsConfig, | 16 | AsphaltEdgeGutterConfig, |
| 16 | GutterConfigModel, | 17 | AsphaltEdgeImageConfig, |
| 17 | ImageEdgeConfigModel, | 18 | AsphaltEdgeWeightsConfig, |
| 18 | config_from_dict, | 19 | config_from_dict, |
| 19 | load_asphalt_edge_config, | 20 | load_asphalt_edge_config, |
| 21 | load_default_config_dict, | ||
| 22 | parse_set_overrides, | ||
| 20 | ) | 23 | ) |
| 21 | from iolabs_point_cloud_detection_asphaltedge.detector import Config | 24 | from iolabs_point_cloud_detection_asphaltedge.detector import Config |
| 22 | from iolabs_point_cloud_detection_asphaltedge.features import FeatureWeights | 25 | from iolabs_point_cloud_detection_asphaltedge.features import FeatureWeights |
| 23 | from iolabs_point_cloud_detection_asphaltedge.gutter import GutterConfig | 26 | from iolabs_point_cloud_detection_asphaltedge.gutter import GutterConfig |
| 24 | from iolabs_point_cloud_detection_asphaltedge.image_edge import ImageEdgeConfig | 27 | from iolabs_point_cloud_detection_asphaltedge.image_edge import ImageEdgeConfig |
| 25 | 28 | ||
| 26 | 29 | ||
| 27 | def test_error_class_is_config_error_and_value_error() -> None: | 30 | def _packaged_json() -> dict: |
| 28 | assert issubclass(AsphaltEdgeConfigError, config_loader.ConfigError) | 31 | path = config_loader.default_config_path( |
| 29 | assert issubclass(AsphaltEdgeConfigError, ValueError) | 32 | "iolabs_point_cloud_detection_asphaltedge", "asphalt_edge.default.json" |
| 33 | ) | ||
| 34 | return json.loads(path.read_text(encoding="utf-8")) | ||
| 35 | |||
| 30 | 36 | ||
| 37 | def _as_json(config: dict) -> dict: | ||
| 38 | """Normalise a ``model_dump`` (tuples) to JSON types (lists).""" | ||
| 39 | return json.loads(json.dumps(config)) | ||
| 31 | 40 | ||
| 32 | def test_load_packaged_defaults_with_zero_overrides() -> None: | 41 | |
| 42 | def test_model_defaults_match_packaged_json() -> None: | ||
| 43 | assert _as_json(AsphaltEdgeConfig().model_dump()) == _packaged_json() | ||
| 44 | |||
| 45 | |||
| 46 | def test_load_asphalt_edge_config_returns_packaged_defaults() -> None: | ||
| 33 | cfg = load_asphalt_edge_config() | 47 | cfg = load_asphalt_edge_config() |
| 34 | assert isinstance(cfg, Config) | 48 | assert isinstance(cfg, Config) |
| 49 | assert dataclasses.asdict(cfg) == AsphaltEdgeConfig().model_dump() | ||
| 35 | assert cfg.station_step == 0.5 | 50 | assert cfg.station_step == 0.5 |
| 36 | assert cfg.off_range == (-20.0, 20.0) | 51 | assert cfg.off_range == (-20.0, 20.0) |
| 37 | assert cfg.mad_k == 3.0 | 52 | assert cfg.mad_k == 3.0 |
| 38 | assert cfg.weights.intensity_step == 2.5 | 53 | assert cfg.weights.intensity_step == 2.5 |
| 39 | assert cfg.image.left_band == (1.0, 17.0) | 54 | assert cfg.image.left_band == (1.0, 17.0) |
| 40 | assert cfg.image.roughness_weight == 0.4 | 55 | assert cfg.image.roughness_weight == 0.4 |
| 56 | assert cfg.image.zrange_weight == 1.5 | ||
| 41 | assert cfg.image.robust is True | 57 | assert cfg.image.robust is True |
| 42 | assert cfg.gutter.enabled is True | 58 | assert cfg.gutter.enabled is True |
| 43 | assert cfg.gutter.step_thr == 4000.0 | 59 | assert cfg.gutter.step_thr == 4000.0 |
| 44 | # Dataclass-only knobs (not in the packaged JSON) keep their class defaults. | ||
| 45 | assert cfg.image.zrange_weight == 1.5 | ||
| 46 | assert cfg.polyline.min_confidence == cfg.min_confidence | 60 | assert cfg.polyline.min_confidence == cfg.min_confidence |
| 47 | 61 | ||
| 48 | 62 | ||
| 49 | def test_overrides_deep_merge_nested_sections() -> None: | 63 | def test_load_default_config_dict_matches_packaged_json() -> None: |
| 64 | assert _as_json(load_default_config_dict()) == _packaged_json() | ||
| 65 | |||
| 66 | |||
| 67 | def test_error_class_is_config_error() -> None: | ||
| 68 | assert issubclass(AsphaltEdgeConfigError, config_loader.ConfigError) | ||
| 69 | assert issubclass(AsphaltEdgeConfigError, ValueError) | ||
| 70 | |||
| 71 | |||
| 72 | def test_overrides_deep_merge_onto_defaults() -> None: | ||
| 50 | cfg = load_asphalt_edge_config( | 73 | cfg = load_asphalt_edge_config( |
| 51 | overrides={ | 74 | overrides={ |
| 52 | "min_confidence": 0.4, | 75 | "min_confidence": 0.4, |
| 53 | "weights": {"z_rise": 9.0}, | 76 | "weights": {"z_rise": 9.0}, |
| 64 | assert cfg.gutter.enabled is False | 87 | assert cfg.gutter.enabled is False |
| 65 | assert cfg.gutter.min_conf == 0.35 | 88 | assert cfg.gutter.min_conf == 0.35 |
| 66 | 89 | ||
| 67 | 90 | ||
| 68 | def test_unknown_top_level_key_rejected() -> None: | 91 | def test_unknown_top_level_key_is_rejected() -> None: |
| 69 | with pytest.raises(AsphaltEdgeConfigError, match="not_a_key") as excinfo: | 92 | with pytest.raises(AsphaltEdgeConfigError, match="not_a_key") as excinfo: |
| 70 | load_asphalt_edge_config(overrides={"not_a_key": 1}) | 93 | load_asphalt_edge_config(overrides={"not_a_key": 1}) |
| 71 | message = str(excinfo.value) | 94 | message = str(excinfo.value) |
| 72 | assert "Unknown" in message | 95 | assert "Unknown" in message |
| 73 | assert "station_step" in message | 96 | assert "station_step" in message |
| 74 | 97 | ||
| 75 | 98 | ||
| 76 | def test_unknown_nested_key_rejected() -> None: | 99 | def test_unknown_nested_key_is_rejected() -> None: |
| 77 | with pytest.raises(AsphaltEdgeConfigError, match="typo_band") as excinfo: | 100 | with pytest.raises(AsphaltEdgeConfigError, match="typo_band") as excinfo: |
| 78 | load_asphalt_edge_config(overrides={"image": {"typo_band": [0.0, 1.0]}}) | 101 | load_asphalt_edge_config(overrides={"image": {"typo_band": [0.0, 1.0]}}) |
| 79 | message = str(excinfo.value) | 102 | message = str(excinfo.value) |
| 80 | assert "Unknown" in message | 103 | assert "Unknown" in message |
| 81 | assert "left_band" in message | 104 | assert "left_band" in message |
| 82 | 105 | ||
| 83 | 106 | ||
| 107 | def test_set_override_coercion_and_rejection() -> None: | ||
| 108 | overrides = parse_set_overrides( | ||
| 109 | ["station_step=1e3", "image.robust=on", "gutter.min_conf=0.5"] | ||
| 110 | ) | ||
| 111 | assert overrides == { | ||
| 112 | "station_step": 1000.0, | ||
| 113 | "image": {"robust": "on"}, | ||
| 114 | "gutter": {"min_conf": 0.5}, | ||
| 115 | } | ||
| 116 | cfg = load_asphalt_edge_config(overrides=overrides) | ||
| 117 | assert cfg.station_step == 1000.0 | ||
| 118 | assert cfg.image.robust is True | ||
| 119 | assert cfg.gutter.min_conf == 0.5 | ||
| 120 | with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"): | ||
| 121 | load_asphalt_edge_config(overrides=parse_set_overrides(["image.robust=flase"])) | ||
| 122 | with pytest.raises(AsphaltEdgeConfigError, match="Expected"): | ||
| 123 | parse_set_overrides(["station_step"]) | ||
| 124 | |||
| 125 | |||
| 84 | def test_config_from_dict_unknown_key_rejected() -> None: | 126 | def test_config_from_dict_unknown_key_rejected() -> None: |
| 85 | with pytest.raises(AsphaltEdgeConfigError, match="bogus"): | 127 | with pytest.raises(AsphaltEdgeConfigError, match="bogus"): |
| 86 | config_from_dict({"station_step": 0.5, "bogus": 1}) | 128 | config_from_dict({"station_step": 0.5, "bogus": 1}) |
| 87 | 129 | ||
| 88 | 130 | ||
| 89 | def test_bool_string_overrides_coerce() -> None: | ||
| 90 | cfg = load_asphalt_edge_config( | ||
| 91 | overrides={"image": {"robust": "false"}, "gutter": {"enabled": "off"}} | ||
| 92 | ) | ||
| 93 | assert cfg.image.robust is False | ||
| 94 | assert cfg.gutter.enabled is False | ||
| 95 | |||
| 96 | |||
| 97 | def test_invalid_bool_token_rejected() -> None: | 131 | def test_invalid_bool_token_rejected() -> None: |
| 98 | with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"): | 132 | with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"): |
| 99 | load_asphalt_edge_config(overrides={"gutter": {"enabled": "flase"}}) | 133 | load_asphalt_edge_config(overrides={"gutter": {"enabled": "flase"}}) |
| 100 | 134 |
| 103 | with pytest.raises(AsphaltEdgeConfigError, match="Invalid float"): | 137 | with pytest.raises(AsphaltEdgeConfigError, match="Invalid float"): |
| 104 | load_asphalt_edge_config(overrides={"station_step": "abc"}) | 138 | load_asphalt_edge_config(overrides={"station_step": "abc"}) |
| 105 | 139 | ||
| 106 | 140 | ||
| 141 | def test_out_of_range_value_rejected() -> None: | ||
| 142 | with pytest.raises(AsphaltEdgeConfigError, match="station_step"): | ||
| 143 | load_asphalt_edge_config(overrides={"station_step": 0.0}) | ||
| 144 | with pytest.raises(AsphaltEdgeConfigError, match="min_confidence"): | ||
| 145 | load_asphalt_edge_config(overrides={"min_confidence": 1.5}) | ||
| 146 | with pytest.raises(AsphaltEdgeConfigError, match="roughness_weight"): | ||
| 147 | load_asphalt_edge_config(overrides={"image": {"roughness_weight": -1.0}}) | ||
| 148 | |||
| 149 | |||
| 150 | def test_unordered_interval_rejected() -> None: | ||
| 151 | with pytest.raises(AsphaltEdgeConfigError, match="off_range"): | ||
| 152 | config_from_dict({"off_range": [5.0, -5.0]}) | ||
| 153 | with pytest.raises(AsphaltEdgeConfigError, match="left_band"): | ||
| 154 | config_from_dict({"image": {"left_band": [17.0, 1.0]}}) | ||
| 155 | |||
| 156 | |||
| 107 | def test_off_range_list_becomes_float_tuple() -> None: | 157 | def test_off_range_list_becomes_float_tuple() -> None: |
| 108 | cfg = config_from_dict({"off_range": ["-3", "4"]}) | 158 | cfg = config_from_dict({"off_range": ["-3", "4"]}) |
| 109 | assert cfg.off_range == (-3.0, 4.0) | 159 | assert cfg.off_range == (-3.0, 4.0) |
| 110 | 160 |
| 120 | assert isinstance(cfg.weights, FeatureWeights) | 170 | assert isinstance(cfg.weights, FeatureWeights) |
| 121 | assert isinstance(cfg.image, ImageEdgeConfig) | 171 | assert isinstance(cfg.image, ImageEdgeConfig) |
| 122 | assert isinstance(cfg.gutter, GutterConfig) | 172 | assert isinstance(cfg.gutter, GutterConfig) |
| 123 | assert cfg.min_confidence == 0.1 | 173 | assert cfg.min_confidence == 0.1 |
| 124 | # Partial mappings keep dataclass defaults for omitted keys. | 174 | # Partial mappings keep the shipped defaults for omitted keys. |
| 125 | assert cfg.off_range == (-15.0, 15.0) | 175 | assert cfg.off_range == (-20.0, 20.0) |
| 126 | assert cfg.image.roughness_weight == 0.0 | 176 | assert cfg.image.roughness_weight == 0.4 |
| 127 | 177 | ||
| 128 | 178 | ||
| 129 | def test_load_from_config_path(tmp_path: Path) -> None: | 179 | def test_load_from_config_path(tmp_path: Path) -> None: |
| 130 | path = tmp_path / "custom.json" | 180 | path = tmp_path / "custom.json" |
| 143 | @pytest.mark.parametrize( | 193 | @pytest.mark.parametrize( |
| 144 | ("dataclass_cls", "model_cls"), | 194 | ("dataclass_cls", "model_cls"), |
| 145 | [ | 195 | [ |
| 146 | (Config, AsphaltEdgeConfig), | 196 | (Config, AsphaltEdgeConfig), |
| 147 | (FeatureWeights, FeatureWeightsConfig), | 197 | (FeatureWeights, AsphaltEdgeWeightsConfig), |
| 148 | (ImageEdgeConfig, ImageEdgeConfigModel), | 198 | (ImageEdgeConfig, AsphaltEdgeImageConfig), |
| 149 | (GutterConfig, GutterConfigModel), | 199 | (GutterConfig, AsphaltEdgeGutterConfig), |
| 150 | ], | 200 | ], |
| 151 | ) | 201 | ) |
| 152 | def test_model_mirrors_runtime_dataclass(dataclass_cls: type, model_cls: type) -> None: | 202 | def test_model_mirrors_runtime_dataclass(dataclass_cls: type, model_cls: type) -> None: |
| 153 | """Every model field must exist on the dataclass with the same type and default.""" | 203 | """Every model field must exist on the dataclass with the same type and default.""" |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.