Miroslav Simko <developer.ai@iolabs.ch> 2026-09-02T08:33:50+02:00
Commit #50 ยท 7 snippets
pyproject.toml | 5 +- .../config.py | 299 ++++++++++++++------- .../detector.py | 9 +- tests/test_config.py | 165 ++++++++++++ 4 files changed, 380 insertions(+), 98 deletions(-)
| 1 | """Packaged-default configuration loader for asphalt edge detection. | 1 | """Packaged-default configuration loader for asphalt edge detection. |
| 2 | 2 | ||
| 3 | Mirrors the sibling iolabs point-cloud packages: defaults live in a | 3 | Defaults live in the force-included ``asphalt_edge.default.json``. A pydantic |
| 4 | force-included ``asphalt_edge.default.json`` and are loaded/merged here into the | 4 | model tree derived from :class:`iolabs.common.config_loader.ConfigModel` is |
| 5 | typed :class:`Config` carrier, rather than being hardcoded only as dataclass | 5 | the schema: nested JSON sections are nested models, field names are JSON keys, |
| 6 | field defaults. | 6 | and unknown keys are rejected. Validated values are copied onto the runtime |
| 7 | dataclasses (:class:`~.detector.Config`, :class:`~.features.FeatureWeights`, | ||
| 8 | :class:`~.image_edge.ImageEdgeConfig`, :class:`~.gutter.GutterConfig`) because | ||
| 9 | the algorithm, tests and scripts mutate those carriers. | ||
| 10 | |||
| 11 | To add a config key: add the field to the runtime dataclass *and* to the | ||
| 12 | matching ``ConfigModel`` subclass here, with the same name, type and default | ||
| 13 | (``_to_runtime`` passes every model field on to the dataclass, so a field on | ||
| 14 | only one side raises). Add it to the packaged JSON only when the shipped | ||
| 15 | default differs from the field default; ``test_config`` guards the parity. | ||
| 7 | """ | 16 | """ |
| 8 | 17 | ||
| 9 | import json | 18 | from __future__ import annotations |
| 10 | from dataclasses import fields | 19 | |
| 20 | import logging | ||
| 11 | from pathlib import Path | 21 | from pathlib import Path |
| 12 | from typing import Any | 22 | from typing import Any |
| 13 | 23 | ||
| 14 | from iolabs.common.config_loader import ( | 24 | from iolabs.common import config_loader |
| 15 | ConfigError, | ||
| 16 | deep_merge_dicts, | ||
| 17 | default_config_path, | ||
| 18 | validate_allowed_keys, | ||
| 19 | ) | ||
| 20 | 25 | ||
| 21 | from .detector import Config | 26 | from .detector import Config |
| 22 | from .features import FeatureWeights | 27 | from .features import FeatureWeights |
| 23 | from .gutter import GutterConfig | 28 | from .gutter import GutterConfig |
| 24 | from .image_edge import ImageEdgeConfig | 29 | from .image_edge import ImageEdgeConfig |
| 25 | 30 | ||
| 26 | ALLOWED_WEIGHT_KEYS = frozenset(field.name for field in fields(FeatureWeights)) | 31 | logger = logging.getLogger(__name__) |
| 27 | ALLOWED_IMAGE_KEYS = frozenset(field.name for field in fields(ImageEdgeConfig)) | ||
| 28 | ALLOWED_GUTTER_KEYS = frozenset(field.name for field in fields(GutterConfig)) | ||
| 29 | ALLOWED_CONFIG_KEYS = ( | ||
| 30 | frozenset(field.name for field in fields(Config)) | {"weights", "image", "gutter"} | ||
| 31 | ) | ||
| 32 | _IMAGE_TUPLE_KEYS = ( | ||
| 33 | "left_band", | ||
| 34 | "right_band", | ||
| 35 | "inner_left_band", | ||
| 36 | "inner_right_band", | ||
| 37 | "zrange_resume_zone_m", | ||
| 38 | ) | ||
| 39 | 32 | ||
| 40 | _PACKAGE = "iolabs_point_cloud_detection_asphaltedge" | 33 | _PACKAGE = "iolabs_point_cloud_detection_asphaltedge" |
| 41 | _DEFAULT_CONFIG_FILENAME = "asphalt_edge.default.json" | 34 | _DEFAULT_CONFIG_FILENAME = "asphalt_edge.default.json" |
| 42 | 35 | _CONTEXT = "asphalt edge config" | |
| 43 | 36 | ||
| 44 | class AsphaltEdgeConfigError(ConfigError): | 37 | |
| 45 | """Raised when the asphalt edge config contains unsupported keys.""" | 38 | class AsphaltEdgeConfigError(config_loader.ConfigError): |
| 39 | """Raised when the asphalt edge config contains unsupported keys or values.""" | ||
| 40 | |||
| 41 | |||
| 42 | class FeatureWeightsConfig(config_loader.ConfigModel): | ||
| 43 | """``weights`` block of ``asphalt_edge.default.json``.""" | ||
| 44 | |||
| 45 | intensity_step: float = 2.5 | ||
| 46 | density_drop: float = 2.0 | ||
| 47 | z_rise: float = 1.0 | ||
| 48 | roughness_rise: float = 0.8 | ||
| 49 | surface_notch: float = 1.5 | ||
| 50 | dog_step: float = 1.0 | ||
| 51 | |||
| 52 | |||
| 53 | class ImageEdgeConfigModel(config_loader.ConfigModel): | ||
| 54 | """``image`` block plus the dataclass knobs accepted as overrides.""" | ||
| 55 | |||
| 56 | station_step: float = 0.25 | ||
| 57 | offset_step: float = 0.05 | ||
| 58 | offset_min: float = -18.0 | ||
| 59 | offset_max: float = 18.0 | ||
| 60 | left_band: tuple[float, float] = (1.0, 17.0) | ||
| 61 | right_band: tuple[float, float] = (-17.0, -1.0) | ||
| 62 | inner_left_band: tuple[float, float] = (0.3, 6.0) | ||
| 63 | inner_right_band: tuple[float, float] = (-6.0, -0.3) | ||
| 64 | inner_min_valid_frac: float = 0.2 | ||
| 65 | inner_bound_margin_m: float = 1.25 | ||
| 66 | step_win_m: float = 1.2 | ||
| 67 | tex_win_m: float = 0.5 | ||
| 68 | smooth_offset_m: float = 0.1 | ||
| 69 | intensity_weight: float = 1.0 | ||
| 70 | texture_weight: float = 0.6 | ||
| 71 | density_weight: float = 0.0 | ||
| 72 | roughness_weight: float = 0.0 | ||
| 73 | z_step_weight: float = 0.0 | ||
| 74 | zrange_weight: float = 1.5 | ||
| 75 | zrange_win_m: float = 0.4 | ||
| 76 | zrange_cap_m: float = 0.15 | ||
| 77 | zrange_snap: bool = True | ||
| 78 | zrange_snap_window_m: float = 0.4 | ||
| 79 | zrange_snap_max_shift_m: float = 0.25 | ||
| 80 | zrange_snap_thresh_min_m: float = 0.012 | ||
| 81 | zrange_snap_mad_k: float = 4.0 | ||
| 82 | zrange_snap_rel_h: float = 0.10 | ||
| 83 | zrange_snap_regularize: bool = True | ||
| 84 | zrange_snap_reg_max_dev_m: float = 0.30 | ||
| 85 | zrange_intensity_snap: bool = True | ||
| 86 | zrange_isnap_grad_k: float = 3.0 | ||
| 87 | zrange_isnap_min_step: float = 8.0 | ||
| 88 | zrange_isnap_paint_guard: bool = True | ||
| 89 | zrange_onset_bonus: float = 1.2 | ||
| 90 | zrange_onset_smooth: bool = True | ||
| 91 | zrange_guardrail_z_m: float = 0.30 | ||
| 92 | zrange_guardrail_lookback_m: float = 2.0 | ||
| 93 | zrange_guardrail_veto: bool = False | ||
| 94 | zrange_guardrail_veto_conf: float = 0.7 | ||
| 95 | zrange_guardrail_veto_margin_m: float = 0.5 | ||
| 96 | zrange_guardrail_veto_types: tuple[str, ...] = ("w_beam",) | ||
| 97 | zrange_guardrail_veto_walls: bool = False | ||
| 98 | zrange_resume_weight: float = 3.0 | ||
| 99 | zrange_resume_zone_m: tuple[float, float] = (0.5, 2.5) | ||
| 100 | zrange_flat_thresh_m: float = 0.015 | ||
| 101 | zrange_resume_tex_k: float = 1.5 | ||
| 102 | zrange_shoulder_rule: bool = True | ||
| 103 | zrange_shoulder_lookback_m: float = 2.0 | ||
| 104 | zrange_shoulder_min_gap_m: float = 0.3 | ||
| 105 | zrange_shoulder_corridor_anchor: bool = True | ||
| 106 | zrange_shoulder_sustain_m: float = 1.5 | ||
| 107 | zrange_shoulder_max_search_m: float = 8.0 | ||
| 108 | zrange_shoulder_grad_k: float = 2.0 | ||
| 109 | zrange_shoulder_min_step: float = 6.0 | ||
| 110 | zrange_shoulder_min_width_m: float = 3.0 | ||
| 111 | zrange_shoulder_support_m: float = 1.5 | ||
| 112 | zrange_shoulder_support_tol_m: float = 0.6 | ||
| 113 | zrange_shoulder_support_frac: float = 0.5 | ||
| 114 | zrange_shoulder_consolidate: bool = True | ||
| 115 | zrange_shoulder_revert_isolated: bool = True | ||
| 116 | zrange_shoulder_run_smoothness_guard: bool = False | ||
| 117 | zrange_shoulder_run_max_step_m: float = 0.046 | ||
| 118 | zrange_paint_veto: bool = True | ||
| 119 | zrange_paint_quantile: float = 0.92 | ||
| 120 | zrange_paint_max_width_m: float = 0.5 | ||
| 121 | zrange_paint_corridor_margin_m: float = 1.5 | ||
| 122 | robust: bool = True | ||
| 123 | edge_smooth_window_m: float = 2.0 | ||
| 124 | slope_cheap_m: float = 0.15 | ||
| 125 | trans_lin: float = 9.0 | ||
| 126 | trans_quad: float = 220.0 | ||
| 127 | max_jump_m: float = 0.9 | ||
| 128 | bridge_stiffness: float = 3.0 | ||
| 129 | bridge_anchor_min_conf: float = 0.75 | ||
| 130 | invalid_penalty: float = 2.5 | ||
| 131 | min_valid_frac: float = 0.35 | ||
| 132 | conf_gain: float = 0.7 | ||
| 133 | outer_bound_margin_m: float = 1.5 | ||
| 134 | outer_median_bound: bool = False | ||
| 135 | outer_median_bound_margin_m: float = 0.5 | ||
| 136 | outer_median_bound_hard: bool = False | ||
| 137 | vehicle_mask: bool = True | ||
| 138 | vehicle_z_thresh_m: float = 0.02 | ||
| 139 | vehicle_corridor_margin_m: float = 0.5 | ||
| 140 | vehicle_mask_z_only: bool = False | ||
| 141 | vehicle_mask_neutral: bool = True | ||
| 142 | vehicle_mask_seed_guard: bool = False | ||
| 143 | vehicle_mask_baseline_fill: bool = False | ||
| 144 | edge_support_gate: bool = False | ||
| 145 | use_crf: bool = False | ||
| 146 | crf_second_order: bool = True | ||
| 147 | crf_lambda_parallel: float = 4.0 | ||
| 148 | crf_lambda_curvature: float = 8.0 | ||
| 149 | crf_lambda_prior: float = 1.5 | ||
| 150 | crf_huber_delta_m: float = 0.10 | ||
| 151 | crf_delta_cap_m: float = 0.6 | ||
| 152 | crf_conf_ref: float = 1.0 | ||
| 153 | crf_not_observable_support: float = 0.15 | ||
| 154 | crf_support_intensity_ref_dn: float = 40.0 | ||
| 155 | crf_support_zrange_ref_m: float = 0.05 | ||
| 156 | |||
| 157 | |||
| 158 | class GutterConfigModel(config_loader.ConfigModel): | ||
| 159 | """``gutter`` block of ``asphalt_edge.default.json``.""" | ||
| 160 | |||
| 161 | enabled: bool = True | ||
| 162 | station_step: float = 1.0 | ||
| 163 | half_window_m: float = 0.75 | ||
| 164 | step_thr: float = 4000.0 | ||
| 165 | rough_mult: float = 2.5 | ||
| 166 | max_gutter_m: float = 1.5 | ||
| 167 | min_conf: float = 0.35 | ||
| 168 | |||
| 169 | |||
| 170 | class AsphaltEdgeConfig(config_loader.ConfigModel): | ||
| 171 | """Root schema mirroring ``asphalt_edge.default.json``.""" | ||
| 172 | |||
| 173 | station_step: float = 0.5 | ||
| 174 | half_thickness: float = 0.5 | ||
| 175 | bin_width: float = 0.1 | ||
| 176 | off_range: tuple[float, float] = (-15.0, 15.0) | ||
| 177 | search_in: float = 4.5 | ||
| 178 | search_out: float = 0.3 | ||
| 179 | band_m: float = 1.0 | ||
| 180 | weights: FeatureWeightsConfig = FeatureWeightsConfig() | ||
| 181 | image: ImageEdgeConfigModel = ImageEdgeConfigModel() | ||
| 182 | gutter: GutterConfigModel = GutterConfigModel() | ||
| 183 | min_confidence: float = 0.25 | ||
| 184 | median_window_m: float = 15.0 | ||
| 185 | mad_k: float = 3.5 | ||
| 186 | max_interp_gap_m: float = 5.0 | ||
| 187 | max_interp_offset_jump_m: float = 0.5 | ||
| 188 | max_offset_rate: float = 0.6 | ||
| 189 | min_run_m: float = 3.0 | ||
| 190 | end_stub_m: float = 5.0 | ||
| 191 | savgol_window_m: float = 11.0 | ||
| 192 | savgol_order: int = 2 | ||
| 193 | max_points: int = 7_000_000 | ||
| 194 | hash_round_units_per_m: float = 1000.0 | ||
| 195 | min_carriageway_m: float = 2.0 | ||
| 196 | |||
| 197 | |||
| 198 | def _to_runtime(model: AsphaltEdgeConfig) -> Config: | ||
| 199 | """Copy a validated model onto the mutable runtime dataclasses.""" | ||
| 200 | data = model.model_dump() | ||
| 201 | return Config( | ||
| 202 | weights=FeatureWeights(**data.pop("weights")), | ||
| 203 | image=ImageEdgeConfig(**data.pop("image")), | ||
| 204 | gutter=GutterConfig(**data.pop("gutter")), | ||
| 205 | **data, | ||
| 206 | ) | ||
| 46 | 207 | ||
| 47 | 208 | ||
| 48 | def config_from_dict(raw_config: dict[str, Any]) -> Config: | 209 | def config_from_dict(raw_config: dict[str, Any]) -> Config: |
| 49 | """Build a typed :class:`Config` from a (possibly partial) mapping.""" | 210 | """Build a typed :class:`Config` from a (possibly partial) mapping.""" |
| 50 | config = dict(raw_config) | 211 | model = config_loader.validate_config( |
| 51 | validate_allowed_keys( | 212 | AsphaltEdgeConfig, |
| 52 | config, ALLOWED_CONFIG_KEYS, context="asphalt edge config", error_cls=AsphaltEdgeConfigError | 213 | raw_config, |
| 214 | context=_CONTEXT, | ||
| 215 | error_cls=AsphaltEdgeConfigError, | ||
| 53 | ) | 216 | ) |
| 54 | 217 | return _to_runtime(model) | |
| 55 | weights_raw = config.pop("weights", None) | ||
| 56 | if weights_raw is not None: | ||
| 57 | if not isinstance(weights_raw, dict): | ||
| 58 | raise AsphaltEdgeConfigError("asphalt edge config field 'weights' must be a mapping") | ||
| 59 | validate_allowed_keys( | ||
| 60 | weights_raw, | ||
| 61 | ALLOWED_WEIGHT_KEYS, | ||
| 62 | context="asphalt edge weights", | ||
| 63 | error_cls=AsphaltEdgeConfigError, | ||
| 64 | ) | ||
| 65 | weights = FeatureWeights(**weights_raw) | ||
| 66 | else: | ||
| 67 | weights = FeatureWeights() | ||
| 68 | |||
| 69 | image_raw = config.pop("image", None) | ||
| 70 | if image_raw is not None: | ||
| 71 | if not isinstance(image_raw, dict): | ||
| 72 | raise AsphaltEdgeConfigError("asphalt edge config field 'image' must be a mapping") | ||
| 73 | image_raw = dict(image_raw) | ||
| 74 | validate_allowed_keys( | ||
| 75 | image_raw, | ||
| 76 | ALLOWED_IMAGE_KEYS, | ||
| 77 | context="asphalt edge image", | ||
| 78 | error_cls=AsphaltEdgeConfigError, | ||
| 79 | ) | ||
| 80 | for key in _IMAGE_TUPLE_KEYS: | ||
| 81 | if key in image_raw and image_raw[key] is not None: | ||
| 82 | lo, hi = image_raw[key] | ||
| 83 | image_raw[key] = (float(lo), float(hi)) | ||
| 84 | image = ImageEdgeConfig(**image_raw) | ||
| 85 | else: | ||
| 86 | image = ImageEdgeConfig() | ||
| 87 | |||
| 88 | gutter_raw = config.pop("gutter", None) | ||
| 89 | if gutter_raw is not None: | ||
| 90 | if not isinstance(gutter_raw, dict): | ||
| 91 | raise AsphaltEdgeConfigError("asphalt edge config field 'gutter' must be a mapping") | ||
| 92 | validate_allowed_keys( | ||
| 93 | gutter_raw, | ||
| 94 | ALLOWED_GUTTER_KEYS, | ||
| 95 | context="asphalt edge gutter", | ||
| 96 | error_cls=AsphaltEdgeConfigError, | ||
| 97 | ) | ||
| 98 | gutter = GutterConfig(**gutter_raw) | ||
| 99 | else: | ||
| 100 | gutter = GutterConfig() | ||
| 101 | |||
| 102 | if "off_range" in config and config["off_range"] is not None: | ||
| 103 | lo, hi = config["off_range"] | ||
| 104 | config["off_range"] = (float(lo), float(hi)) | ||
| 105 | |||
| 106 | return Config(weights=weights, image=image, gutter=gutter, **config) | ||
| 107 | 218 | ||
| 108 | 219 | ||
| 109 | def load_asphalt_edge_config( | 220 | def load_asphalt_edge_config( |
| 110 | config_path: str | Path | None = None, | 221 | config_path: str | Path | None = None, |
| 111 | *, | 222 | *, |
| 112 | overrides: dict[str, Any] | None = None, | 223 | overrides: dict[str, Any] | None = None, |
| 113 | ) -> Config: | 224 | ) -> Config: |
| 114 | """Load the packaged default config, apply optional overrides, return a Config.""" | 225 | """Load the packaged default config, apply optional overrides, return a Config.""" |
| 115 | resolved = ( | 226 | model = config_loader.load_config( |
| 116 | Path(config_path) | 227 | AsphaltEdgeConfig, |
| 117 | if config_path is not None | 228 | package=_PACKAGE, |
| 118 | else default_config_path(_PACKAGE, _DEFAULT_CONFIG_FILENAME) | 229 | filename=_DEFAULT_CONFIG_FILENAME, |
| 230 | overrides=overrides, | ||
| 231 | config_path=config_path, | ||
| 232 | context=_CONTEXT, | ||
| 233 | error_cls=AsphaltEdgeConfigError, | ||
| 119 | ) | 234 | ) |
| 120 | with resolved.open("r", encoding="utf-8") as handle: | ||
| 121 | raw_config: dict[str, Any] = json.load(handle) | ||
| 122 | if overrides: | 235 | if overrides: |
| 123 | raw_config = deep_merge_dicts(raw_config, dict(overrides)) | 236 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) |
| 124 | return config_from_dict(raw_config) | 237 | return _to_runtime(model) |
| 34 | @dataclass | 34 | @dataclass |
| 35 | class Config: | 35 | class Config: |
| 36 | """Tunable parameters of one asphalt-edge detection run. | 36 | """Tunable parameters of one asphalt-edge detection run. |
| 37 | 37 | ||
| 38 | Defaults mirror ``asphalt_edge.default.json``; :func:`.config_from_dict` | 38 | Defaults of a loaded config come from ``asphalt_edge.default.json`` via |
| 39 | overlays a user config onto them. The nested rectified-image, gutter and | 39 | :func:`.config_from_dict`. Nested rectified-image, gutter and weight |
| 40 | weight blocks live in their own dataclasses reachable from here. | 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`` | ||
| 42 | with the same name, type and default; the packaged JSON only needs it when | ||
| 43 | the shipped default differs. | ||
| 41 | """ | 44 | """ |
| 42 | 45 | ||
| 43 | station_step: float = 0.5 | 46 | station_step: float = 0.5 |
| 44 | half_thickness: float = 0.5 | 47 | half_thickness: float = 0.5 |
| 1 | """Packaged-JSON asphalt-edge config loading and validation.""" | ||
| 2 | |||
| 3 | from __future__ import annotations | ||
| 4 | |||
| 5 | import dataclasses | ||
| 6 | import typing | ||
| 7 | from pathlib import Path | ||
| 8 | |||
| 9 | import pytest | ||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | from iolabs_point_cloud_detection_asphaltedge.config import ( | ||
| 13 | AsphaltEdgeConfig, | ||
| 14 | AsphaltEdgeConfigError, | ||
| 15 | FeatureWeightsConfig, | ||
| 16 | GutterConfigModel, | ||
| 17 | ImageEdgeConfigModel, | ||
| 18 | config_from_dict, | ||
| 19 | load_asphalt_edge_config, | ||
| 20 | ) | ||
| 21 | from iolabs_point_cloud_detection_asphaltedge.detector import Config | ||
| 22 | from iolabs_point_cloud_detection_asphaltedge.features import FeatureWeights | ||
| 23 | from iolabs_point_cloud_detection_asphaltedge.gutter import GutterConfig | ||
| 24 | from iolabs_point_cloud_detection_asphaltedge.image_edge import ImageEdgeConfig | ||
| 25 | |||
| 26 | |||
| 27 | def test_error_class_is_config_error_and_value_error() -> None: | ||
| 28 | assert issubclass(AsphaltEdgeConfigError, config_loader.ConfigError) | ||
| 29 | assert issubclass(AsphaltEdgeConfigError, ValueError) | ||
| 30 | |||
| 31 | |||
| 32 | def test_load_packaged_defaults_with_zero_overrides() -> None: | ||
| 33 | cfg = load_asphalt_edge_config() | ||
| 34 | assert isinstance(cfg, Config) | ||
| 35 | assert cfg.station_step == 0.5 | ||
| 36 | assert cfg.off_range == (-20.0, 20.0) | ||
| 37 | assert cfg.mad_k == 3.0 | ||
| 38 | assert cfg.weights.intensity_step == 2.5 | ||
| 39 | assert cfg.image.left_band == (1.0, 17.0) | ||
| 40 | assert cfg.image.roughness_weight == 0.4 | ||
| 41 | assert cfg.image.robust is True | ||
| 42 | assert cfg.gutter.enabled is True | ||
| 43 | 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 | ||
| 47 | |||
| 48 | |||
| 49 | def test_overrides_deep_merge_nested_sections() -> None: | ||
| 50 | cfg = load_asphalt_edge_config( | ||
| 51 | overrides={ | ||
| 52 | "min_confidence": 0.4, | ||
| 53 | "weights": {"z_rise": 9.0}, | ||
| 54 | "image": {"robust": False, "zrange_weight": 2.25}, | ||
| 55 | "gutter": {"enabled": False}, | ||
| 56 | } | ||
| 57 | ) | ||
| 58 | assert cfg.min_confidence == 0.4 | ||
| 59 | assert cfg.weights.z_rise == 9.0 | ||
| 60 | assert cfg.weights.intensity_step == 2.5 | ||
| 61 | assert cfg.image.robust is False | ||
| 62 | assert cfg.image.zrange_weight == 2.25 | ||
| 63 | assert cfg.image.texture_weight == 0.6 | ||
| 64 | assert cfg.gutter.enabled is False | ||
| 65 | assert cfg.gutter.min_conf == 0.35 | ||
| 66 | |||
| 67 | |||
| 68 | def test_unknown_top_level_key_rejected() -> None: | ||
| 69 | with pytest.raises(AsphaltEdgeConfigError, match="not_a_key") as excinfo: | ||
| 70 | load_asphalt_edge_config(overrides={"not_a_key": 1}) | ||
| 71 | message = str(excinfo.value) | ||
| 72 | assert "Unknown" in message | ||
| 73 | assert "station_step" in message | ||
| 74 | |||
| 75 | |||
| 76 | def test_unknown_nested_key_rejected() -> None: | ||
| 77 | with pytest.raises(AsphaltEdgeConfigError, match="typo_band") as excinfo: | ||
| 78 | load_asphalt_edge_config(overrides={"image": {"typo_band": [0.0, 1.0]}}) | ||
| 79 | message = str(excinfo.value) | ||
| 80 | assert "Unknown" in message | ||
| 81 | assert "left_band" in message | ||
| 82 | |||
| 83 | |||
| 84 | def test_config_from_dict_unknown_key_rejected() -> None: | ||
| 85 | with pytest.raises(AsphaltEdgeConfigError, match="bogus"): | ||
| 86 | config_from_dict({"station_step": 0.5, "bogus": 1}) | ||
| 87 | |||
| 88 | |||
| 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: | ||
| 98 | with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"): | ||
| 99 | load_asphalt_edge_config(overrides={"gutter": {"enabled": "flase"}}) | ||
| 100 | |||
| 101 | |||
| 102 | def test_invalid_float_token_rejected() -> None: | ||
| 103 | with pytest.raises(AsphaltEdgeConfigError, match="Invalid float"): | ||
| 104 | load_asphalt_edge_config(overrides={"station_step": "abc"}) | ||
| 105 | |||
| 106 | |||
| 107 | def test_off_range_list_becomes_float_tuple() -> None: | ||
| 108 | cfg = config_from_dict({"off_range": ["-3", "4"]}) | ||
| 109 | assert cfg.off_range == (-3.0, 4.0) | ||
| 110 | |||
| 111 | |||
| 112 | def test_image_band_list_becomes_float_tuple() -> None: | ||
| 113 | cfg = config_from_dict({"image": {"left_band": [2, 8]}}) | ||
| 114 | assert cfg.image.left_band == (2.0, 8.0) | ||
| 115 | |||
| 116 | |||
| 117 | def test_config_from_dict_builds_runtime_dataclasses() -> None: | ||
| 118 | cfg = config_from_dict({"min_confidence": 0.1}) | ||
| 119 | assert isinstance(cfg, Config) | ||
| 120 | assert isinstance(cfg.weights, FeatureWeights) | ||
| 121 | assert isinstance(cfg.image, ImageEdgeConfig) | ||
| 122 | assert isinstance(cfg.gutter, GutterConfig) | ||
| 123 | assert cfg.min_confidence == 0.1 | ||
| 124 | # Partial mappings keep dataclass defaults for omitted keys. | ||
| 125 | assert cfg.off_range == (-15.0, 15.0) | ||
| 126 | assert cfg.image.roughness_weight == 0.0 | ||
| 127 | |||
| 128 | |||
| 129 | def test_load_from_config_path(tmp_path: Path) -> None: | ||
| 130 | path = tmp_path / "custom.json" | ||
| 131 | path.write_text('{"station_step": 1.25, "gutter": {"enabled": false}}\n', encoding="utf-8") | ||
| 132 | cfg = load_asphalt_edge_config(config_path=path) | ||
| 133 | assert cfg.station_step == 1.25 | ||
| 134 | assert cfg.gutter.enabled is False | ||
| 135 | assert cfg.band_m == 1.0 | ||
| 136 | |||
| 137 | |||
| 138 | def test_except_value_error_still_catches_config_errors() -> None: | ||
| 139 | with pytest.raises(ValueError): | ||
| 140 | load_asphalt_edge_config(overrides={"nope": True}) | ||
| 141 | |||
| 142 | |||
| 143 | @pytest.mark.parametrize( | ||
| 144 | ("dataclass_cls", "model_cls"), | ||
| 145 | [ | ||
| 146 | (Config, AsphaltEdgeConfig), | ||
| 147 | (FeatureWeights, FeatureWeightsConfig), | ||
| 148 | (ImageEdgeConfig, ImageEdgeConfigModel), | ||
| 149 | (GutterConfig, GutterConfigModel), | ||
| 150 | ], | ||
| 151 | ) | ||
| 152 | 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.""" | ||
| 154 | hints = typing.get_type_hints(dataclass_cls) | ||
| 155 | nested = {"weights", "image", "gutter"} | ||
| 156 | assert {field.name for field in dataclasses.fields(dataclass_cls)} == set( | ||
| 157 | model_cls.model_fields | ||
| 158 | ) | ||
| 159 | defaults = dataclass_cls() | ||
| 160 | model = model_cls() | ||
| 161 | for name, field in model_cls.model_fields.items(): | ||
| 162 | if name in nested: | ||
| 163 | continue | ||
| 164 | assert hints[name] == field.annotation, name | ||
| 165 | assert getattr(defaults, name) == getattr(model, name), name | ||
| 0 |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-point-cloud-detection-asphaltedge" | 2 | name = "iolabs-point-cloud-detection-asphaltedge" |
| 3 | version = "0.2.0" | 3 | version = "0.2.1" |
| 4 | description = "Asphalt edge detection from highway LIDAR road-surface point clouds" | 4 | description = "Asphalt edge detection from highway LIDAR road-surface point clouds" |
| 5 | requires-python = ">=3.11,<3.13" | 5 | requires-python = ">=3.11,<3.13" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 7 | "numpy>=1.20.0", | 7 | "numpy>=1.20.0", |
| 8 | "scipy>=1.7.0", | 8 | "scipy>=1.7.0", |
| 9 | "matplotlib>=3.4.0", | 9 | "matplotlib>=3.4.0", |
| 10 | "pillow>=10.0", | 10 | "pillow>=10.0", |
| 11 | "iolabs-common>=0.6.0", | 11 | "pydantic>=2.7", |
| 12 | "iolabs-common>=0.8.0", | ||
| 12 | "iolabs-geometry-geometry>=0.11.0", | 13 | "iolabs-geometry-geometry>=0.11.0", |
| 13 | "iolabs-geometry-raster>=0.2.0", | 14 | "iolabs-geometry-raster>=0.2.0", |
| 14 | ] | 15 | ] |
| 15 | 16 |
| 1 | """Packaged-default configuration loader for asphalt edge detection. | 1 | """Packaged-default configuration loader for asphalt edge detection. |
| 2 | 2 | ||
| 3 | Mirrors the sibling iolabs point-cloud packages: defaults live in a | 3 | Defaults live in the force-included ``asphalt_edge.default.json``. A pydantic |
| 4 | force-included ``asphalt_edge.default.json`` and are loaded/merged here into the | 4 | model tree derived from :class:`iolabs.common.config_loader.ConfigModel` is |
| 5 | typed :class:`Config` carrier, rather than being hardcoded only as dataclass | 5 | the schema: nested JSON sections are nested models, field names are JSON keys, |
| 6 | field defaults. | 6 | and unknown keys are rejected. Validated values are copied onto the runtime |
| 7 | dataclasses (:class:`~.detector.Config`, :class:`~.features.FeatureWeights`, | ||
| 8 | :class:`~.image_edge.ImageEdgeConfig`, :class:`~.gutter.GutterConfig`) because | ||
| 9 | the algorithm, tests and scripts mutate those carriers. | ||
| 10 | |||
| 11 | To add a config key: add the field to the runtime dataclass *and* to the | ||
| 12 | matching ``ConfigModel`` subclass here, with the same name, type and default | ||
| 13 | (``_to_runtime`` passes every model field on to the dataclass, so a field on | ||
| 14 | only one side raises). Add it to the packaged JSON only when the shipped | ||
| 15 | default differs from the field default; ``test_config`` guards the parity. | ||
| 7 | """ | 16 | """ |
| 8 | 17 | ||
| 9 | import json | 18 | from __future__ import annotations |
| 10 | from dataclasses import fields | 19 | |
| 20 | import logging | ||
| 11 | from pathlib import Path | 21 | from pathlib import Path |
| 12 | from typing import Any | 22 | from typing import Any |
| 13 | 23 | ||
| 14 | from iolabs.common.config_loader import ( | 24 | from iolabs.common import config_loader |
| 15 | ConfigError, | ||
| 16 | deep_merge_dicts, | ||
| 17 | default_config_path, | ||
| 18 | validate_allowed_keys, | ||
| 19 | ) | ||
| 20 | 25 | ||
| 21 | from .detector import Config | 26 | from .detector import Config |
| 22 | from .features import FeatureWeights | 27 | from .features import FeatureWeights |
| 23 | from .gutter import GutterConfig | 28 | from .gutter import GutterConfig |
| 24 | from .image_edge import ImageEdgeConfig | 29 | from .image_edge import ImageEdgeConfig |
| 25 | 30 | ||
| 26 | ALLOWED_WEIGHT_KEYS = frozenset(field.name for field in fields(FeatureWeights)) | 31 | logger = logging.getLogger(__name__) |
| 27 | ALLOWED_IMAGE_KEYS = frozenset(field.name for field in fields(ImageEdgeConfig)) | ||
| 28 | ALLOWED_GUTTER_KEYS = frozenset(field.name for field in fields(GutterConfig)) | ||
| 29 | ALLOWED_CONFIG_KEYS = ( | ||
| 30 | frozenset(field.name for field in fields(Config)) | {"weights", "image", "gutter"} | ||
| 31 | ) | ||
| 32 | _IMAGE_TUPLE_KEYS = ( | ||
| 33 | "left_band", | ||
| 34 | "right_band", | ||
| 35 | "inner_left_band", | ||
| 36 | "inner_right_band", | ||
| 37 | "zrange_resume_zone_m", | ||
| 38 | ) | ||
| 39 | 32 | ||
| 40 | _PACKAGE = "iolabs_point_cloud_detection_asphaltedge" | 33 | _PACKAGE = "iolabs_point_cloud_detection_asphaltedge" |
| 41 | _DEFAULT_CONFIG_FILENAME = "asphalt_edge.default.json" | 34 | _DEFAULT_CONFIG_FILENAME = "asphalt_edge.default.json" |
| 42 | 35 | _CONTEXT = "asphalt edge config" | |
| 43 | 36 | ||
| 44 | class AsphaltEdgeConfigError(ConfigError): | 37 | |
| 45 | """Raised when the asphalt edge config contains unsupported keys.""" | 38 | class AsphaltEdgeConfigError(config_loader.ConfigError): |
| 39 | """Raised when the asphalt edge config contains unsupported keys or values.""" | ||
| 40 | |||
| 41 | |||
| 42 | class FeatureWeightsConfig(config_loader.ConfigModel): | ||
| 43 | """``weights`` block of ``asphalt_edge.default.json``.""" | ||
| 44 | |||
| 45 | intensity_step: float = 2.5 | ||
| 46 | density_drop: float = 2.0 | ||
| 47 | z_rise: float = 1.0 | ||
| 48 | roughness_rise: float = 0.8 | ||
| 49 | surface_notch: float = 1.5 | ||
| 50 | dog_step: float = 1.0 | ||
| 51 | |||
| 52 | |||
| 53 | class ImageEdgeConfigModel(config_loader.ConfigModel): | ||
| 54 | """``image`` block plus the dataclass knobs accepted as overrides.""" | ||
| 55 | |||
| 56 | station_step: float = 0.25 | ||
| 57 | offset_step: float = 0.05 | ||
| 58 | offset_min: float = -18.0 | ||
| 59 | offset_max: float = 18.0 | ||
| 60 | left_band: tuple[float, float] = (1.0, 17.0) | ||
| 61 | right_band: tuple[float, float] = (-17.0, -1.0) | ||
| 62 | inner_left_band: tuple[float, float] = (0.3, 6.0) | ||
| 63 | inner_right_band: tuple[float, float] = (-6.0, -0.3) | ||
| 64 | inner_min_valid_frac: float = 0.2 | ||
| 65 | inner_bound_margin_m: float = 1.25 | ||
| 66 | step_win_m: float = 1.2 | ||
| 67 | tex_win_m: float = 0.5 | ||
| 68 | smooth_offset_m: float = 0.1 | ||
| 69 | intensity_weight: float = 1.0 | ||
| 70 | texture_weight: float = 0.6 | ||
| 71 | density_weight: float = 0.0 | ||
| 72 | roughness_weight: float = 0.0 | ||
| 73 | z_step_weight: float = 0.0 | ||
| 74 | zrange_weight: float = 1.5 | ||
| 75 | zrange_win_m: float = 0.4 | ||
| 76 | zrange_cap_m: float = 0.15 | ||
| 77 | zrange_snap: bool = True | ||
| 78 | zrange_snap_window_m: float = 0.4 | ||
| 79 | zrange_snap_max_shift_m: float = 0.25 | ||
| 80 | zrange_snap_thresh_min_m: float = 0.012 | ||
| 81 | zrange_snap_mad_k: float = 4.0 | ||
| 82 | zrange_snap_rel_h: float = 0.10 | ||
| 83 | zrange_snap_regularize: bool = True | ||
| 84 | zrange_snap_reg_max_dev_m: float = 0.30 | ||
| 85 | zrange_intensity_snap: bool = True | ||
| 86 | zrange_isnap_grad_k: float = 3.0 | ||
| 87 | zrange_isnap_min_step: float = 8.0 | ||
| 88 | zrange_isnap_paint_guard: bool = True | ||
| 89 | zrange_onset_bonus: float = 1.2 | ||
| 90 | zrange_onset_smooth: bool = True | ||
| 91 | zrange_guardrail_z_m: float = 0.30 | ||
| 92 | zrange_guardrail_lookback_m: float = 2.0 | ||
| 93 | zrange_guardrail_veto: bool = False | ||
| 94 | zrange_guardrail_veto_conf: float = 0.7 | ||
| 95 | zrange_guardrail_veto_margin_m: float = 0.5 | ||
| 96 | zrange_guardrail_veto_types: tuple[str, ...] = ("w_beam",) | ||
| 97 | zrange_guardrail_veto_walls: bool = False | ||
| 98 | zrange_resume_weight: float = 3.0 | ||
| 99 | zrange_resume_zone_m: tuple[float, float] = (0.5, 2.5) | ||
| 100 | zrange_flat_thresh_m: float = 0.015 | ||
| 101 | zrange_resume_tex_k: float = 1.5 | ||
| 102 | zrange_shoulder_rule: bool = True | ||
| 103 | zrange_shoulder_lookback_m: float = 2.0 | ||
| 104 | zrange_shoulder_min_gap_m: float = 0.3 | ||
| 105 | zrange_shoulder_corridor_anchor: bool = True | ||
| 106 | zrange_shoulder_sustain_m: float = 1.5 | ||
| 107 | zrange_shoulder_max_search_m: float = 8.0 | ||
| 108 | zrange_shoulder_grad_k: float = 2.0 | ||
| 109 | zrange_shoulder_min_step: float = 6.0 | ||
| 110 | zrange_shoulder_min_width_m: float = 3.0 | ||
| 111 | zrange_shoulder_support_m: float = 1.5 | ||
| 112 | zrange_shoulder_support_tol_m: float = 0.6 | ||
| 113 | zrange_shoulder_support_frac: float = 0.5 | ||
| 114 | zrange_shoulder_consolidate: bool = True | ||
| 115 | zrange_shoulder_revert_isolated: bool = True | ||
| 116 | zrange_shoulder_run_smoothness_guard: bool = False | ||
| 117 | zrange_shoulder_run_max_step_m: float = 0.046 | ||
| 118 | zrange_paint_veto: bool = True | ||
| 119 | zrange_paint_quantile: float = 0.92 | ||
| 120 | zrange_paint_max_width_m: float = 0.5 | ||
| 121 | zrange_paint_corridor_margin_m: float = 1.5 | ||
| 122 | robust: bool = True | ||
| 123 | edge_smooth_window_m: float = 2.0 | ||
| 124 | slope_cheap_m: float = 0.15 | ||
| 125 | trans_lin: float = 9.0 | ||
| 126 | trans_quad: float = 220.0 | ||
| 127 | max_jump_m: float = 0.9 | ||
| 128 | bridge_stiffness: float = 3.0 | ||
| 129 | bridge_anchor_min_conf: float = 0.75 | ||
| 130 | invalid_penalty: float = 2.5 | ||
| 131 | min_valid_frac: float = 0.35 | ||
| 132 | conf_gain: float = 0.7 | ||
| 133 | outer_bound_margin_m: float = 1.5 | ||
| 134 | outer_median_bound: bool = False | ||
| 135 | outer_median_bound_margin_m: float = 0.5 | ||
| 136 | outer_median_bound_hard: bool = False | ||
| 137 | vehicle_mask: bool = True | ||
| 138 | vehicle_z_thresh_m: float = 0.02 | ||
| 139 | vehicle_corridor_margin_m: float = 0.5 | ||
| 140 | vehicle_mask_z_only: bool = False | ||
| 141 | vehicle_mask_neutral: bool = True | ||
| 142 | vehicle_mask_seed_guard: bool = False | ||
| 143 | vehicle_mask_baseline_fill: bool = False | ||
| 144 | edge_support_gate: bool = False | ||
| 145 | use_crf: bool = False | ||
| 146 | crf_second_order: bool = True | ||
| 147 | crf_lambda_parallel: float = 4.0 | ||
| 148 | crf_lambda_curvature: float = 8.0 | ||
| 149 | crf_lambda_prior: float = 1.5 | ||
| 150 | crf_huber_delta_m: float = 0.10 | ||
| 151 | crf_delta_cap_m: float = 0.6 | ||
| 152 | crf_conf_ref: float = 1.0 | ||
| 153 | crf_not_observable_support: float = 0.15 | ||
| 154 | crf_support_intensity_ref_dn: float = 40.0 | ||
| 155 | crf_support_zrange_ref_m: float = 0.05 | ||
| 156 | |||
| 157 | |||
| 158 | class GutterConfigModel(config_loader.ConfigModel): | ||
| 159 | """``gutter`` block of ``asphalt_edge.default.json``.""" | ||
| 160 | |||
| 161 | enabled: bool = True | ||
| 162 | station_step: float = 1.0 | ||
| 163 | half_window_m: float = 0.75 | ||
| 164 | step_thr: float = 4000.0 | ||
| 165 | rough_mult: float = 2.5 | ||
| 166 | max_gutter_m: float = 1.5 | ||
| 167 | min_conf: float = 0.35 | ||
| 168 | |||
| 169 | |||
| 170 | class AsphaltEdgeConfig(config_loader.ConfigModel): | ||
| 171 | """Root schema mirroring ``asphalt_edge.default.json``.""" | ||
| 172 | |||
| 173 | station_step: float = 0.5 | ||
| 174 | half_thickness: float = 0.5 | ||
| 175 | bin_width: float = 0.1 | ||
| 176 | off_range: tuple[float, float] = (-15.0, 15.0) | ||
| 177 | search_in: float = 4.5 | ||
| 178 | search_out: float = 0.3 | ||
| 179 | band_m: float = 1.0 | ||
| 180 | weights: FeatureWeightsConfig = FeatureWeightsConfig() | ||
| 181 | image: ImageEdgeConfigModel = ImageEdgeConfigModel() | ||
| 182 | gutter: GutterConfigModel = GutterConfigModel() | ||
| 183 | min_confidence: float = 0.25 | ||
| 184 | median_window_m: float = 15.0 | ||
| 185 | mad_k: float = 3.5 | ||
| 186 | max_interp_gap_m: float = 5.0 | ||
| 187 | max_interp_offset_jump_m: float = 0.5 | ||
| 188 | max_offset_rate: float = 0.6 | ||
| 189 | min_run_m: float = 3.0 | ||
| 190 | end_stub_m: float = 5.0 | ||
| 191 | savgol_window_m: float = 11.0 | ||
| 192 | savgol_order: int = 2 | ||
| 193 | max_points: int = 7_000_000 | ||
| 194 | hash_round_units_per_m: float = 1000.0 | ||
| 195 | min_carriageway_m: float = 2.0 | ||
| 196 | |||
| 197 | |||
| 198 | def _to_runtime(model: AsphaltEdgeConfig) -> Config: | ||
| 199 | """Copy a validated model onto the mutable runtime dataclasses.""" | ||
| 200 | data = model.model_dump() | ||
| 201 | return Config( | ||
| 202 | weights=FeatureWeights(**data.pop("weights")), | ||
| 203 | image=ImageEdgeConfig(**data.pop("image")), | ||
| 204 | gutter=GutterConfig(**data.pop("gutter")), | ||
| 205 | **data, | ||
| 206 | ) | ||
| 46 | 207 | ||
| 47 | 208 | ||
| 48 | def config_from_dict(raw_config: dict[str, Any]) -> Config: | 209 | def config_from_dict(raw_config: dict[str, Any]) -> Config: |
| 49 | """Build a typed :class:`Config` from a (possibly partial) mapping.""" | 210 | """Build a typed :class:`Config` from a (possibly partial) mapping.""" |
| 50 | config = dict(raw_config) | 211 | model = config_loader.validate_config( |
| 51 | validate_allowed_keys( | 212 | AsphaltEdgeConfig, |
| 52 | config, ALLOWED_CONFIG_KEYS, context="asphalt edge config", error_cls=AsphaltEdgeConfigError | 213 | raw_config, |
| 214 | context=_CONTEXT, | ||
| 215 | error_cls=AsphaltEdgeConfigError, | ||
| 53 | ) | 216 | ) |
| 54 | 217 | return _to_runtime(model) | |
| 55 | weights_raw = config.pop("weights", None) | ||
| 56 | if weights_raw is not None: | ||
| 57 | if not isinstance(weights_raw, dict): | ||
| 58 | raise AsphaltEdgeConfigError("asphalt edge config field 'weights' must be a mapping") | ||
| 59 | validate_allowed_keys( | ||
| 60 | weights_raw, | ||
| 61 | ALLOWED_WEIGHT_KEYS, | ||
| 62 | context="asphalt edge weights", | ||
| 63 | error_cls=AsphaltEdgeConfigError, | ||
| 64 | ) | ||
| 65 | weights = FeatureWeights(**weights_raw) | ||
| 66 | else: | ||
| 67 | weights = FeatureWeights() | ||
| 68 | |||
| 69 | image_raw = config.pop("image", None) | ||
| 70 | if image_raw is not None: | ||
| 71 | if not isinstance(image_raw, dict): | ||
| 72 | raise AsphaltEdgeConfigError("asphalt edge config field 'image' must be a mapping") | ||
| 73 | image_raw = dict(image_raw) | ||
| 74 | validate_allowed_keys( | ||
| 75 | image_raw, | ||
| 76 | ALLOWED_IMAGE_KEYS, | ||
| 77 | context="asphalt edge image", | ||
| 78 | error_cls=AsphaltEdgeConfigError, | ||
| 79 | ) | ||
| 80 | for key in _IMAGE_TUPLE_KEYS: | ||
| 81 | if key in image_raw and image_raw[key] is not None: | ||
| 82 | lo, hi = image_raw[key] | ||
| 83 | image_raw[key] = (float(lo), float(hi)) | ||
| 84 | image = ImageEdgeConfig(**image_raw) | ||
| 85 | else: | ||
| 86 | image = ImageEdgeConfig() | ||
| 87 | |||
| 88 | gutter_raw = config.pop("gutter", None) | ||
| 89 | if gutter_raw is not None: | ||
| 90 | if not isinstance(gutter_raw, dict): | ||
| 91 | raise AsphaltEdgeConfigError("asphalt edge config field 'gutter' must be a mapping") | ||
| 92 | validate_allowed_keys( | ||
| 93 | gutter_raw, | ||
| 94 | ALLOWED_GUTTER_KEYS, | ||
| 95 | context="asphalt edge gutter", | ||
| 96 | error_cls=AsphaltEdgeConfigError, | ||
| 97 | ) | ||
| 98 | gutter = GutterConfig(**gutter_raw) | ||
| 99 | else: | ||
| 100 | gutter = GutterConfig() | ||
| 101 | |||
| 102 | if "off_range" in config and config["off_range"] is not None: | ||
| 103 | lo, hi = config["off_range"] | ||
| 104 | config["off_range"] = (float(lo), float(hi)) | ||
| 105 | |||
| 106 | return Config(weights=weights, image=image, gutter=gutter, **config) | ||
| 107 | 218 | ||
| 108 | 219 | ||
| 109 | def load_asphalt_edge_config( | 220 | def load_asphalt_edge_config( |
| 110 | config_path: str | Path | None = None, | 221 | config_path: str | Path | None = None, |
| 111 | *, | 222 | *, |
| 112 | overrides: dict[str, Any] | None = None, | 223 | overrides: dict[str, Any] | None = None, |
| 113 | ) -> Config: | 224 | ) -> Config: |
| 114 | """Load the packaged default config, apply optional overrides, return a Config.""" | 225 | """Load the packaged default config, apply optional overrides, return a Config.""" |
| 115 | resolved = ( | 226 | model = config_loader.load_config( |
| 116 | Path(config_path) | 227 | AsphaltEdgeConfig, |
| 117 | if config_path is not None | 228 | package=_PACKAGE, |
| 118 | else default_config_path(_PACKAGE, _DEFAULT_CONFIG_FILENAME) | 229 | filename=_DEFAULT_CONFIG_FILENAME, |
| 230 | overrides=overrides, | ||
| 231 | config_path=config_path, | ||
| 232 | context=_CONTEXT, | ||
| 233 | error_cls=AsphaltEdgeConfigError, | ||
| 119 | ) | 234 | ) |
| 120 | with resolved.open("r", encoding="utf-8") as handle: | ||
| 121 | raw_config: dict[str, Any] = json.load(handle) | ||
| 122 | if overrides: | 235 | if overrides: |
| 123 | raw_config = deep_merge_dicts(raw_config, dict(overrides)) | 236 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) |
| 124 | return config_from_dict(raw_config) | 237 | return _to_runtime(model) |
| 34 | @dataclass | 34 | @dataclass |
| 35 | class Config: | 35 | class Config: |
| 36 | """Tunable parameters of one asphalt-edge detection run. | 36 | """Tunable parameters of one asphalt-edge detection run. |
| 37 | 37 | ||
| 38 | Defaults mirror ``asphalt_edge.default.json``; :func:`.config_from_dict` | 38 | Defaults of a loaded config come from ``asphalt_edge.default.json`` via |
| 39 | overlays a user config onto them. The nested rectified-image, gutter and | 39 | :func:`.config_from_dict`. Nested rectified-image, gutter and weight |
| 40 | weight blocks live in their own dataclasses reachable from here. | 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`` | ||
| 42 | with the same name, type and default; the packaged JSON only needs it when | ||
| 43 | the shipped default differs. | ||
| 41 | """ | 44 | """ |
| 42 | 45 | ||
| 43 | station_step: float = 0.5 | 46 | station_step: float = 0.5 |
| 44 | half_thickness: float = 0.5 | 47 | half_thickness: float = 0.5 |
| 1 | """Packaged-JSON asphalt-edge config loading and validation.""" | ||
| 2 | |||
| 3 | from __future__ import annotations | ||
| 4 | |||
| 5 | import dataclasses | ||
| 6 | import typing | ||
| 7 | from pathlib import Path | ||
| 8 | |||
| 9 | import pytest | ||
| 10 | from iolabs.common import config_loader | ||
| 11 | |||
| 12 | from iolabs_point_cloud_detection_asphaltedge.config import ( | ||
| 13 | AsphaltEdgeConfig, | ||
| 14 | AsphaltEdgeConfigError, | ||
| 15 | FeatureWeightsConfig, | ||
| 16 | GutterConfigModel, | ||
| 17 | ImageEdgeConfigModel, | ||
| 18 | config_from_dict, | ||
| 19 | load_asphalt_edge_config, | ||
| 20 | ) | ||
| 21 | from iolabs_point_cloud_detection_asphaltedge.detector import Config | ||
| 22 | from iolabs_point_cloud_detection_asphaltedge.features import FeatureWeights | ||
| 23 | from iolabs_point_cloud_detection_asphaltedge.gutter import GutterConfig | ||
| 24 | from iolabs_point_cloud_detection_asphaltedge.image_edge import ImageEdgeConfig | ||
| 25 | |||
| 26 | |||
| 27 | def test_error_class_is_config_error_and_value_error() -> None: | ||
| 28 | assert issubclass(AsphaltEdgeConfigError, config_loader.ConfigError) | ||
| 29 | assert issubclass(AsphaltEdgeConfigError, ValueError) | ||
| 30 | |||
| 31 | |||
| 32 | def test_load_packaged_defaults_with_zero_overrides() -> None: | ||
| 33 | cfg = load_asphalt_edge_config() | ||
| 34 | assert isinstance(cfg, Config) | ||
| 35 | assert cfg.station_step == 0.5 | ||
| 36 | assert cfg.off_range == (-20.0, 20.0) | ||
| 37 | assert cfg.mad_k == 3.0 | ||
| 38 | assert cfg.weights.intensity_step == 2.5 | ||
| 39 | assert cfg.image.left_band == (1.0, 17.0) | ||
| 40 | assert cfg.image.roughness_weight == 0.4 | ||
| 41 | assert cfg.image.robust is True | ||
| 42 | assert cfg.gutter.enabled is True | ||
| 43 | 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 | ||
| 47 | |||
| 48 | |||
| 49 | def test_overrides_deep_merge_nested_sections() -> None: | ||
| 50 | cfg = load_asphalt_edge_config( | ||
| 51 | overrides={ | ||
| 52 | "min_confidence": 0.4, | ||
| 53 | "weights": {"z_rise": 9.0}, | ||
| 54 | "image": {"robust": False, "zrange_weight": 2.25}, | ||
| 55 | "gutter": {"enabled": False}, | ||
| 56 | } | ||
| 57 | ) | ||
| 58 | assert cfg.min_confidence == 0.4 | ||
| 59 | assert cfg.weights.z_rise == 9.0 | ||
| 60 | assert cfg.weights.intensity_step == 2.5 | ||
| 61 | assert cfg.image.robust is False | ||
| 62 | assert cfg.image.zrange_weight == 2.25 | ||
| 63 | assert cfg.image.texture_weight == 0.6 | ||
| 64 | assert cfg.gutter.enabled is False | ||
| 65 | assert cfg.gutter.min_conf == 0.35 | ||
| 66 | |||
| 67 | |||
| 68 | def test_unknown_top_level_key_rejected() -> None: | ||
| 69 | with pytest.raises(AsphaltEdgeConfigError, match="not_a_key") as excinfo: | ||
| 70 | load_asphalt_edge_config(overrides={"not_a_key": 1}) | ||
| 71 | message = str(excinfo.value) | ||
| 72 | assert "Unknown" in message | ||
| 73 | assert "station_step" in message | ||
| 74 | |||
| 75 | |||
| 76 | def test_unknown_nested_key_rejected() -> None: | ||
| 77 | with pytest.raises(AsphaltEdgeConfigError, match="typo_band") as excinfo: | ||
| 78 | load_asphalt_edge_config(overrides={"image": {"typo_band": [0.0, 1.0]}}) | ||
| 79 | message = str(excinfo.value) | ||
| 80 | assert "Unknown" in message | ||
| 81 | assert "left_band" in message | ||
| 82 | |||
| 83 | |||
| 84 | def test_config_from_dict_unknown_key_rejected() -> None: | ||
| 85 | with pytest.raises(AsphaltEdgeConfigError, match="bogus"): | ||
| 86 | config_from_dict({"station_step": 0.5, "bogus": 1}) | ||
| 87 | |||
| 88 | |||
| 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: | ||
| 98 | with pytest.raises(AsphaltEdgeConfigError, match="Invalid boolean"): | ||
| 99 | load_asphalt_edge_config(overrides={"gutter": {"enabled": "flase"}}) | ||
| 100 | |||
| 101 | |||
| 102 | def test_invalid_float_token_rejected() -> None: | ||
| 103 | with pytest.raises(AsphaltEdgeConfigError, match="Invalid float"): | ||
| 104 | load_asphalt_edge_config(overrides={"station_step": "abc"}) | ||
| 105 | |||
| 106 | |||
| 107 | def test_off_range_list_becomes_float_tuple() -> None: | ||
| 108 | cfg = config_from_dict({"off_range": ["-3", "4"]}) | ||
| 109 | assert cfg.off_range == (-3.0, 4.0) | ||
| 110 | |||
| 111 | |||
| 112 | def test_image_band_list_becomes_float_tuple() -> None: | ||
| 113 | cfg = config_from_dict({"image": {"left_band": [2, 8]}}) | ||
| 114 | assert cfg.image.left_band == (2.0, 8.0) | ||
| 115 | |||
| 116 | |||
| 117 | def test_config_from_dict_builds_runtime_dataclasses() -> None: | ||
| 118 | cfg = config_from_dict({"min_confidence": 0.1}) | ||
| 119 | assert isinstance(cfg, Config) | ||
| 120 | assert isinstance(cfg.weights, FeatureWeights) | ||
| 121 | assert isinstance(cfg.image, ImageEdgeConfig) | ||
| 122 | assert isinstance(cfg.gutter, GutterConfig) | ||
| 123 | assert cfg.min_confidence == 0.1 | ||
| 124 | # Partial mappings keep dataclass defaults for omitted keys. | ||
| 125 | assert cfg.off_range == (-15.0, 15.0) | ||
| 126 | assert cfg.image.roughness_weight == 0.0 | ||
| 127 | |||
| 128 | |||
| 129 | def test_load_from_config_path(tmp_path: Path) -> None: | ||
| 130 | path = tmp_path / "custom.json" | ||
| 131 | path.write_text('{"station_step": 1.25, "gutter": {"enabled": false}}\n', encoding="utf-8") | ||
| 132 | cfg = load_asphalt_edge_config(config_path=path) | ||
| 133 | assert cfg.station_step == 1.25 | ||
| 134 | assert cfg.gutter.enabled is False | ||
| 135 | assert cfg.band_m == 1.0 | ||
| 136 | |||
| 137 | |||
| 138 | def test_except_value_error_still_catches_config_errors() -> None: | ||
| 139 | with pytest.raises(ValueError): | ||
| 140 | load_asphalt_edge_config(overrides={"nope": True}) | ||
| 141 | |||
| 142 | |||
| 143 | @pytest.mark.parametrize( | ||
| 144 | ("dataclass_cls", "model_cls"), | ||
| 145 | [ | ||
| 146 | (Config, AsphaltEdgeConfig), | ||
| 147 | (FeatureWeights, FeatureWeightsConfig), | ||
| 148 | (ImageEdgeConfig, ImageEdgeConfigModel), | ||
| 149 | (GutterConfig, GutterConfigModel), | ||
| 150 | ], | ||
| 151 | ) | ||
| 152 | 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.""" | ||
| 154 | hints = typing.get_type_hints(dataclass_cls) | ||
| 155 | nested = {"weights", "image", "gutter"} | ||
| 156 | assert {field.name for field in dataclasses.fields(dataclass_cls)} == set( | ||
| 157 | model_cls.model_fields | ||
| 158 | ) | ||
| 159 | defaults = dataclass_cls() | ||
| 160 | model = model_cls() | ||
| 161 | for name, field in model_cls.model_fields.items(): | ||
| 162 | if name in nested: | ||
| 163 | continue | ||
| 164 | assert hints[name] == field.annotation, name | ||
| 165 | assert getattr(defaults, name) == getattr(model, name), name | ||
| 0 |
ConfigModel: nested section models mirror the packaged*.default.jsonkey for key; whitelist sets and hand-rolled coercion deleted; loader built onconfig_loader.load_config. Public entry-point names and return types unchanged so lanefinder wrappers keep working.pydantic>=2.7dependency.