Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:33:47+02:00
Commit #14 ยท 5 snippets
pyproject.toml | 5 +- .../_config.py | 198 +++++++++------------ tests/test_config_model.py | 64 +++++++ 3 files changed, 151 insertions(+), 116 deletions(-)
| 1 | """Load, merge and validate trajectory-filter config from packaged JSON. | ||
| 2 | |||
| 3 | The schema is `TrajectoryFilterConfig`. To add a key, add a field on that | ||
| 4 | model and a matching entry in ``trajectory_filter.default.json``. | ||
| 5 | """ | ||
| 6 | |||
| 1 | from __future__ import annotations | 7 | from __future__ import annotations |
| 2 | 8 | ||
| 3 | import json | 9 | import logging |
| 4 | from importlib import resources | ||
| 5 | from pathlib import Path | 10 | from pathlib import Path |
| 6 | from typing import Any | 11 | from typing import Any |
| 7 | 12 | ||
| 8 | ALLOWED_TRAJECTORY_FILTER_CONFIG_KEYS = frozenset( | 13 | from iolabs.common import config_loader |
| 9 | { | 14 | |
| 10 | "spline_pcd_extension", | 15 | logger = logging.getLogger(__name__) |
| 11 | "fit_segment_length", | 16 | |
| 12 | "spline_smoothing", | 17 | _PACKAGE = "iolabs_point_cloud_trajectory_filter" |
| 13 | "resample_points", | 18 | _DEFAULT_FILENAME = "trajectory_filter.default.json" |
| 14 | "loop_chord_arc_ratio_min", | 19 | _CONTEXT = "trajectory-filter config" |
| 15 | "loop_pca_span_ratio_min", | 20 | |
| 16 | "min_coverage_increment_m", | 21 | |
| 17 | "max_connectivity_gap_m", | 22 | class TrajectoryFilterConfig(config_loader.ConfigModel): |
| 18 | "contributed_endpoint_window_m", | 23 | """Validated trajectory-filter settings; field names match the default JSON.""" |
| 19 | "loop_score_weight", | 24 | |
| 20 | "residual_coverage_distance_m", | 25 | spline_pcd_extension: str = "_run1_spline_points" |
| 21 | "min_residual_fragment_length_m", | 26 | fit_segment_length: float = 2.0 |
| 22 | "min_residual_point_count", | 27 | spline_smoothing: float = 0.0 |
| 23 | "min_branch_length_m", | 28 | resample_points: int = 400 |
| 24 | "max_branch_count", | 29 | loop_chord_arc_ratio_min: float = 0.85 |
| 25 | "branch_assignment_distance_m", | 30 | loop_pca_span_ratio_min: float = 0.6 |
| 26 | "branch_assignment_min_near_fraction", | 31 | min_coverage_increment_m: float = 50.0 |
| 27 | "branch_assignment_min_near_length_m", | 32 | max_connectivity_gap_m: float = 35.0 |
| 28 | "branch_assignment_min_near_sample_count", | 33 | contributed_endpoint_window_m: float = 10.0 |
| 29 | "branch_assignment_force_spine_stems", | 34 | loop_score_weight: float = 0.85 |
| 30 | "branch_assignment_debug_ply_filename_template", | 35 | residual_coverage_distance_m: float = 200.0 |
| 31 | "manifest_filename", | 36 | min_residual_fragment_length_m: float = 25.0 |
| 32 | "save_debug_artifacts", | 37 | min_residual_point_count: int = 10 |
| 33 | "save_debug_ply", | 38 | min_branch_length_m: float = 1000.0 |
| 34 | "debug_ply_filename", | 39 | max_branch_count: int = 20 |
| 35 | "clipped_debug_ply_filename", | 40 | branch_assignment_distance_m: float = 75.0 |
| 36 | "clipped_debug_interval_margin_m", | 41 | branch_assignment_min_near_fraction: float = 0.1 |
| 37 | "candidate_debug_ply_filename", | 42 | branch_assignment_min_near_length_m: float = 50.0 |
| 38 | "debug_json_filename", | 43 | branch_assignment_min_near_sample_count: int = 4 |
| 39 | } | 44 | branch_assignment_force_spine_stems: bool = True |
| 40 | ) | 45 | branch_assignment_debug_ply_filename_template: str = ( |
| 41 | 46 | "run2_branch_{branch_index:03d}_assigned_sources.ply" | |
| 42 | |||
| 43 | class TrajectoryFilterConfigError(ValueError): | ||
| 44 | """Raised when trajectory-filter config contains unsupported keys.""" | ||
| 45 | |||
| 46 | |||
| 47 | def _default_config_path() -> Path: | ||
| 48 | if __package__ in {None, ""}: | ||
| 49 | return Path(__file__).resolve().with_name("trajectory_filter.default.json") | ||
| 50 | return Path( | ||
| 51 | str(resources.files(__package__).joinpath("trajectory_filter.default.json")) | ||
| 52 | ) | 47 | ) |
| 48 | manifest_filename: str = "run2_spine_splines.json" | ||
| 49 | save_debug_artifacts: bool = False | ||
| 50 | save_debug_ply: bool = False | ||
| 51 | debug_ply_filename: str = "run2_spine_splines.ply" | ||
| 52 | clipped_debug_ply_filename: str = "run2_spine_splines_clipped.ply" | ||
| 53 | clipped_debug_interval_margin_m: float = 10.0 | ||
| 54 | candidate_debug_ply_filename: str = "run2_spine_candidates.ply" | ||
| 55 | debug_json_filename: str = "run2_spine_debug.json" | ||
| 53 | 56 | ||
| 54 | 57 | ||
| 55 | def _deep_merge_dicts( | 58 | class TrajectoryFilterConfigError(config_loader.ConfigError): |
| 56 | base: dict[str, Any], | 59 | """Raised when trajectory-filter config contains unsupported keys.""" |
| 57 | overrides: dict[str, Any], | ||
| 58 | ) -> dict[str, Any]: | ||
| 59 | for key, value in overrides.items(): | ||
| 60 | if isinstance(value, dict) and isinstance(base.get(key), dict): | ||
| 61 | base[key] = _deep_merge_dicts(dict(base[key]), value) | ||
| 62 | else: | ||
| 63 | base[key] = value | ||
| 64 | return base | ||
| 65 | |||
| 66 | |||
| 67 | def _validate_trajectory_filter_config_keys(config: dict[str, Any]) -> None: | ||
| 68 | unknown_keys = sorted(set(config) - ALLOWED_TRAJECTORY_FILTER_CONFIG_KEYS) | ||
| 69 | if not unknown_keys: | ||
| 70 | return | ||
| 71 | |||
| 72 | allowed_keys = ", ".join(sorted(ALLOWED_TRAJECTORY_FILTER_CONFIG_KEYS)) | ||
| 73 | raise TrajectoryFilterConfigError( | ||
| 74 | "Unknown trajectory-filter config key(s): " | ||
| 75 | f"{', '.join(unknown_keys)}. " | ||
| 76 | f"Allowed keys: {allowed_keys}" | ||
| 77 | ) | ||
| 78 | 60 | ||
| 79 | 61 | ||
| 80 | def normalize_trajectory_filter_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 62 | def normalize_trajectory_filter_config(raw_config: dict[str, Any]) -> dict[str, Any]: |
| 81 | config = dict(raw_config) | 63 | """Fill defaults, reject unknown keys, and return a plain config dict.""" |
| 82 | _validate_trajectory_filter_config_keys(config) | 64 | return config_loader.validate_config( |
| 83 | config.setdefault("spline_pcd_extension", "_run1_spline_points") | 65 | TrajectoryFilterConfig, |
| 84 | config.setdefault("fit_segment_length", 2.0) | 66 | raw_config, |
| 85 | config.setdefault("spline_smoothing", 0.0) | 67 | context=_CONTEXT, |
| 86 | config.setdefault("resample_points", 400) | 68 | error_cls=TrajectoryFilterConfigError, |
| 87 | config.setdefault("loop_chord_arc_ratio_min", 0.85) | 69 | ).model_dump() |
| 88 | config.setdefault("loop_pca_span_ratio_min", 0.6) | ||
| 89 | config.setdefault("min_coverage_increment_m", 50.0) | ||
| 90 | config.setdefault("max_connectivity_gap_m", 35.0) | ||
| 91 | config.setdefault("contributed_endpoint_window_m", 10.0) | ||
| 92 | config.setdefault("loop_score_weight", 0.85) | ||
| 93 | config.setdefault("residual_coverage_distance_m", 200.0) | ||
| 94 | config.setdefault("min_residual_fragment_length_m", 25.0) | ||
| 95 | config.setdefault("min_residual_point_count", 10) | ||
| 96 | config.setdefault("min_branch_length_m", 1000.0) | ||
| 97 | config.setdefault("max_branch_count", 20) | ||
| 98 | config.setdefault("branch_assignment_distance_m", 75.0) | ||
| 99 | config.setdefault("branch_assignment_min_near_fraction", 0.10) | ||
| 100 | config.setdefault("branch_assignment_min_near_length_m", 50.0) | ||
| 101 | config.setdefault("branch_assignment_min_near_sample_count", 4) | ||
| 102 | config.setdefault("branch_assignment_force_spine_stems", True) | ||
| 103 | config.setdefault( | ||
| 104 | "branch_assignment_debug_ply_filename_template", | ||
| 105 | "run2_branch_{branch_index:03d}_assigned_sources.ply", | ||
| 106 | ) | ||
| 107 | config.setdefault("manifest_filename", "run2_spine_splines.json") | ||
| 108 | config.setdefault("save_debug_artifacts", False) | ||
| 109 | config.setdefault("save_debug_ply", False) | ||
| 110 | config.setdefault("debug_ply_filename", "run2_spine_splines.ply") | ||
| 111 | config.setdefault("clipped_debug_ply_filename", "run2_spine_splines_clipped.ply") | ||
| 112 | config.setdefault("clipped_debug_interval_margin_m", 10.0) | ||
| 113 | config.setdefault("candidate_debug_ply_filename", "run2_spine_candidates.ply") | ||
| 114 | config.setdefault("debug_json_filename", "run2_spine_debug.json") | ||
| 115 | return config | ||
| 116 | 70 | ||
| 117 | 71 | ||
| 118 | def load_trajectory_filter_config( | 72 | def load_trajectory_filter_config( |
| 119 | config_path: str | Path | None = None, | 73 | config_path: str | Path | None = None, |
| 120 | ) -> dict[str, Any]: | 74 | ) -> dict[str, Any]: |
| 121 | resolved_path = ( | 75 | """Load packaged (or *config_path*) defaults and return a plain config dict.""" |
| 122 | Path(config_path) if config_path is not None else _default_config_path() | 76 | return _load_model(config_path=config_path).model_dump() |
| 123 | ) | ||
| 124 | with resolved_path.open("r", encoding="utf-8") as handle: | ||
| 125 | raw_config: dict[str, Any] = json.load(handle) | ||
| 126 | return normalize_trajectory_filter_config(raw_config) | ||
| 127 | 77 | ||
| 128 | 78 | ||
| 129 | def build_trajectory_filter_config( | 79 | def build_trajectory_filter_config( |
| 130 | *, | 80 | *, |
| 131 | overrides: dict[str, Any] | None = None, | 81 | overrides: dict[str, Any] | None = None, |
| 132 | config_path: str | Path | None = None, | 82 | config_path: str | Path | None = None, |
| 133 | ) -> dict[str, Any]: | 83 | ) -> dict[str, Any]: |
| 134 | config = load_trajectory_filter_config(config_path) | 84 | """Load defaults, deep-merge *overrides*, and return a plain config dict.""" |
| 135 | if overrides: | 85 | return _load_model(overrides=overrides, config_path=config_path).model_dump() |
| 136 | config = _deep_merge_dicts(config, dict(overrides)) | 86 | |
| 137 | return normalize_trajectory_filter_config(config) | 87 | |
| 88 | def _load_model( | ||
| 89 | *, | ||
| 90 | overrides: dict[str, Any] | None = None, | ||
| 91 | config_path: str | Path | None = None, | ||
| 92 | ) -> TrajectoryFilterConfig: | ||
| 93 | """Load, merge and validate into `TrajectoryFilterConfig`.""" | ||
| 94 | logger.debug( | ||
| 95 | "Loading %s from %s", | ||
| 96 | _CONTEXT, | ||
| 97 | config_path or f"{_PACKAGE}:{_DEFAULT_FILENAME}", | ||
| 98 | ) | ||
| 99 | return config_loader.load_config( | ||
| 100 | TrajectoryFilterConfig, | ||
| 101 | package=_PACKAGE, | ||
| 102 | filename=_DEFAULT_FILENAME, | ||
| 103 | overrides=overrides, | ||
| 104 | config_path=config_path, | ||
| 105 | context=_CONTEXT, | ||
| 106 | error_cls=TrajectoryFilterConfigError, | ||
| 107 | ) |
| 1 | """Tests for the pydantic config layer of the trajectory filter.""" | ||
| 2 | |||
| 3 | from __future__ import annotations | ||
| 4 | |||
| 5 | import json | ||
| 6 | from pathlib import Path | ||
| 7 | |||
| 8 | import pytest | ||
| 9 | from iolabs.common import config_loader | ||
| 10 | |||
| 11 | from iolabs_point_cloud_trajectory_filter import _config | ||
| 12 | |||
| 13 | _DEFAULT_JSON = ( | ||
| 14 | Path(_config.__file__).resolve().with_name("trajectory_filter.default.json") | ||
| 15 | ) | ||
| 16 | |||
| 17 | |||
| 18 | def test_error_class_is_config_error() -> None: | ||
| 19 | assert issubclass(_config.TrajectoryFilterConfigError, config_loader.ConfigError) | ||
| 20 | assert issubclass(_config.TrajectoryFilterConfigError, ValueError) | ||
| 21 | |||
| 22 | |||
| 23 | def test_packaged_json_matches_model_defaults() -> None: | ||
| 24 | packaged = json.loads(_DEFAULT_JSON.read_text(encoding="utf-8")) | ||
| 25 | assert packaged == _config.TrajectoryFilterConfig().model_dump() | ||
| 26 | assert _config.load_trajectory_filter_config() == packaged | ||
| 27 | |||
| 28 | |||
| 29 | def test_build_merges_set_overrides_and_coerces_strings() -> None: | ||
| 30 | overrides = config_loader.parse_set_overrides( | ||
| 31 | [ | ||
| 32 | "save_debug_ply=true", | ||
| 33 | "max_branch_count=3", | ||
| 34 | "loop_score_weight=0.5", | ||
| 35 | ] | ||
| 36 | ) | ||
| 37 | config = _config.build_trajectory_filter_config(overrides=overrides) | ||
| 38 | assert config["save_debug_ply"] is True | ||
| 39 | assert config["max_branch_count"] == 3 | ||
| 40 | assert config["loop_score_weight"] == 0.5 | ||
| 41 | assert config["manifest_filename"] == "run2_spine_splines.json" | ||
| 42 | |||
| 43 | |||
| 44 | def test_build_rejects_unknown_override_key() -> None: | ||
| 45 | with pytest.raises( | ||
| 46 | _config.TrajectoryFilterConfigError, | ||
| 47 | match="Unknown trajectory-filter config key", | ||
| 48 | ): | ||
| 49 | _config.build_trajectory_filter_config(overrides={"typo_branch_count": 1}) | ||
| 50 | |||
| 51 | |||
| 52 | def test_config_path_replaces_packaged_defaults(tmp_path: Path) -> None: | ||
| 53 | path = tmp_path / "custom.json" | ||
| 54 | path.write_text(json.dumps({"max_branch_count": 7}), encoding="utf-8") | ||
| 55 | config = _config.load_trajectory_filter_config(path) | ||
| 56 | assert config["max_branch_count"] == 7 | ||
| 57 | assert config["save_debug_ply"] is False | ||
| 58 | |||
| 59 | |||
| 60 | def test_bad_values_are_rejected() -> None: | ||
| 61 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 62 | _config.normalize_trajectory_filter_config({"max_branch_count": "not-an-int"}) | ||
| 63 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 64 | _config.normalize_trajectory_filter_config({"save_debug_ply": "flase"}) | ||
| 0 |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-point-cloud-trajectory-filter" | 2 | name = "iolabs-point-cloud-trajectory-filter" |
| 3 | version = "0.2.2" | 3 | version = "0.2.3" |
| 4 | description = "Spine-spline selection from per-LAS trajectory point clouds" | 4 | description = "Spine-spline selection from per-LAS trajectory 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.11.0", | 8 | "scipy>=1.11.0", |
| 9 | "open3d>=0.19.0", | 9 | "open3d>=0.19.0", |
| 10 | "iolabs-geometry-geometry", | 10 | "iolabs-geometry-geometry", |
| 11 | "iolabs-common", | 11 | "iolabs-common>=0.8.0", |
| 12 | "iolabs-logstash>=0.4.0", | 12 | "iolabs-logstash>=0.4.0", |
| 13 | "pydantic>=2.7", | ||
| 13 | ] | 14 | ] |
| 14 | 15 | ||
| 15 | [project.optional-dependencies] | 16 | [project.optional-dependencies] |
| 16 | dev = [ | 17 | dev = [ |
| 1 | """Load, merge and validate trajectory-filter config from packaged JSON. | ||
| 2 | |||
| 3 | The schema is `TrajectoryFilterConfig`. To add a key, add a field on that | ||
| 4 | model and a matching entry in ``trajectory_filter.default.json``. | ||
| 5 | """ | ||
| 6 | |||
| 1 | from __future__ import annotations | 7 | from __future__ import annotations |
| 2 | 8 | ||
| 3 | import json | 9 | import logging |
| 4 | from importlib import resources | ||
| 5 | from pathlib import Path | 10 | from pathlib import Path |
| 6 | from typing import Any | 11 | from typing import Any |
| 7 | 12 | ||
| 8 | ALLOWED_TRAJECTORY_FILTER_CONFIG_KEYS = frozenset( | 13 | from iolabs.common import config_loader |
| 9 | { | 14 | |
| 10 | "spline_pcd_extension", | 15 | logger = logging.getLogger(__name__) |
| 11 | "fit_segment_length", | 16 | |
| 12 | "spline_smoothing", | 17 | _PACKAGE = "iolabs_point_cloud_trajectory_filter" |
| 13 | "resample_points", | 18 | _DEFAULT_FILENAME = "trajectory_filter.default.json" |
| 14 | "loop_chord_arc_ratio_min", | 19 | _CONTEXT = "trajectory-filter config" |
| 15 | "loop_pca_span_ratio_min", | 20 | |
| 16 | "min_coverage_increment_m", | 21 | |
| 17 | "max_connectivity_gap_m", | 22 | class TrajectoryFilterConfig(config_loader.ConfigModel): |
| 18 | "contributed_endpoint_window_m", | 23 | """Validated trajectory-filter settings; field names match the default JSON.""" |
| 19 | "loop_score_weight", | 24 | |
| 20 | "residual_coverage_distance_m", | 25 | spline_pcd_extension: str = "_run1_spline_points" |
| 21 | "min_residual_fragment_length_m", | 26 | fit_segment_length: float = 2.0 |
| 22 | "min_residual_point_count", | 27 | spline_smoothing: float = 0.0 |
| 23 | "min_branch_length_m", | 28 | resample_points: int = 400 |
| 24 | "max_branch_count", | 29 | loop_chord_arc_ratio_min: float = 0.85 |
| 25 | "branch_assignment_distance_m", | 30 | loop_pca_span_ratio_min: float = 0.6 |
| 26 | "branch_assignment_min_near_fraction", | 31 | min_coverage_increment_m: float = 50.0 |
| 27 | "branch_assignment_min_near_length_m", | 32 | max_connectivity_gap_m: float = 35.0 |
| 28 | "branch_assignment_min_near_sample_count", | 33 | contributed_endpoint_window_m: float = 10.0 |
| 29 | "branch_assignment_force_spine_stems", | 34 | loop_score_weight: float = 0.85 |
| 30 | "branch_assignment_debug_ply_filename_template", | 35 | residual_coverage_distance_m: float = 200.0 |
| 31 | "manifest_filename", | 36 | min_residual_fragment_length_m: float = 25.0 |
| 32 | "save_debug_artifacts", | 37 | min_residual_point_count: int = 10 |
| 33 | "save_debug_ply", | 38 | min_branch_length_m: float = 1000.0 |
| 34 | "debug_ply_filename", | 39 | max_branch_count: int = 20 |
| 35 | "clipped_debug_ply_filename", | 40 | branch_assignment_distance_m: float = 75.0 |
| 36 | "clipped_debug_interval_margin_m", | 41 | branch_assignment_min_near_fraction: float = 0.1 |
| 37 | "candidate_debug_ply_filename", | 42 | branch_assignment_min_near_length_m: float = 50.0 |
| 38 | "debug_json_filename", | 43 | branch_assignment_min_near_sample_count: int = 4 |
| 39 | } | 44 | branch_assignment_force_spine_stems: bool = True |
| 40 | ) | 45 | branch_assignment_debug_ply_filename_template: str = ( |
| 41 | 46 | "run2_branch_{branch_index:03d}_assigned_sources.ply" | |
| 42 | |||
| 43 | class TrajectoryFilterConfigError(ValueError): | ||
| 44 | """Raised when trajectory-filter config contains unsupported keys.""" | ||
| 45 | |||
| 46 | |||
| 47 | def _default_config_path() -> Path: | ||
| 48 | if __package__ in {None, ""}: | ||
| 49 | return Path(__file__).resolve().with_name("trajectory_filter.default.json") | ||
| 50 | return Path( | ||
| 51 | str(resources.files(__package__).joinpath("trajectory_filter.default.json")) | ||
| 52 | ) | 47 | ) |
| 48 | manifest_filename: str = "run2_spine_splines.json" | ||
| 49 | save_debug_artifacts: bool = False | ||
| 50 | save_debug_ply: bool = False | ||
| 51 | debug_ply_filename: str = "run2_spine_splines.ply" | ||
| 52 | clipped_debug_ply_filename: str = "run2_spine_splines_clipped.ply" | ||
| 53 | clipped_debug_interval_margin_m: float = 10.0 | ||
| 54 | candidate_debug_ply_filename: str = "run2_spine_candidates.ply" | ||
| 55 | debug_json_filename: str = "run2_spine_debug.json" | ||
| 53 | 56 | ||
| 54 | 57 | ||
| 55 | def _deep_merge_dicts( | 58 | class TrajectoryFilterConfigError(config_loader.ConfigError): |
| 56 | base: dict[str, Any], | 59 | """Raised when trajectory-filter config contains unsupported keys.""" |
| 57 | overrides: dict[str, Any], | ||
| 58 | ) -> dict[str, Any]: | ||
| 59 | for key, value in overrides.items(): | ||
| 60 | if isinstance(value, dict) and isinstance(base.get(key), dict): | ||
| 61 | base[key] = _deep_merge_dicts(dict(base[key]), value) | ||
| 62 | else: | ||
| 63 | base[key] = value | ||
| 64 | return base | ||
| 65 | |||
| 66 | |||
| 67 | def _validate_trajectory_filter_config_keys(config: dict[str, Any]) -> None: | ||
| 68 | unknown_keys = sorted(set(config) - ALLOWED_TRAJECTORY_FILTER_CONFIG_KEYS) | ||
| 69 | if not unknown_keys: | ||
| 70 | return | ||
| 71 | |||
| 72 | allowed_keys = ", ".join(sorted(ALLOWED_TRAJECTORY_FILTER_CONFIG_KEYS)) | ||
| 73 | raise TrajectoryFilterConfigError( | ||
| 74 | "Unknown trajectory-filter config key(s): " | ||
| 75 | f"{', '.join(unknown_keys)}. " | ||
| 76 | f"Allowed keys: {allowed_keys}" | ||
| 77 | ) | ||
| 78 | 60 | ||
| 79 | 61 | ||
| 80 | def normalize_trajectory_filter_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 62 | def normalize_trajectory_filter_config(raw_config: dict[str, Any]) -> dict[str, Any]: |
| 81 | config = dict(raw_config) | 63 | """Fill defaults, reject unknown keys, and return a plain config dict.""" |
| 82 | _validate_trajectory_filter_config_keys(config) | 64 | return config_loader.validate_config( |
| 83 | config.setdefault("spline_pcd_extension", "_run1_spline_points") | 65 | TrajectoryFilterConfig, |
| 84 | config.setdefault("fit_segment_length", 2.0) | 66 | raw_config, |
| 85 | config.setdefault("spline_smoothing", 0.0) | 67 | context=_CONTEXT, |
| 86 | config.setdefault("resample_points", 400) | 68 | error_cls=TrajectoryFilterConfigError, |
| 87 | config.setdefault("loop_chord_arc_ratio_min", 0.85) | 69 | ).model_dump() |
| 88 | config.setdefault("loop_pca_span_ratio_min", 0.6) | ||
| 89 | config.setdefault("min_coverage_increment_m", 50.0) | ||
| 90 | config.setdefault("max_connectivity_gap_m", 35.0) | ||
| 91 | config.setdefault("contributed_endpoint_window_m", 10.0) | ||
| 92 | config.setdefault("loop_score_weight", 0.85) | ||
| 93 | config.setdefault("residual_coverage_distance_m", 200.0) | ||
| 94 | config.setdefault("min_residual_fragment_length_m", 25.0) | ||
| 95 | config.setdefault("min_residual_point_count", 10) | ||
| 96 | config.setdefault("min_branch_length_m", 1000.0) | ||
| 97 | config.setdefault("max_branch_count", 20) | ||
| 98 | config.setdefault("branch_assignment_distance_m", 75.0) | ||
| 99 | config.setdefault("branch_assignment_min_near_fraction", 0.10) | ||
| 100 | config.setdefault("branch_assignment_min_near_length_m", 50.0) | ||
| 101 | config.setdefault("branch_assignment_min_near_sample_count", 4) | ||
| 102 | config.setdefault("branch_assignment_force_spine_stems", True) | ||
| 103 | config.setdefault( | ||
| 104 | "branch_assignment_debug_ply_filename_template", | ||
| 105 | "run2_branch_{branch_index:03d}_assigned_sources.ply", | ||
| 106 | ) | ||
| 107 | config.setdefault("manifest_filename", "run2_spine_splines.json") | ||
| 108 | config.setdefault("save_debug_artifacts", False) | ||
| 109 | config.setdefault("save_debug_ply", False) | ||
| 110 | config.setdefault("debug_ply_filename", "run2_spine_splines.ply") | ||
| 111 | config.setdefault("clipped_debug_ply_filename", "run2_spine_splines_clipped.ply") | ||
| 112 | config.setdefault("clipped_debug_interval_margin_m", 10.0) | ||
| 113 | config.setdefault("candidate_debug_ply_filename", "run2_spine_candidates.ply") | ||
| 114 | config.setdefault("debug_json_filename", "run2_spine_debug.json") | ||
| 115 | return config | ||
| 116 | 70 | ||
| 117 | 71 | ||
| 118 | def load_trajectory_filter_config( | 72 | def load_trajectory_filter_config( |
| 119 | config_path: str | Path | None = None, | 73 | config_path: str | Path | None = None, |
| 120 | ) -> dict[str, Any]: | 74 | ) -> dict[str, Any]: |
| 121 | resolved_path = ( | 75 | """Load packaged (or *config_path*) defaults and return a plain config dict.""" |
| 122 | Path(config_path) if config_path is not None else _default_config_path() | 76 | return _load_model(config_path=config_path).model_dump() |
| 123 | ) | ||
| 124 | with resolved_path.open("r", encoding="utf-8") as handle: | ||
| 125 | raw_config: dict[str, Any] = json.load(handle) | ||
| 126 | return normalize_trajectory_filter_config(raw_config) | ||
| 127 | 77 | ||
| 128 | 78 | ||
| 129 | def build_trajectory_filter_config( | 79 | def build_trajectory_filter_config( |
| 130 | *, | 80 | *, |
| 131 | overrides: dict[str, Any] | None = None, | 81 | overrides: dict[str, Any] | None = None, |
| 132 | config_path: str | Path | None = None, | 82 | config_path: str | Path | None = None, |
| 133 | ) -> dict[str, Any]: | 83 | ) -> dict[str, Any]: |
| 134 | config = load_trajectory_filter_config(config_path) | 84 | """Load defaults, deep-merge *overrides*, and return a plain config dict.""" |
| 135 | if overrides: | 85 | return _load_model(overrides=overrides, config_path=config_path).model_dump() |
| 136 | config = _deep_merge_dicts(config, dict(overrides)) | 86 | |
| 137 | return normalize_trajectory_filter_config(config) | 87 | |
| 88 | def _load_model( | ||
| 89 | *, | ||
| 90 | overrides: dict[str, Any] | None = None, | ||
| 91 | config_path: str | Path | None = None, | ||
| 92 | ) -> TrajectoryFilterConfig: | ||
| 93 | """Load, merge and validate into `TrajectoryFilterConfig`.""" | ||
| 94 | logger.debug( | ||
| 95 | "Loading %s from %s", | ||
| 96 | _CONTEXT, | ||
| 97 | config_path or f"{_PACKAGE}:{_DEFAULT_FILENAME}", | ||
| 98 | ) | ||
| 99 | return config_loader.load_config( | ||
| 100 | TrajectoryFilterConfig, | ||
| 101 | package=_PACKAGE, | ||
| 102 | filename=_DEFAULT_FILENAME, | ||
| 103 | overrides=overrides, | ||
| 104 | config_path=config_path, | ||
| 105 | context=_CONTEXT, | ||
| 106 | error_cls=TrajectoryFilterConfigError, | ||
| 107 | ) |
| 1 | """Tests for the pydantic config layer of the trajectory filter.""" | ||
| 2 | |||
| 3 | from __future__ import annotations | ||
| 4 | |||
| 5 | import json | ||
| 6 | from pathlib import Path | ||
| 7 | |||
| 8 | import pytest | ||
| 9 | from iolabs.common import config_loader | ||
| 10 | |||
| 11 | from iolabs_point_cloud_trajectory_filter import _config | ||
| 12 | |||
| 13 | _DEFAULT_JSON = ( | ||
| 14 | Path(_config.__file__).resolve().with_name("trajectory_filter.default.json") | ||
| 15 | ) | ||
| 16 | |||
| 17 | |||
| 18 | def test_error_class_is_config_error() -> None: | ||
| 19 | assert issubclass(_config.TrajectoryFilterConfigError, config_loader.ConfigError) | ||
| 20 | assert issubclass(_config.TrajectoryFilterConfigError, ValueError) | ||
| 21 | |||
| 22 | |||
| 23 | def test_packaged_json_matches_model_defaults() -> None: | ||
| 24 | packaged = json.loads(_DEFAULT_JSON.read_text(encoding="utf-8")) | ||
| 25 | assert packaged == _config.TrajectoryFilterConfig().model_dump() | ||
| 26 | assert _config.load_trajectory_filter_config() == packaged | ||
| 27 | |||
| 28 | |||
| 29 | def test_build_merges_set_overrides_and_coerces_strings() -> None: | ||
| 30 | overrides = config_loader.parse_set_overrides( | ||
| 31 | [ | ||
| 32 | "save_debug_ply=true", | ||
| 33 | "max_branch_count=3", | ||
| 34 | "loop_score_weight=0.5", | ||
| 35 | ] | ||
| 36 | ) | ||
| 37 | config = _config.build_trajectory_filter_config(overrides=overrides) | ||
| 38 | assert config["save_debug_ply"] is True | ||
| 39 | assert config["max_branch_count"] == 3 | ||
| 40 | assert config["loop_score_weight"] == 0.5 | ||
| 41 | assert config["manifest_filename"] == "run2_spine_splines.json" | ||
| 42 | |||
| 43 | |||
| 44 | def test_build_rejects_unknown_override_key() -> None: | ||
| 45 | with pytest.raises( | ||
| 46 | _config.TrajectoryFilterConfigError, | ||
| 47 | match="Unknown trajectory-filter config key", | ||
| 48 | ): | ||
| 49 | _config.build_trajectory_filter_config(overrides={"typo_branch_count": 1}) | ||
| 50 | |||
| 51 | |||
| 52 | def test_config_path_replaces_packaged_defaults(tmp_path: Path) -> None: | ||
| 53 | path = tmp_path / "custom.json" | ||
| 54 | path.write_text(json.dumps({"max_branch_count": 7}), encoding="utf-8") | ||
| 55 | config = _config.load_trajectory_filter_config(path) | ||
| 56 | assert config["max_branch_count"] == 7 | ||
| 57 | assert config["save_debug_ply"] is False | ||
| 58 | |||
| 59 | |||
| 60 | def test_bad_values_are_rejected() -> None: | ||
| 61 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 62 | _config.normalize_trajectory_filter_config({"max_branch_count": "not-an-int"}) | ||
| 63 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 64 | _config.normalize_trajectory_filter_config({"save_debug_ply": "flase"}) | ||
| 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.