Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:08:33+02:00
Commit #70 ยท 31 snippets
README.md | 26 +- .../_config.py | 5 +- .../_config_model.py | 5 +- .../config.py | 13 +- .../verticalsigns.default.json | 330 +++++++++++++++------ tests/conftest.py | 3 + tests/test_chroma_vegetation.py | 2 +- tests/test_config_split.py | 18 +- 8 files changed, 286 insertions(+), 116 deletions(-)
| 107 | def with_overrides(self, **overrides: Any) -> "DetectorConfig": | 107 | def with_overrides(self, **overrides: Any) -> "DetectorConfig": |
| 108 | """Return a copy of this config with *overrides* applied. | 108 | """Return a copy of this config with *overrides* applied. |
| 109 | 109 | ||
| 110 | ``model_copy(update=...)`` skips validation, so a misspelled name would | 110 | ``model_copy(update=...)`` skips validation, so a misspelled name would |
| 111 | be attached as a new attribute and the intended threshold would keep | 111 | be attached as a new attribute and a wrongly typed value would be |
| 112 | its default. The names are therefore checked here, reproducing the | 112 | stored uncoerced. The names are checked here and the values are run |
| 113 | ``TypeError`` that ``dataclasses.replace`` used to raise. | 113 | through the model, so this validates where ``dataclasses.replace`` |
| 114 | merely type-checked the call. | ||
| 114 | 115 | ||
| 115 | Args: | 116 | Args: |
| 116 | overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``. | 117 | overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``. |
| 117 | 118 | ||
| 118 | Returns: | 119 | Returns: |
| 119 | A new frozen config carrying *overrides*. | 120 | A new frozen config carrying *overrides*. |
| 120 | 121 | ||
| 121 | Raises: | 122 | Raises: |
| 122 | ValueError: An override names a field this config does not declare. | 123 | ValueError: An override names a field this config does not declare, |
| 124 | or carries a value the field rejects (a | ||
| 125 | ``pydantic.ValidationError``, itself a ``ValueError``). | ||
| 123 | """ | 126 | """ |
| 124 | unknown = sorted(set(overrides) - set(type(self).model_fields)) | 127 | unknown = sorted(set(overrides) - set(type(self).model_fields)) |
| 125 | if unknown: | 128 | if unknown: |
| 126 | raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}") | 129 | raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}") |
| 127 | return self.model_copy(update=overrides) | 130 | return type(self).model_validate({**self.model_dump(), **overrides}) |
| 128 | 131 | ||
| 129 | @classmethod | 132 | @classmethod |
| 130 | def load(cls, config_path: str | Path | None = None) -> "DetectorConfig": | 133 | def load(cls, config_path: str | Path | None = None) -> "DetectorConfig": |
| 131 | """Load config from the packaged defaults merged with an optional user JSON.""" | 134 | """Load config from the packaged defaults merged with an optional user JSON.""" |
| 61 | overrides=overrides, | 62 | overrides=overrides, |
| 62 | context=_CONTEXT, | 63 | context=_CONTEXT, |
| 63 | error_cls=VerticalSignsConfigError, | 64 | error_cls=VerticalSignsConfigError, |
| 64 | ) | 65 | ) |
| 65 | return config.model_dump() | 66 | return config.model_dump(mode="json") |
| 66 | 67 | ||
| 67 | 68 | ||
| 68 | def _read_user_config(config_path: str | Path) -> dict[str, Any]: | 69 | def _read_user_config(config_path: str | Path) -> dict[str, Any]: |
| 69 | """Read a user config JSON, wrapping decode errors in the package error.""" | 70 | """Read a user config JSON, wrapping decode errors in the package error.""" |
| 2 | 2 | ||
| 3 | ``VerticalSignsConfig`` mirrors ``verticalsigns.default.json`` section for | 3 | ``VerticalSignsConfig`` mirrors ``verticalsigns.default.json`` section for |
| 4 | section and key for key: it is the single source of truth for which config | 4 | section and key for key: it is the single source of truth for which config |
| 5 | keys exist and what type each one has. Adding a key means adding a field to | 5 | keys exist and what type each one has. Adding a key means adding a field to |
| 6 | the matching section model and a default to the packaged JSON. | 6 | the matching section model and the same default to the packaged JSON; the two |
| 7 | sides must stay in lockstep, and ``tests/test_config_split.py`` fails if they | ||
| 8 | drift. A key the detector modules read also needs its flat ``DetectorConfig`` | ||
| 9 | field and the ``*_kwargs`` line that maps it (see ``config.py``). | ||
| 7 | """ | 10 | """ |
| 8 | 11 | ||
| 9 | from iolabs.common import config_loader | 12 | from iolabs.common import config_loader |
| 10 | 13 |
| 107 | def with_overrides(self, **overrides: Any) -> "DetectorConfig": | 107 | def with_overrides(self, **overrides: Any) -> "DetectorConfig": |
| 108 | """Return a copy of this config with *overrides* applied. | 108 | """Return a copy of this config with *overrides* applied. |
| 109 | 109 | ||
| 110 | ``model_copy(update=...)`` skips validation, so a misspelled name would | 110 | ``model_copy(update=...)`` skips validation, so a misspelled name would |
| 111 | be attached as a new attribute and the intended threshold would keep | 111 | be attached as a new attribute and a wrongly typed value would be |
| 112 | its default. The names are therefore checked here, reproducing the | 112 | stored uncoerced. The names are checked here and the values are run |
| 113 | ``TypeError`` that ``dataclasses.replace`` used to raise. | 113 | through the model, so this validates where ``dataclasses.replace`` |
| 114 | merely type-checked the call. | ||
| 114 | 115 | ||
| 115 | Args: | 116 | Args: |
| 116 | overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``. | 117 | overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``. |
| 117 | 118 | ||
| 118 | Returns: | 119 | Returns: |
| 119 | A new frozen config carrying *overrides*. | 120 | A new frozen config carrying *overrides*. |
| 120 | 121 | ||
| 121 | Raises: | 122 | Raises: |
| 122 | ValueError: An override names a field this config does not declare. | 123 | ValueError: An override names a field this config does not declare, |
| 124 | or carries a value the field rejects (a | ||
| 125 | ``pydantic.ValidationError``, itself a ``ValueError``). | ||
| 123 | """ | 126 | """ |
| 124 | unknown = sorted(set(overrides) - set(type(self).model_fields)) | 127 | unknown = sorted(set(overrides) - set(type(self).model_fields)) |
| 125 | if unknown: | 128 | if unknown: |
| 126 | raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}") | 129 | raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}") |
| 127 | return self.model_copy(update=overrides) | 130 | return type(self).model_validate({**self.model_dump(), **overrides}) |
| 128 | 131 | ||
| 129 | @classmethod | 132 | @classmethod |
| 130 | def load(cls, config_path: str | Path | None = None) -> "DetectorConfig": | 133 | def load(cls, config_path: str | Path | None = None) -> "DetectorConfig": |
| 131 | """Load config from the packaged defaults merged with an optional user JSON.""" | 134 | """Load config from the packaged defaults merged with an optional user JSON.""" |
| 6 | "occupancy": { | 6 | "occupancy": { |
| 7 | "cell_m": 0.15 | 7 | "cell_m": 0.15 |
| 8 | }, | 8 | }, |
| 9 | "candidates": { | 9 | "candidates": { |
| 10 | "min_height_m": 0.30, | 10 | "min_height_m": 0.3, |
| 11 | "max_height_m": 10.0, | 11 | "max_height_m": 10.0, |
| 12 | "seed_min_vertical_span_m": 0.80, | 12 | "seed_min_vertical_span_m": 0.8, |
| 13 | "seed_min_h_max_m": 0.90, | 13 | "seed_min_h_max_m": 0.9, |
| 14 | "seed_bright_min_vertical_span_m": 0.45, | 14 | "seed_bright_min_vertical_span_m": 0.45, |
| 15 | "seed_bright_min_h_max_m": 0.60, | 15 | "seed_bright_min_h_max_m": 0.6, |
| 16 | "seed_bright_min_points": 3 | 16 | "seed_bright_min_points": 3 |
| 17 | }, | 17 | }, |
| 18 | "clustering": { | 18 | "clustering": { |
| 19 | "eps_m": 0.45, | 19 | "eps_m": 0.45, |
| 20 | "min_samples": 1, | 20 | "min_samples": 1, |
| 21 | "hull_margin_m": 0.20 | 21 | "hull_margin_m": 0.2 |
| 22 | }, | 22 | }, |
| 23 | "classification": { | 23 | "classification": { |
| 24 | "continuity_bin_m": 0.25, | 24 | "continuity_bin_m": 0.25, |
| 25 | "reject_len_major_m": 6.0, | 25 | "reject_len_major_m": 6.0, |
| 26 | "reject_h_max_with_large_footprint_m": 4.5, | 26 | "reject_h_max_with_large_footprint_m": 4.5, |
| 27 | "min_continuity": 0.50, | 27 | "min_continuity": 0.5, |
| 28 | "min_accept_h_max_m": 0.90, | 28 | "min_accept_h_max_m": 0.9, |
| 29 | "core_rms_bin_m": 0.25, | 29 | "core_rms_bin_m": 0.25, |
| 30 | "core_rms_h_min_m": 0.30, | 30 | "core_rms_h_min_m": 0.3, |
| 31 | "core_rms_h_cap_m": 3.0, | 31 | "core_rms_h_cap_m": 3.0, |
| 32 | "hi_intensity_all_points_percentile": 98.0, | 32 | "hi_intensity_all_points_percentile": 98.0, |
| 33 | "min_volumetric_density": 8000.0, | 33 | "min_volumetric_density": 8000.0, |
| 34 | "pole_floating_min_h_min_m": 3.5, | 34 | "pole_floating_min_h_min_m": 3.5, |
| 36 | "dedup_radius_m": 0.8, | 36 | "dedup_radius_m": 0.8, |
| 37 | "emit_trees": false, | 37 | "emit_trees": false, |
| 38 | "ml_verifier_enabled": true, | 38 | "ml_verifier_enabled": true, |
| 39 | "ml_veto_threshold": -1.0, | 39 | "ml_veto_threshold": -1.0, |
| 40 | "ml_model_path": "" | 40 | "ml_model_path": "", |
| 41 | "lattice_admission": true, | ||
| 42 | "lattice_max_seed_spacing_m": 60.0, | ||
| 43 | "lattice_max_skip": 6, | ||
| 44 | "lattice_max_spacing_resid": 0.15, | ||
| 45 | "lattice_min_anchors": 4, | ||
| 46 | "lattice_min_seed_spacing_m": 15.0, | ||
| 47 | "lattice_pool_h_max_max_m": 1.4, | ||
| 48 | "lattice_pool_h_max_min_m": 0.8, | ||
| 49 | "lattice_pool_max_len_major_m": 1.2, | ||
| 50 | "lattice_pool_max_plate_thickness_m": 0.05, | ||
| 51 | "lattice_pool_min_hi_seed_fraction": 0.15, | ||
| 52 | "lattice_pool_min_points": 20, | ||
| 53 | "lattice_pool_min_verticality": 0.85, | ||
| 54 | "lattice_snap_m": 3.0, | ||
| 55 | "ml_veto_requires_corridor": true, | ||
| 56 | "robust_extent_hi_percentile": 99.0, | ||
| 57 | "robust_extent_lo_percentile": 1.0, | ||
| 58 | "robust_extent_stats": true, | ||
| 59 | "robust_h_max_percentile": 98.0, | ||
| 60 | "seed_bright_percentile": 95.0, | ||
| 61 | "single_record_transient_veto": true, | ||
| 62 | "transient_max_h_max_m": 2.5, | ||
| 63 | "transient_max_verticality": 0.3, | ||
| 64 | "transient_min_len_major_m": 2.0, | ||
| 65 | "veg_texture_min_hi_seed_fraction": 0.668, | ||
| 66 | "veg_texture_min_plate_thickness_m": 0.05, | ||
| 67 | "veg_texture_veto": true, | ||
| 68 | "verticality_sentinel_fix": true | ||
| 41 | }, | 69 | }, |
| 42 | "corridor": { | 70 | "corridor": { |
| 43 | "max_dist_to_road_m": 10.0, | 71 | "max_dist_to_road_m": 10.0, |
| 44 | "on_carriageway_dist_m": 0.25, | 72 | "on_carriageway_dist_m": 0.25, |
| 78 | "sign_post": { | 106 | "sign_post": { |
| 79 | "max_len_minor_m": 0.8, | 107 | "max_len_minor_m": 0.8, |
| 80 | "h_min_m": 1.5, | 108 | "h_min_m": 1.5, |
| 81 | "h_max_m": 6.0, | 109 | "h_max_m": 6.0, |
| 82 | "min_continuity": 0.60, | 110 | "min_continuity": 0.6, |
| 83 | "plate_hi_intensity_fraction": 0.40, | 111 | "plate_hi_intensity_fraction": 0.4, |
| 84 | "plate_hi_intensity_fraction_weak": 0.30, | 112 | "plate_hi_intensity_fraction_weak": 0.3, |
| 85 | "plate_upper_surplus_ratio": 2.0, | 113 | "plate_upper_surplus_ratio": 2.0, |
| 86 | "min_upper_half_surplus": 0.30, | 114 | "min_upper_half_surplus": 0.3, |
| 87 | "plate_min_core_rms_m": 0.10, | 115 | "plate_min_core_rms_m": 0.1, |
| 88 | "max_plate_thickness_m": 0.15, | 116 | "max_plate_thickness_m": 0.15, |
| 89 | "bare_post_min_h_max_m": 4.5, | 117 | "bare_post_min_h_max_m": 4.5, |
| 90 | "bare_post_max_core_rms_m": 0.065, | 118 | "bare_post_max_core_rms_m": 0.065, |
| 91 | "bare_post_min_verticality": 0.90, | 119 | "bare_post_min_verticality": 0.9, |
| 92 | "bare_post_min_points": 450 | 120 | "bare_post_min_points": 450 |
| 93 | }, | 121 | }, |
| 94 | "panel": { | 122 | "panel": { |
| 95 | "min_hi": 0.40, | 123 | "min_hi": 0.4, |
| 96 | "max_thickness_m": 0.20, | 124 | "max_thickness_m": 0.2, |
| 97 | "h_min_m": 0.9, | 125 | "h_min_m": 0.9, |
| 98 | "len_major_min_m": 1.5, | 126 | "len_major_min_m": 1.5, |
| 99 | "len_major_max_m": 5.0 | 127 | "len_major_max_m": 5.0 |
| 100 | }, | 128 | }, |
| 160 | "far_max_distance_m": 30.0, | 188 | "far_max_distance_m": 30.0, |
| 161 | "far_include_lane_lines": true, | 189 | "far_include_lane_lines": true, |
| 162 | "far_tier2_enabled": true, | 190 | "far_tier2_enabled": true, |
| 163 | "far_tier2_distance_m": 15.0, | 191 | "far_tier2_distance_m": 15.0, |
| 164 | "far_tier2_max_saturation": 150 | 192 | "far_tier2_max_saturation": 150, |
| 193 | "max_carriageway_width_m": 20.0, | ||
| 194 | "min_carriageway_width_m": 3.0, | ||
| 195 | "paint_fallback_enabled": false, | ||
| 196 | "xml_enabled": true, | ||
| 197 | "xml_max_distance_m": 60.0, | ||
| 198 | "xml_min_agreement": 0.6, | ||
| 199 | "xml_station_step_m": 10.0, | ||
| 200 | "xml_station_tolerance_m": 2.0, | ||
| 201 | "xml_vote_slack_m": 3.0 | ||
| 165 | }, | 202 | }, |
| 166 | "field_stake": { | 203 | "field_stake": { |
| 167 | "row_emit": true, | 204 | "row_emit": true, |
| 168 | "min_members": 4, | 205 | "min_members": 4, |
| 212 | "h_min_m": 1.5, | 249 | "h_min_m": 1.5, |
| 213 | "h_max_m": 4.5, | 250 | "h_max_m": 4.5, |
| 214 | "len_major_m": 2.5, | 251 | "len_major_m": 2.5, |
| 215 | "len_minor_m": 1.5, | 252 | "len_minor_m": 1.5, |
| 216 | "max_hi_intensity_fraction": 0.10 | 253 | "max_hi_intensity_fraction": 0.1 |
| 217 | }, | 254 | }, |
| 218 | "views": { | 255 | "views": { |
| 219 | "near_radius_m": 45.0, | 256 | "near_radius_m": 45.0, |
| 220 | "fov_deg": 55.0, | 257 | "fov_deg": 55.0, |
| 221 | "splat": 2, | 258 | "splat": 2, |
| 222 | "image_width": 1100, | 259 | "image_width": 1100, |
| 223 | "image_height": 750, | 260 | "image_height": 750, |
| 224 | "view_names": ["back", "side"] | 261 | "view_names": [ |
| 262 | "back", | ||
| 263 | "side" | ||
| 264 | ] | ||
| 225 | }, | 265 | }, |
| 226 | "perspective": { | 266 | "perspective": { |
| 227 | "depth_tol_m": 0.5, | 267 | "depth_tol_m": 0.5, |
| 228 | "line_samples": 20, | 268 | "line_samples": 20, |
| 237 | "share_radius_m": 15.0, | 277 | "share_radius_m": 15.0, |
| 238 | "coverage_tol_m": 0.5 | 278 | "coverage_tol_m": 0.5 |
| 239 | }, | 279 | }, |
| 240 | "tree_instance": { | 280 | "tree_instance": { |
| 241 | "enabled": false, | 281 | "enabled": false, |
| 242 | "local_ground_footprint_m": 15.0, | 282 | "local_ground_footprint_m": 15.0, |
| 243 | "local_ground_cell_m": 2.0, | 283 | "local_ground_cell_m": 2.0, |
| 244 | "local_ground_percentile": 5.0, | 284 | "local_ground_percentile": 5.0, |
| 245 | "local_ground_window_m": 6.0, | 285 | "local_ground_window_m": 6.0, |
| 246 | "crown_base_bin_m": 0.25, | 286 | "crown_base_bin_m": 0.25, |
| 247 | "crown_base_density_frac": 0.35, | 287 | "crown_base_density_frac": 0.35, |
| 248 | "crown_base_run_bins": 3, | 288 | "crown_base_run_bins": 3, |
| 249 | "crown_base_min_m": 1.2, | 289 | "crown_base_min_m": 1.2, |
| 250 | "stem_band_low_m": 0.5, | 290 | "stem_band_low_m": 0.5, |
| 251 | "stem_band_cap_m": 4.0, | 291 | "stem_band_cap_m": 4.0, |
| 252 | "stem_band_min_thickness_m": 0.7, | 292 | "stem_band_min_thickness_m": 0.7, |
| 253 | "stem_eps_m": 0.35, | 293 | "stem_eps_m": 0.35, |
| 254 | "stem_min_samples": 20, | 294 | "stem_min_samples": 20, |
| 255 | "stem_max_diameter_m": 1.2, | 295 | "stem_max_diameter_m": 1.2, |
| 256 | "stem_min_vertical_reach": 0.5, | 296 | "stem_min_vertical_reach": 0.5, |
| 257 | "stem_min_verticality": 0.6, | 297 | "stem_min_verticality": 0.6, |
| 258 | "stem_min_score": 0.45, | 298 | "stem_min_score": 0.45, |
| 259 | "stem_exg_bonus": 0.1, | 299 | "stem_exg_bonus": 0.1, |
| 260 | "stem_merge_dist_m": 1.2, | 300 | "stem_merge_dist_m": 1.2, |
| 261 | "stem_uncertain_dist_m": 2.0, | 301 | "stem_uncertain_dist_m": 2.0, |
| 262 | "apex_fallback_enabled": true, | 302 | "apex_fallback_enabled": true, |
| 263 | "apex_cell_m": 0.5, | 303 | "apex_cell_m": 0.5, |
| 264 | "apex_smooth_sigma_m": 0.7, | 304 | "apex_smooth_sigma_m": 0.7, |
| 265 | "apex_min_separation_m": 2.5, | 305 | "apex_min_separation_m": 2.5, |
| 266 | "apex_min_prominence_m": 0.8, | 306 | "apex_min_prominence_m": 0.8, |
| 267 | "apex_min_height_m": 2.0, | 307 | "apex_min_height_m": 2.0, |
| 268 | "apex_trigger_span_m": 8.0, | 308 | "apex_trigger_span_m": 8.0, |
| 269 | "apex_seed_radius_m": 0.6, | 309 | "apex_seed_radius_m": 0.6, |
| 270 | "apex_confidence_scale": 0.6, | 310 | "apex_confidence_scale": 0.6, |
| 271 | "min_points_per_instance": 1200, | 311 | "min_points_per_instance": 1200, |
| 272 | "seedless_single_max_footprint_m": 10.0, | 312 | "seedless_single_max_footprint_m": 10.0, |
| 273 | "seedless_single_min_height_m": 1.5, | 313 | "seedless_single_min_height_m": 1.5, |
| 274 | "seedless_single_max_height_m": 25.0, | 314 | "seedless_single_max_height_m": 25.0, |
| 275 | "seedless_single_confidence": 0.35, | 315 | "seedless_single_confidence": 0.35, |
| 276 | "seedless_min_p95_h_m": 2.0, | 316 | "seedless_min_p95_h_m": 2.0, |
| 277 | "seedless_max_aspect": 2.5, | 317 | "seedless_max_aspect": 2.5, |
| 278 | "seedless_min_points": 800, | 318 | "seedless_min_points": 800, |
| 279 | "float_fragment_min_h_m": 3.0, | 319 | "float_fragment_min_h_m": 3.0, |
| 280 | "float_fragment_p25_h_m": 4.0, | 320 | "float_fragment_p25_h_m": 4.0, |
| 281 | "min_tree_footprint_m": 1.5, | 321 | "min_tree_footprint_m": 1.5, |
| 282 | "max_tree_footprint_m": 60.0, | 322 | "max_tree_footprint_m": 60.0, |
| 283 | "megacluster_points": 1000000, | 323 | "megacluster_points": 1000000, |
| 284 | "planar_min_footprint_m": 12.0, | 324 | "planar_min_footprint_m": 12.0, |
| 285 | "planar_cell_m": 1.0, | 325 | "planar_cell_m": 1.0, |
| 286 | "planar_max_spread_m": 0.3, | 326 | "planar_max_spread_m": 0.3, |
| 287 | "planar_fraction_min": 0.55, | 327 | "planar_fraction_min": 0.55, |
| 288 | "hedge_max_ground_gap_m": 2.0, | 328 | "hedge_max_ground_gap_m": 2.0, |
| 289 | "hedge_max_height_m": 7.5, | 329 | "hedge_max_height_m": 7.5, |
| 290 | "hedge_min_length_m": 8.0, | 330 | "hedge_min_length_m": 8.0, |
| 291 | "hedge_min_area_m2": 20.0, | 331 | "hedge_min_area_m2": 20.0, |
| 292 | "hedge_min_continuity": 0.75, | 332 | "hedge_min_continuity": 0.75, |
| 293 | "hedge_continuity_bin_m": 1.0, | 333 | "hedge_continuity_bin_m": 1.0, |
| 294 | "hedge_max_top_relief_m": 1.5, | 334 | "hedge_max_top_relief_m": 1.5, |
| 295 | "hedge_max_seed_per_10m": 1.0, | 335 | "hedge_max_seed_per_10m": 1.0, |
| 296 | "hedge_stem_score_min": 0.6, | 336 | "hedge_stem_score_min": 0.6, |
| 297 | "assign_voxel_m": 0.3, | 337 | "assign_voxel_m": 0.3, |
| 298 | "assign_max_gap_m": 1.25, | 338 | "assign_max_gap_m": 1.25, |
| 299 | "assign_max_graph_dist_m": 30.0, | 339 | "assign_max_graph_dist_m": 30.0, |
| 300 | "max_claim_radius_m": 9.0, | 340 | "max_claim_radius_m": 9.0, |
| 301 | "low_evidence_margin": 0.05, | 341 | "low_evidence_margin": 0.05, |
| 302 | "low_evidence_abstain": false, | 342 | "low_evidence_abstain": false, |
| 303 | "min_cluster_points": 150, | 343 | "min_cluster_points": 150, |
| 304 | "single_tree_footprint_m": 8.0, | 344 | "single_tree_footprint_m": 8.0, |
| 305 | "partial_abstain_fraction": 0.2, | 345 | "partial_abstain_fraction": 0.2, |
| 306 | "min_instance_points": 120, | 346 | "min_instance_points": 120, |
| 307 | "min_instance_fraction": 0.01, | 347 | "min_instance_fraction": 0.01, |
| 308 | "instance_max_linearity": 0.92, | 348 | "instance_max_linearity": 0.92, |
| 309 | "instance_min_minor_m": 1.0, | 349 | "instance_min_minor_m": 1.0, |
| 310 | "instance_min_vertical_m": 1.5, | 350 | "instance_min_vertical_m": 1.5, |
| 311 | "instance_min_thickness_share": 0.02, | 351 | "instance_min_thickness_share": 0.02, |
| 312 | "confidence_seed_weight": 0.6, | 352 | "confidence_seed_weight": 0.6, |
| 313 | "confidence_size_ref_points": 2000.0, | 353 | "confidence_size_ref_points": 2000.0, |
| 314 | "confidence_max": 0.95, | 354 | "confidence_max": 0.95, |
| 315 | "confidence_fallback_max": 0.9 | 355 | "confidence_fallback_max": 0.9 |
| 356 | }, | ||
| 357 | "conic_gate": { | ||
| 358 | "apex_deg_max": 35.0, | ||
| 359 | "apex_deg_min": 5.0, | ||
| 360 | "change_of_curvature_min": 0.06, | ||
| 361 | "enabled": false, | ||
| 362 | "h_max_min_m": 2.5, | ||
| 363 | "h_over_width_max": 12.0, | ||
| 364 | "h_over_width_min": 1.5, | ||
| 365 | "max_hi_intensity_fraction": 0.2, | ||
| 366 | "max_on_road_fraction": 0.6, | ||
| 367 | "min_crown_area_m2": 0.3, | ||
| 368 | "min_decile_fill_fraction": 0.8, | ||
| 369 | "omnivariance_min": 0.1, | ||
| 370 | "taper_slope_max": -0.4, | ||
| 371 | "taper_slope_robust_max": -0.3, | ||
| 372 | "texture_cue_enabled": true | ||
| 373 | }, | ||
| 374 | "conifer_rule": { | ||
| 375 | "enabled": false, | ||
| 376 | "h_max_min_m": 2.0, | ||
| 377 | "h_over_width_max": 15.0, | ||
| 378 | "h_over_width_min": 2.0, | ||
| 379 | "max_apex_ratio": 0.75, | ||
| 380 | "max_crown_base_frac": 0.55, | ||
| 381 | "max_crown_taper": -0.1, | ||
| 382 | "max_hi_intensity_fraction": 0.2, | ||
| 383 | "max_on_road_fraction": 0.6, | ||
| 384 | "max_stem_ratio": 2.2, | ||
| 385 | "max_volumetric_density": 380.0, | ||
| 386 | "min_change_of_curvature": 0.04, | ||
| 387 | "min_crown_area_m2": 0.2, | ||
| 388 | "min_decile_fill_fraction": 0.8, | ||
| 389 | "min_volumetric_density": 140.0 | ||
| 390 | }, | ||
| 391 | "radius": { | ||
| 392 | "crown_lobe_coverage_target": 0.95, | ||
| 393 | "crown_lobe_gap_m": 0.5, | ||
| 394 | "crown_lobe_max_count": 8, | ||
| 395 | "crown_lobe_min_points": 30, | ||
| 396 | "crown_lobe_min_samples": 10, | ||
| 397 | "crown_radius_percentile": 95.0, | ||
| 398 | "debug_cluster_points": false, | ||
| 399 | "fit_bin_m": 0.25, | ||
| 400 | "fit_divergence_factor": 4.0, | ||
| 401 | "fit_min_arc_deg": 60.0, | ||
| 402 | "fit_min_bin_points": 8, | ||
| 403 | "fit_residual_abs_m": 0.03, | ||
| 404 | "fit_residual_frac": 0.35, | ||
| 405 | "pole_radius_max_m": 0.5, | ||
| 406 | "trunk_radius_max_m": 0.8 | ||
| 407 | }, | ||
| 408 | "rail_halfpost": { | ||
| 409 | "band_lat_m": 0.8, | ||
| 410 | "band_z_hi_m": 1.5, | ||
| 411 | "band_z_lo_m": 0.15, | ||
| 412 | "cluster_cell_m": 0.15, | ||
| 413 | "dedupe_m": 1.5, | ||
| 414 | "enabled": false, | ||
| 415 | "ground_cell_m": 2.0, | ||
| 416 | "ground_percentile": 10.0, | ||
| 417 | "h_max_m": 0.8, | ||
| 418 | "h_min_m": 0.2, | ||
| 419 | "max_lateral_m": 0.5, | ||
| 420 | "max_width_m": 0.2, | ||
| 421 | "min_emit_points": 8, | ||
| 422 | "min_points": 15, | ||
| 423 | "min_z_extent_m": 0.1, | ||
| 424 | "models_dir": "", | ||
| 425 | "prime_min_records": 2, | ||
| 426 | "prime_min_sat": 1, | ||
| 427 | "sample_step_m": 0.1, | ||
| 428 | "saturation_intensity": 55000.0 | ||
| 429 | }, | ||
| 430 | "reject_rescue": { | ||
| 431 | "accepted_exclusion_m": 2.0, | ||
| 432 | "enabled": false, | ||
| 433 | "h_max_m": 1.6, | ||
| 434 | "h_min_m": 0.85, | ||
| 435 | "max_core_rms_m": 0.2, | ||
| 436 | "merge_radius_m": 1.0, | ||
| 437 | "min_continuity": 0.8, | ||
| 438 | "min_decile_fill": 0.6, | ||
| 439 | "min_h_over_width": 1.4, | ||
| 440 | "min_points": 30, | ||
| 441 | "min_records": 2, | ||
| 442 | "min_roadctx_sat": 17, | ||
| 443 | "min_verticality": 0.9, | ||
| 444 | "per_segment_cap": 0 | ||
| 445 | }, | ||
| 446 | "tcs_ground": { | ||
| 447 | "cache_dir": "", | ||
| 448 | "cell_m": 0.2, | ||
| 449 | "elev_scalar": 0.0, | ||
| 450 | "enabled": false, | ||
| 451 | "max_elev_diff_m": 0.15, | ||
| 452 | "mechanism": "smrf_numpy", | ||
| 453 | "pit_fill_enabled": true, | ||
| 454 | "slope_threshold": 0.3, | ||
| 455 | "smrf_max_window_m": 6.0 | ||
| 316 | } | 456 | } |
| 317 | } | 457 | } |
| 1 | """Shared fixtures for the vertical-sign detector tests.""" | 1 | """Shared fixtures for the vertical-sign detector tests.""" |
| 2 | 2 | ||
| 3 | import typing | ||
| 3 | from collections.abc import Callable | 4 | from collections.abc import Callable |
| 4 | from typing import Any | 5 | from typing import Any |
| 5 | 6 | ||
| 6 | import pytest | 7 | import pytest |
| 17 | elif annotation is int: | 18 | elif annotation is int: |
| 18 | values[name] = int(field.default) + 1 | 19 | values[name] = int(field.default) + 1 |
| 19 | elif annotation is str: | 20 | elif annotation is str: |
| 20 | values[name] = f"{field.default}_x" | 21 | values[name] = f"{field.default}_x" |
| 22 | elif typing.get_origin(annotation) is tuple: | ||
| 23 | values[name] = [f"{item}_x" for item in field.default] | ||
| 21 | else: | 24 | else: |
| 22 | values[name] = 0.5 | 25 | values[name] = 0.5 |
| 23 | return values | 26 | return values |
| 24 | 27 |
| 278 | "chroma_vegetation", | 278 | "chroma_vegetation", |
| 279 | ) | 279 | ) |
| 280 | 280 | ||
| 281 | 281 | ||
| 282 | def test_overrides_reach_the_dataclass() -> None: | 282 | def test_overrides_reach_the_model() -> None: |
| 283 | config = DetectorConfig.from_mapping( | 283 | config = DetectorConfig.from_mapping( |
| 284 | {"chroma_vegetation": {"enabled": True, "exg_min": 0.33}} | 284 | {"chroma_vegetation": {"enabled": True, "exg_min": 0.33}} |
| 285 | ) | 285 | ) |
| 286 | assert config.chroma_veg_enabled is True | 286 | assert config.chroma_veg_enabled is True |
| 95 | 95 | ||
| 96 | 96 | ||
| 97 | def test_the_packaged_defaults_round_trip() -> None: | 97 | def test_the_packaged_defaults_round_trip() -> None: |
| 98 | packaged = load_default_config() | 98 | packaged = load_default_config() |
| 99 | assert json.dumps(packaged) # it is a plain JSON document | 99 | assert json.loads(json.dumps(packaged)) == packaged # plain JSON types only |
| 100 | assert DetectorConfig.from_mapping(packaged) == DetectorConfig.load() | 100 | assert DetectorConfig.from_mapping(packaged) == DetectorConfig.load() |
| 101 | 101 | ||
| 102 | 102 | ||
| 103 | def test_the_packaged_defaults_equal_the_flat_defaults() -> None: | 103 | def test_the_packaged_defaults_equal_the_flat_defaults() -> None: |
| 124 | except ValueError as exc: | 124 | except ValueError as exc: |
| 125 | assert "cluster_eps" in str(exc) | 125 | assert "cluster_eps" in str(exc) |
| 126 | else: # pragma: no cover - the failure the test exists to catch | 126 | else: # pragma: no cover - the failure the test exists to catch |
| 127 | raise AssertionError("a misspelled field name was accepted") | 127 | raise AssertionError("a misspelled field name was accepted") |
| 128 | |||
| 129 | |||
| 130 | def test_the_packaged_json_declares_exactly_the_model_keys() -> None: | ||
| 131 | """The packaged JSON and the model must not drift apart in SHAPE either. | ||
| 132 | |||
| 133 | ``load_verticalsigns_config`` returns the validated model dump, so a key | ||
| 134 | the model declares but the JSON omits would be injected into the returned | ||
| 135 | document (and a JSON key the model lacks would be rejected outright). | ||
| 136 | """ | ||
| 137 | packaged = json.loads( | ||
| 138 | (CONFIG_PY.parent / "verticalsigns.default.json").read_text(encoding="utf-8") | ||
| 139 | ) | ||
| 140 | model = _config_model.VerticalSignsConfig().model_dump(mode="json") | ||
| 141 | assert {s: sorted(keys) for s, keys in packaged.items()} == { | ||
| 142 | s: sorted(keys) for s, keys in model.items() | ||
| 143 | } |
| 18 | 18 | ||
| 19 | `python -m iolabs_point_cloud_detection_verticalsigns.detect ...` works too, as | 19 | `python -m iolabs_point_cloud_detection_verticalsigns.detect ...` works too, as |
| 20 | does `uv run verticalsigns-views ...` for the per-detection close-up renders. | 20 | does `uv run verticalsigns-views ...` for the per-detection close-up renders. |
| 21 | 21 | ||
| 22 | Thresholds live in the packaged `verticalsigns.default.json` (nested sections: | 22 | Thresholds live in the packaged `verticalsigns.default.json`, one nested |
| 23 | `ground`, `occupancy`, `candidates`, `clustering`, `classification`, `corridor`, | 23 | section per detector stage (`ground`, `occupancy`, `candidates`, `clustering`, |
| 24 | `context`, `delineator`, `sign_post`, `panel`, `gantry`, `repetitive_row`, | 24 | `classification`, `radius`, `corridor`, `context`, `delineator`, `sign_post`, |
| 25 | `marker_extract`, `rail_halfpost`, `reject_rescue`, `tree`, `tree_detection`, | 25 | `panel`, `gantry`, `repetitive_row`, `field_stake`, `marker_extract`, |
| 26 | `chroma_vegetation`, `vehicle`, `views`, `perspective`). Pass `--config` | 26 | `rail_halfpost`, `reject_rescue`, `road_context`, `edge_line`, `tree`, |
| 27 | to deep-merge a partial JSON over those defaults; unknown keys and bad values | 27 | `tree_detection`, `tree_instance`, `chroma_vegetation`, `tcs_ground`, |
| 28 | are rejected. | 28 | `conic_gate`, `conifer_rule`, `vehicle`, `views`, `perspective`). Pass |
| 29 | `--config` to deep-merge a partial JSON over those defaults; unknown keys and | ||
| 30 | bad values are rejected. | ||
| 29 | 31 | ||
| 30 | The schema of that JSON is the `VerticalSignsConfig` pydantic model tree | 32 | The schema of that JSON is the `VerticalSignsConfig` pydantic model tree |
| 31 | (`_config_model.py` plus the `_model_<slice>.py` sections, built on | 33 | (`_config_model.py` plus the `_model_<slice>.py` sections, built on |
| 32 | `iolabs.common.config_loader.ConfigModel`): one nested model per JSON section, | 34 | `iolabs.common.config_loader.ConfigModel`): one nested model per JSON section, |
| 33 | one field per key. **Adding a config key = add the field to its section model | 35 | one field per key, and the two sides must agree key for key (guarded by |
| 34 | and the default to `verticalsigns.default.json`, nothing else.** | 36 | `tests/test_config_split.py`). **Adding a config key = add the field to its |
| 37 | section model and the same default to `verticalsigns.default.json`** โ plus, | ||
| 38 | if a detector module reads it, the flat field and `*_kwargs` line below. | ||
| 35 | 39 | ||
| 36 | The 379-field `DetectorConfig` is the FLAT view the detector modules read | 40 | The 379-field `DetectorConfig` is the FLAT view the detector modules read |
| 37 | (`config.ground_cell_m`): it is declared across the `_config_<section>` modules | 41 | (`config.ground_cell_m`): it is declared across the `_config_<section>` modules |
| 38 | and recombined in `config.py`, which re-exports every name โ import from | 42 | and recombined in `config.py`, which re-exports every name โ import from |
| 39 | `...verticalsigns.config` exactly as before. A new key that the detector reads | 43 | `...verticalsigns.config` exactly as before. A new key that the detector reads |
| 40 | also needs its flat field and the `*_kwargs` line that maps the section key | 44 | needs its flat field here and the `*_kwargs` line that maps the section key |
| 41 | onto it. | 45 | onto it; a key only consumed from the nested document (e.g. `views`) does not. |
| 42 | 46 | ||
| 43 | ## QC rendering is an optional extra | 47 | ## QC rendering is an optional extra |
| 44 | 48 | ||
| 45 | `verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need | 49 | `verticalsigns-views` and `verticalsigns-perspective` render QC imagery and need |
| 46 | config_path: Optional user JSON merged over the packaged defaults. Only | 46 | config_path: Optional user JSON merged over the packaged defaults. Only |
| 47 | the keys it carries are overridden. | 47 | the keys it carries are overridden. |
| 48 | 48 | ||
| 49 | Returns: | 49 | Returns: |
| 50 | The validated config document, every section present. | 50 | The validated config document as plain JSON types: every section and |
| 51 | every key the model declares is present, defaults included. | ||
| 51 | 52 | ||
| 52 | Raises: | 53 | Raises: |
| 53 | VerticalSignsConfigError: The user JSON is malformed, or the merged | 54 | VerticalSignsConfigError: The user JSON is malformed, or the merged |
| 54 | config holds an unknown section/key or an invalid value. | 55 | config holds an unknown section/key or an invalid value. |
| 61 | overrides=overrides, | 62 | overrides=overrides, |
| 62 | context=_CONTEXT, | 63 | context=_CONTEXT, |
| 63 | error_cls=VerticalSignsConfigError, | 64 | error_cls=VerticalSignsConfigError, |
| 64 | ) | 65 | ) |
| 65 | return config.model_dump() | 66 | return config.model_dump(mode="json") |
| 66 | 67 | ||
| 67 | 68 | ||
| 68 | def _read_user_config(config_path: str | Path) -> dict[str, Any]: | 69 | def _read_user_config(config_path: str | Path) -> dict[str, Any]: |
| 69 | """Read a user config JSON, wrapping decode errors in the package error.""" | 70 | """Read a user config JSON, wrapping decode errors in the package error.""" |
| 2 | 2 | ||
| 3 | ``VerticalSignsConfig`` mirrors ``verticalsigns.default.json`` section for | 3 | ``VerticalSignsConfig`` mirrors ``verticalsigns.default.json`` section for |
| 4 | section and key for key: it is the single source of truth for which config | 4 | section and key for key: it is the single source of truth for which config |
| 5 | keys exist and what type each one has. Adding a key means adding a field to | 5 | keys exist and what type each one has. Adding a key means adding a field to |
| 6 | the matching section model and a default to the packaged JSON. | 6 | the matching section model and the same default to the packaged JSON; the two |
| 7 | sides must stay in lockstep, and ``tests/test_config_split.py`` fails if they | ||
| 8 | drift. A key the detector modules read also needs its flat ``DetectorConfig`` | ||
| 9 | field and the ``*_kwargs`` line that maps it (see ``config.py``). | ||
| 7 | """ | 10 | """ |
| 8 | 11 | ||
| 9 | from iolabs.common import config_loader | 12 | from iolabs.common import config_loader |
| 10 | 13 |
| 107 | def with_overrides(self, **overrides: Any) -> "DetectorConfig": | 107 | def with_overrides(self, **overrides: Any) -> "DetectorConfig": |
| 108 | """Return a copy of this config with *overrides* applied. | 108 | """Return a copy of this config with *overrides* applied. |
| 109 | 109 | ||
| 110 | ``model_copy(update=...)`` skips validation, so a misspelled name would | 110 | ``model_copy(update=...)`` skips validation, so a misspelled name would |
| 111 | be attached as a new attribute and the intended threshold would keep | 111 | be attached as a new attribute and a wrongly typed value would be |
| 112 | its default. The names are therefore checked here, reproducing the | 112 | stored uncoerced. The names are checked here and the values are run |
| 113 | ``TypeError`` that ``dataclasses.replace`` used to raise. | 113 | through the model, so this validates where ``dataclasses.replace`` |
| 114 | merely type-checked the call. | ||
| 114 | 115 | ||
| 115 | Args: | 116 | Args: |
| 116 | overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``. | 117 | overrides: Field name to new value, e.g. ``cluster_eps_m=0.9``. |
| 117 | 118 | ||
| 118 | Returns: | 119 | Returns: |
| 119 | A new frozen config carrying *overrides*. | 120 | A new frozen config carrying *overrides*. |
| 120 | 121 | ||
| 121 | Raises: | 122 | Raises: |
| 122 | ValueError: An override names a field this config does not declare. | 123 | ValueError: An override names a field this config does not declare, |
| 124 | or carries a value the field rejects (a | ||
| 125 | ``pydantic.ValidationError``, itself a ``ValueError``). | ||
| 123 | """ | 126 | """ |
| 124 | unknown = sorted(set(overrides) - set(type(self).model_fields)) | 127 | unknown = sorted(set(overrides) - set(type(self).model_fields)) |
| 125 | if unknown: | 128 | if unknown: |
| 126 | raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}") | 129 | raise ValueError(f"Unknown DetectorConfig field(s): {', '.join(unknown)}") |
| 127 | return self.model_copy(update=overrides) | 130 | return type(self).model_validate({**self.model_dump(), **overrides}) |
| 128 | 131 | ||
| 129 | @classmethod | 132 | @classmethod |
| 130 | def load(cls, config_path: str | Path | None = None) -> "DetectorConfig": | 133 | def load(cls, config_path: str | Path | None = None) -> "DetectorConfig": |
| 131 | """Load config from the packaged defaults merged with an optional user JSON.""" | 134 | """Load config from the packaged defaults merged with an optional user JSON.""" |
| 6 | "occupancy": { | 6 | "occupancy": { |
| 7 | "cell_m": 0.15 | 7 | "cell_m": 0.15 |
| 8 | }, | 8 | }, |
| 9 | "candidates": { | 9 | "candidates": { |
| 10 | "min_height_m": 0.30, | 10 | "min_height_m": 0.3, |
| 11 | "max_height_m": 10.0, | 11 | "max_height_m": 10.0, |
| 12 | "seed_min_vertical_span_m": 0.80, | 12 | "seed_min_vertical_span_m": 0.8, |
| 13 | "seed_min_h_max_m": 0.90, | 13 | "seed_min_h_max_m": 0.9, |
| 14 | "seed_bright_min_vertical_span_m": 0.45, | 14 | "seed_bright_min_vertical_span_m": 0.45, |
| 15 | "seed_bright_min_h_max_m": 0.60, | 15 | "seed_bright_min_h_max_m": 0.6, |
| 16 | "seed_bright_min_points": 3 | 16 | "seed_bright_min_points": 3 |
| 17 | }, | 17 | }, |
| 18 | "clustering": { | 18 | "clustering": { |
| 19 | "eps_m": 0.45, | 19 | "eps_m": 0.45, |
| 20 | "min_samples": 1, | 20 | "min_samples": 1, |
| 21 | "hull_margin_m": 0.20 | 21 | "hull_margin_m": 0.2 |
| 22 | }, | 22 | }, |
| 23 | "classification": { | 23 | "classification": { |
| 24 | "continuity_bin_m": 0.25, | 24 | "continuity_bin_m": 0.25, |
| 25 | "reject_len_major_m": 6.0, | 25 | "reject_len_major_m": 6.0, |
| 26 | "reject_h_max_with_large_footprint_m": 4.5, | 26 | "reject_h_max_with_large_footprint_m": 4.5, |
| 27 | "min_continuity": 0.50, | 27 | "min_continuity": 0.5, |
| 28 | "min_accept_h_max_m": 0.90, | 28 | "min_accept_h_max_m": 0.9, |
| 29 | "core_rms_bin_m": 0.25, | 29 | "core_rms_bin_m": 0.25, |
| 30 | "core_rms_h_min_m": 0.30, | 30 | "core_rms_h_min_m": 0.3, |
| 31 | "core_rms_h_cap_m": 3.0, | 31 | "core_rms_h_cap_m": 3.0, |
| 32 | "hi_intensity_all_points_percentile": 98.0, | 32 | "hi_intensity_all_points_percentile": 98.0, |
| 33 | "min_volumetric_density": 8000.0, | 33 | "min_volumetric_density": 8000.0, |
| 34 | "pole_floating_min_h_min_m": 3.5, | 34 | "pole_floating_min_h_min_m": 3.5, |
| 36 | "dedup_radius_m": 0.8, | 36 | "dedup_radius_m": 0.8, |
| 37 | "emit_trees": false, | 37 | "emit_trees": false, |
| 38 | "ml_verifier_enabled": true, | 38 | "ml_verifier_enabled": true, |
| 39 | "ml_veto_threshold": -1.0, | 39 | "ml_veto_threshold": -1.0, |
| 40 | "ml_model_path": "" | 40 | "ml_model_path": "", |
| 41 | "lattice_admission": true, | ||
| 42 | "lattice_max_seed_spacing_m": 60.0, | ||
| 43 | "lattice_max_skip": 6, | ||
| 44 | "lattice_max_spacing_resid": 0.15, | ||
| 45 | "lattice_min_anchors": 4, | ||
| 46 | "lattice_min_seed_spacing_m": 15.0, | ||
| 47 | "lattice_pool_h_max_max_m": 1.4, | ||
| 48 | "lattice_pool_h_max_min_m": 0.8, | ||
| 49 | "lattice_pool_max_len_major_m": 1.2, | ||
| 50 | "lattice_pool_max_plate_thickness_m": 0.05, | ||
| 51 | "lattice_pool_min_hi_seed_fraction": 0.15, | ||
| 52 | "lattice_pool_min_points": 20, | ||
| 53 | "lattice_pool_min_verticality": 0.85, | ||
| 54 | "lattice_snap_m": 3.0, | ||
| 55 | "ml_veto_requires_corridor": true, | ||
| 56 | "robust_extent_hi_percentile": 99.0, | ||
| 57 | "robust_extent_lo_percentile": 1.0, | ||
| 58 | "robust_extent_stats": true, | ||
| 59 | "robust_h_max_percentile": 98.0, | ||
| 60 | "seed_bright_percentile": 95.0, | ||
| 61 | "single_record_transient_veto": true, | ||
| 62 | "transient_max_h_max_m": 2.5, | ||
| 63 | "transient_max_verticality": 0.3, | ||
| 64 | "transient_min_len_major_m": 2.0, | ||
| 65 | "veg_texture_min_hi_seed_fraction": 0.668, | ||
| 66 | "veg_texture_min_plate_thickness_m": 0.05, | ||
| 67 | "veg_texture_veto": true, | ||
| 68 | "verticality_sentinel_fix": true | ||
| 41 | }, | 69 | }, |
| 42 | "corridor": { | 70 | "corridor": { |
| 43 | "max_dist_to_road_m": 10.0, | 71 | "max_dist_to_road_m": 10.0, |
| 44 | "on_carriageway_dist_m": 0.25, | 72 | "on_carriageway_dist_m": 0.25, |
| 78 | "sign_post": { | 106 | "sign_post": { |
| 79 | "max_len_minor_m": 0.8, | 107 | "max_len_minor_m": 0.8, |
| 80 | "h_min_m": 1.5, | 108 | "h_min_m": 1.5, |
| 81 | "h_max_m": 6.0, | 109 | "h_max_m": 6.0, |
| 82 | "min_continuity": 0.60, | 110 | "min_continuity": 0.6, |
| 83 | "plate_hi_intensity_fraction": 0.40, | 111 | "plate_hi_intensity_fraction": 0.4, |
| 84 | "plate_hi_intensity_fraction_weak": 0.30, | 112 | "plate_hi_intensity_fraction_weak": 0.3, |
| 85 | "plate_upper_surplus_ratio": 2.0, | 113 | "plate_upper_surplus_ratio": 2.0, |
| 86 | "min_upper_half_surplus": 0.30, | 114 | "min_upper_half_surplus": 0.3, |
| 87 | "plate_min_core_rms_m": 0.10, | 115 | "plate_min_core_rms_m": 0.1, |
| 88 | "max_plate_thickness_m": 0.15, | 116 | "max_plate_thickness_m": 0.15, |
| 89 | "bare_post_min_h_max_m": 4.5, | 117 | "bare_post_min_h_max_m": 4.5, |
| 90 | "bare_post_max_core_rms_m": 0.065, | 118 | "bare_post_max_core_rms_m": 0.065, |
| 91 | "bare_post_min_verticality": 0.90, | 119 | "bare_post_min_verticality": 0.9, |
| 92 | "bare_post_min_points": 450 | 120 | "bare_post_min_points": 450 |
| 93 | }, | 121 | }, |
| 94 | "panel": { | 122 | "panel": { |
| 95 | "min_hi": 0.40, | 123 | "min_hi": 0.4, |
| 96 | "max_thickness_m": 0.20, | 124 | "max_thickness_m": 0.2, |
| 97 | "h_min_m": 0.9, | 125 | "h_min_m": 0.9, |
| 98 | "len_major_min_m": 1.5, | 126 | "len_major_min_m": 1.5, |
| 99 | "len_major_max_m": 5.0 | 127 | "len_major_max_m": 5.0 |
| 100 | }, | 128 | }, |
| 160 | "far_max_distance_m": 30.0, | 188 | "far_max_distance_m": 30.0, |
| 161 | "far_include_lane_lines": true, | 189 | "far_include_lane_lines": true, |
| 162 | "far_tier2_enabled": true, | 190 | "far_tier2_enabled": true, |
| 163 | "far_tier2_distance_m": 15.0, | 191 | "far_tier2_distance_m": 15.0, |
| 164 | "far_tier2_max_saturation": 150 | 192 | "far_tier2_max_saturation": 150, |
| 193 | "max_carriageway_width_m": 20.0, | ||
| 194 | "min_carriageway_width_m": 3.0, | ||
| 195 | "paint_fallback_enabled": false, | ||
| 196 | "xml_enabled": true, | ||
| 197 | "xml_max_distance_m": 60.0, | ||
| 198 | "xml_min_agreement": 0.6, | ||
| 199 | "xml_station_step_m": 10.0, | ||
| 200 | "xml_station_tolerance_m": 2.0, | ||
| 201 | "xml_vote_slack_m": 3.0 | ||
| 165 | }, | 202 | }, |
| 166 | "field_stake": { | 203 | "field_stake": { |
| 167 | "row_emit": true, | 204 | "row_emit": true, |
| 168 | "min_members": 4, | 205 | "min_members": 4, |
| 212 | "h_min_m": 1.5, | 249 | "h_min_m": 1.5, |
| 213 | "h_max_m": 4.5, | 250 | "h_max_m": 4.5, |
| 214 | "len_major_m": 2.5, | 251 | "len_major_m": 2.5, |
| 215 | "len_minor_m": 1.5, | 252 | "len_minor_m": 1.5, |
| 216 | "max_hi_intensity_fraction": 0.10 | 253 | "max_hi_intensity_fraction": 0.1 |
| 217 | }, | 254 | }, |
| 218 | "views": { | 255 | "views": { |
| 219 | "near_radius_m": 45.0, | 256 | "near_radius_m": 45.0, |
| 220 | "fov_deg": 55.0, | 257 | "fov_deg": 55.0, |
| 221 | "splat": 2, | 258 | "splat": 2, |
| 222 | "image_width": 1100, | 259 | "image_width": 1100, |
| 223 | "image_height": 750, | 260 | "image_height": 750, |
| 224 | "view_names": ["back", "side"] | 261 | "view_names": [ |
| 262 | "back", | ||
| 263 | "side" | ||
| 264 | ] | ||
| 225 | }, | 265 | }, |
| 226 | "perspective": { | 266 | "perspective": { |
| 227 | "depth_tol_m": 0.5, | 267 | "depth_tol_m": 0.5, |
| 228 | "line_samples": 20, | 268 | "line_samples": 20, |
| 237 | "share_radius_m": 15.0, | 277 | "share_radius_m": 15.0, |
| 238 | "coverage_tol_m": 0.5 | 278 | "coverage_tol_m": 0.5 |
| 239 | }, | 279 | }, |
| 240 | "tree_instance": { | 280 | "tree_instance": { |
| 241 | "enabled": false, | 281 | "enabled": false, |
| 242 | "local_ground_footprint_m": 15.0, | 282 | "local_ground_footprint_m": 15.0, |
| 243 | "local_ground_cell_m": 2.0, | 283 | "local_ground_cell_m": 2.0, |
| 244 | "local_ground_percentile": 5.0, | 284 | "local_ground_percentile": 5.0, |
| 245 | "local_ground_window_m": 6.0, | 285 | "local_ground_window_m": 6.0, |
| 246 | "crown_base_bin_m": 0.25, | 286 | "crown_base_bin_m": 0.25, |
| 247 | "crown_base_density_frac": 0.35, | 287 | "crown_base_density_frac": 0.35, |
| 248 | "crown_base_run_bins": 3, | 288 | "crown_base_run_bins": 3, |
| 249 | "crown_base_min_m": 1.2, | 289 | "crown_base_min_m": 1.2, |
| 250 | "stem_band_low_m": 0.5, | 290 | "stem_band_low_m": 0.5, |
| 251 | "stem_band_cap_m": 4.0, | 291 | "stem_band_cap_m": 4.0, |
| 252 | "stem_band_min_thickness_m": 0.7, | 292 | "stem_band_min_thickness_m": 0.7, |
| 253 | "stem_eps_m": 0.35, | 293 | "stem_eps_m": 0.35, |
| 254 | "stem_min_samples": 20, | 294 | "stem_min_samples": 20, |
| 255 | "stem_max_diameter_m": 1.2, | 295 | "stem_max_diameter_m": 1.2, |
| 256 | "stem_min_vertical_reach": 0.5, | 296 | "stem_min_vertical_reach": 0.5, |
| 257 | "stem_min_verticality": 0.6, | 297 | "stem_min_verticality": 0.6, |
| 258 | "stem_min_score": 0.45, | 298 | "stem_min_score": 0.45, |
| 259 | "stem_exg_bonus": 0.1, | 299 | "stem_exg_bonus": 0.1, |
| 260 | "stem_merge_dist_m": 1.2, | 300 | "stem_merge_dist_m": 1.2, |
| 261 | "stem_uncertain_dist_m": 2.0, | 301 | "stem_uncertain_dist_m": 2.0, |
| 262 | "apex_fallback_enabled": true, | 302 | "apex_fallback_enabled": true, |
| 263 | "apex_cell_m": 0.5, | 303 | "apex_cell_m": 0.5, |
| 264 | "apex_smooth_sigma_m": 0.7, | 304 | "apex_smooth_sigma_m": 0.7, |
| 265 | "apex_min_separation_m": 2.5, | 305 | "apex_min_separation_m": 2.5, |
| 266 | "apex_min_prominence_m": 0.8, | 306 | "apex_min_prominence_m": 0.8, |
| 267 | "apex_min_height_m": 2.0, | 307 | "apex_min_height_m": 2.0, |
| 268 | "apex_trigger_span_m": 8.0, | 308 | "apex_trigger_span_m": 8.0, |
| 269 | "apex_seed_radius_m": 0.6, | 309 | "apex_seed_radius_m": 0.6, |
| 270 | "apex_confidence_scale": 0.6, | 310 | "apex_confidence_scale": 0.6, |
| 271 | "min_points_per_instance": 1200, | 311 | "min_points_per_instance": 1200, |
| 272 | "seedless_single_max_footprint_m": 10.0, | 312 | "seedless_single_max_footprint_m": 10.0, |
| 273 | "seedless_single_min_height_m": 1.5, | 313 | "seedless_single_min_height_m": 1.5, |
| 274 | "seedless_single_max_height_m": 25.0, | 314 | "seedless_single_max_height_m": 25.0, |
| 275 | "seedless_single_confidence": 0.35, | 315 | "seedless_single_confidence": 0.35, |
| 276 | "seedless_min_p95_h_m": 2.0, | 316 | "seedless_min_p95_h_m": 2.0, |
| 277 | "seedless_max_aspect": 2.5, | 317 | "seedless_max_aspect": 2.5, |
| 278 | "seedless_min_points": 800, | 318 | "seedless_min_points": 800, |
| 279 | "float_fragment_min_h_m": 3.0, | 319 | "float_fragment_min_h_m": 3.0, |
| 280 | "float_fragment_p25_h_m": 4.0, | 320 | "float_fragment_p25_h_m": 4.0, |
| 281 | "min_tree_footprint_m": 1.5, | 321 | "min_tree_footprint_m": 1.5, |
| 282 | "max_tree_footprint_m": 60.0, | 322 | "max_tree_footprint_m": 60.0, |
| 283 | "megacluster_points": 1000000, | 323 | "megacluster_points": 1000000, |
| 284 | "planar_min_footprint_m": 12.0, | 324 | "planar_min_footprint_m": 12.0, |
| 285 | "planar_cell_m": 1.0, | 325 | "planar_cell_m": 1.0, |
| 286 | "planar_max_spread_m": 0.3, | 326 | "planar_max_spread_m": 0.3, |
| 287 | "planar_fraction_min": 0.55, | 327 | "planar_fraction_min": 0.55, |
| 288 | "hedge_max_ground_gap_m": 2.0, | 328 | "hedge_max_ground_gap_m": 2.0, |
| 289 | "hedge_max_height_m": 7.5, | 329 | "hedge_max_height_m": 7.5, |
| 290 | "hedge_min_length_m": 8.0, | 330 | "hedge_min_length_m": 8.0, |
| 291 | "hedge_min_area_m2": 20.0, | 331 | "hedge_min_area_m2": 20.0, |
| 292 | "hedge_min_continuity": 0.75, | 332 | "hedge_min_continuity": 0.75, |
| 293 | "hedge_continuity_bin_m": 1.0, | 333 | "hedge_continuity_bin_m": 1.0, |
| 294 | "hedge_max_top_relief_m": 1.5, | 334 | "hedge_max_top_relief_m": 1.5, |
| 295 | "hedge_max_seed_per_10m": 1.0, | 335 | "hedge_max_seed_per_10m": 1.0, |
| 296 | "hedge_stem_score_min": 0.6, | 336 | "hedge_stem_score_min": 0.6, |
| 297 | "assign_voxel_m": 0.3, | 337 | "assign_voxel_m": 0.3, |
| 298 | "assign_max_gap_m": 1.25, | 338 | "assign_max_gap_m": 1.25, |
| 299 | "assign_max_graph_dist_m": 30.0, | 339 | "assign_max_graph_dist_m": 30.0, |
| 300 | "max_claim_radius_m": 9.0, | 340 | "max_claim_radius_m": 9.0, |
| 301 | "low_evidence_margin": 0.05, | 341 | "low_evidence_margin": 0.05, |
| 302 | "low_evidence_abstain": false, | 342 | "low_evidence_abstain": false, |
| 303 | "min_cluster_points": 150, | 343 | "min_cluster_points": 150, |
| 304 | "single_tree_footprint_m": 8.0, | 344 | "single_tree_footprint_m": 8.0, |
| 305 | "partial_abstain_fraction": 0.2, | 345 | "partial_abstain_fraction": 0.2, |
| 306 | "min_instance_points": 120, | 346 | "min_instance_points": 120, |
| 307 | "min_instance_fraction": 0.01, | 347 | "min_instance_fraction": 0.01, |
| 308 | "instance_max_linearity": 0.92, | 348 | "instance_max_linearity": 0.92, |
| 309 | "instance_min_minor_m": 1.0, | 349 | "instance_min_minor_m": 1.0, |
| 310 | "instance_min_vertical_m": 1.5, | 350 | "instance_min_vertical_m": 1.5, |
| 311 | "instance_min_thickness_share": 0.02, | 351 | "instance_min_thickness_share": 0.02, |
| 312 | "confidence_seed_weight": 0.6, | 352 | "confidence_seed_weight": 0.6, |
| 313 | "confidence_size_ref_points": 2000.0, | 353 | "confidence_size_ref_points": 2000.0, |
| 314 | "confidence_max": 0.95, | 354 | "confidence_max": 0.95, |
| 315 | "confidence_fallback_max": 0.9 | 355 | "confidence_fallback_max": 0.9 |
| 356 | }, | ||
| 357 | "conic_gate": { | ||
| 358 | "apex_deg_max": 35.0, | ||
| 359 | "apex_deg_min": 5.0, | ||
| 360 | "change_of_curvature_min": 0.06, | ||
| 361 | "enabled": false, | ||
| 362 | "h_max_min_m": 2.5, | ||
| 363 | "h_over_width_max": 12.0, | ||
| 364 | "h_over_width_min": 1.5, | ||
| 365 | "max_hi_intensity_fraction": 0.2, | ||
| 366 | "max_on_road_fraction": 0.6, | ||
| 367 | "min_crown_area_m2": 0.3, | ||
| 368 | "min_decile_fill_fraction": 0.8, | ||
| 369 | "omnivariance_min": 0.1, | ||
| 370 | "taper_slope_max": -0.4, | ||
| 371 | "taper_slope_robust_max": -0.3, | ||
| 372 | "texture_cue_enabled": true | ||
| 373 | }, | ||
| 374 | "conifer_rule": { | ||
| 375 | "enabled": false, | ||
| 376 | "h_max_min_m": 2.0, | ||
| 377 | "h_over_width_max": 15.0, | ||
| 378 | "h_over_width_min": 2.0, | ||
| 379 | "max_apex_ratio": 0.75, | ||
| 380 | "max_crown_base_frac": 0.55, | ||
| 381 | "max_crown_taper": -0.1, | ||
| 382 | "max_hi_intensity_fraction": 0.2, | ||
| 383 | "max_on_road_fraction": 0.6, | ||
| 384 | "max_stem_ratio": 2.2, | ||
| 385 | "max_volumetric_density": 380.0, | ||
| 386 | "min_change_of_curvature": 0.04, | ||
| 387 | "min_crown_area_m2": 0.2, | ||
| 388 | "min_decile_fill_fraction": 0.8, | ||
| 389 | "min_volumetric_density": 140.0 | ||
| 390 | }, | ||
| 391 | "radius": { | ||
| 392 | "crown_lobe_coverage_target": 0.95, | ||
| 393 | "crown_lobe_gap_m": 0.5, | ||
| 394 | "crown_lobe_max_count": 8, | ||
| 395 | "crown_lobe_min_points": 30, | ||
| 396 | "crown_lobe_min_samples": 10, | ||
| 397 | "crown_radius_percentile": 95.0, | ||
| 398 | "debug_cluster_points": false, | ||
| 399 | "fit_bin_m": 0.25, | ||
| 400 | "fit_divergence_factor": 4.0, | ||
| 401 | "fit_min_arc_deg": 60.0, | ||
| 402 | "fit_min_bin_points": 8, | ||
| 403 | "fit_residual_abs_m": 0.03, | ||
| 404 | "fit_residual_frac": 0.35, | ||
| 405 | "pole_radius_max_m": 0.5, | ||
| 406 | "trunk_radius_max_m": 0.8 | ||
| 407 | }, | ||
| 408 | "rail_halfpost": { | ||
| 409 | "band_lat_m": 0.8, | ||
| 410 | "band_z_hi_m": 1.5, | ||
| 411 | "band_z_lo_m": 0.15, | ||
| 412 | "cluster_cell_m": 0.15, | ||
| 413 | "dedupe_m": 1.5, | ||
| 414 | "enabled": false, | ||
| 415 | "ground_cell_m": 2.0, | ||
| 416 | "ground_percentile": 10.0, | ||
| 417 | "h_max_m": 0.8, | ||
| 418 | "h_min_m": 0.2, | ||
| 419 | "max_lateral_m": 0.5, | ||
| 420 | "max_width_m": 0.2, | ||
| 421 | "min_emit_points": 8, | ||
| 422 | "min_points": 15, | ||
| 423 | "min_z_extent_m": 0.1, | ||
| 424 | "models_dir": "", | ||
| 425 | "prime_min_records": 2, | ||
| 426 | "prime_min_sat": 1, | ||
| 427 | "sample_step_m": 0.1, | ||
| 428 | "saturation_intensity": 55000.0 | ||
| 429 | }, | ||
| 430 | "reject_rescue": { | ||
| 431 | "accepted_exclusion_m": 2.0, | ||
| 432 | "enabled": false, | ||
| 433 | "h_max_m": 1.6, | ||
| 434 | "h_min_m": 0.85, | ||
| 435 | "max_core_rms_m": 0.2, | ||
| 436 | "merge_radius_m": 1.0, | ||
| 437 | "min_continuity": 0.8, | ||
| 438 | "min_decile_fill": 0.6, | ||
| 439 | "min_h_over_width": 1.4, | ||
| 440 | "min_points": 30, | ||
| 441 | "min_records": 2, | ||
| 442 | "min_roadctx_sat": 17, | ||
| 443 | "min_verticality": 0.9, | ||
| 444 | "per_segment_cap": 0 | ||
| 445 | }, | ||
| 446 | "tcs_ground": { | ||
| 447 | "cache_dir": "", | ||
| 448 | "cell_m": 0.2, | ||
| 449 | "elev_scalar": 0.0, | ||
| 450 | "enabled": false, | ||
| 451 | "max_elev_diff_m": 0.15, | ||
| 452 | "mechanism": "smrf_numpy", | ||
| 453 | "pit_fill_enabled": true, | ||
| 454 | "slope_threshold": 0.3, | ||
| 455 | "smrf_max_window_m": 6.0 | ||
| 316 | } | 456 | } |
| 317 | } | 457 | } |
| 1 | """Shared fixtures for the vertical-sign detector tests.""" | 1 | """Shared fixtures for the vertical-sign detector tests.""" |
| 2 | 2 | ||
| 3 | import typing | ||
| 3 | from collections.abc import Callable | 4 | from collections.abc import Callable |
| 4 | from typing import Any | 5 | from typing import Any |
| 5 | 6 | ||
| 6 | import pytest | 7 | import pytest |
| 17 | elif annotation is int: | 18 | elif annotation is int: |
| 18 | values[name] = int(field.default) + 1 | 19 | values[name] = int(field.default) + 1 |
| 19 | elif annotation is str: | 20 | elif annotation is str: |
| 20 | values[name] = f"{field.default}_x" | 21 | values[name] = f"{field.default}_x" |
| 22 | elif typing.get_origin(annotation) is tuple: | ||
| 23 | values[name] = [f"{item}_x" for item in field.default] | ||
| 21 | else: | 24 | else: |
| 22 | values[name] = 0.5 | 25 | values[name] = 0.5 |
| 23 | return values | 26 | return values |
| 24 | 27 |
| 278 | "chroma_vegetation", | 278 | "chroma_vegetation", |
| 279 | ) | 279 | ) |
| 280 | 280 | ||
| 281 | 281 | ||
| 282 | def test_overrides_reach_the_dataclass() -> None: | 282 | def test_overrides_reach_the_model() -> None: |
| 283 | config = DetectorConfig.from_mapping( | 283 | config = DetectorConfig.from_mapping( |
| 284 | {"chroma_vegetation": {"enabled": True, "exg_min": 0.33}} | 284 | {"chroma_vegetation": {"enabled": True, "exg_min": 0.33}} |
| 285 | ) | 285 | ) |
| 286 | assert config.chroma_veg_enabled is True | 286 | assert config.chroma_veg_enabled is True |
| 95 | 95 | ||
| 96 | 96 | ||
| 97 | def test_the_packaged_defaults_round_trip() -> None: | 97 | def test_the_packaged_defaults_round_trip() -> None: |
| 98 | packaged = load_default_config() | 98 | packaged = load_default_config() |
| 99 | assert json.dumps(packaged) # it is a plain JSON document | 99 | assert json.loads(json.dumps(packaged)) == packaged # plain JSON types only |
| 100 | assert DetectorConfig.from_mapping(packaged) == DetectorConfig.load() | 100 | assert DetectorConfig.from_mapping(packaged) == DetectorConfig.load() |
| 101 | 101 | ||
| 102 | 102 | ||
| 103 | def test_the_packaged_defaults_equal_the_flat_defaults() -> None: | 103 | def test_the_packaged_defaults_equal_the_flat_defaults() -> None: |
| 124 | except ValueError as exc: | 124 | except ValueError as exc: |
| 125 | assert "cluster_eps" in str(exc) | 125 | assert "cluster_eps" in str(exc) |
| 126 | else: # pragma: no cover - the failure the test exists to catch | 126 | else: # pragma: no cover - the failure the test exists to catch |
| 127 | raise AssertionError("a misspelled field name was accepted") | 127 | raise AssertionError("a misspelled field name was accepted") |
| 128 | |||
| 129 | |||
| 130 | def test_the_packaged_json_declares_exactly_the_model_keys() -> None: | ||
| 131 | """The packaged JSON and the model must not drift apart in SHAPE either. | ||
| 132 | |||
| 133 | ``load_verticalsigns_config`` returns the validated model dump, so a key | ||
| 134 | the model declares but the JSON omits would be injected into the returned | ||
| 135 | document (and a JSON key the model lacks would be rejected outright). | ||
| 136 | """ | ||
| 137 | packaged = json.loads( | ||
| 138 | (CONFIG_PY.parent / "verticalsigns.default.json").read_text(encoding="utf-8") | ||
| 139 | ) | ||
| 140 | model = _config_model.VerticalSignsConfig().model_dump(mode="json") | ||
| 141 | assert {s: sorted(keys) for s, keys in packaged.items()} == { | ||
| 142 | s: sorted(keys) for s, keys in model.items() | ||
| 143 | } |
model_dump(mode='json')for run-stats output, packaged JSON completed to the full model (125 keys added, 0 values changed),with_overridesre-validates.