Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:45:47+02:00
Commit #11 · 21 snippets
README.md | 4 + pyproject.toml | 5 +- .../__init__.py | 2 + .../_config.py | 142 +++++++++++---------- .../trajectories.default.json | 3 + .../trajectory_finder.py | 3 +- tests/conftest.py | 40 ++++++ tests/test_config.py | 87 +++++++++++++ tests/test_trajectory_finder_window.py | 38 +----- 9 files changed, 221 insertions(+), 103 deletions(-)
| 1 | from __future__ import annotations | 1 | from __future__ import annotations |
| 2 | 2 | ||
| 3 | import json | 3 | import logging |
| 4 | from importlib import resources | ||
| 5 | from pathlib import Path | 4 | from pathlib import Path |
| 6 | from typing import Any | 5 | from typing import Any |
| 7 | 6 | ||
| 8 | ALLOWED_TRAJECTORY_CONFIG_KEYS = frozenset( | 7 | from iolabs.common import config_loader |
| 9 | { | ||
| 10 | "save_npz", | ||
| 11 | "save_ply", | ||
| 12 | "only_within_80_degrees", | ||
| 13 | "pcd_extension", | ||
| 14 | "spline_pcd_extension", | ||
| 15 | "trajectory_max_angle", | ||
| 16 | "trajectory_first_downsample", | ||
| 17 | "trajectory_second_downsample", | ||
| 18 | "trajectory_outlier_removal_nb_points", | ||
| 19 | "trajectory_outlier_removal_search_radius", | ||
| 20 | } | ||
| 21 | ) | ||
| 22 | 8 | ||
| 9 | logger = logging.getLogger(__name__) | ||
| 23 | 10 | ||
| 24 | class TrajectoryConfigError(ValueError): | 11 | _PACKAGE = "iolabs_point_cloud_trajectory_detect" |
| 25 | """Raised when trajectory config contains unsupported keys.""" | 12 | _DEFAULT_FILENAME = "trajectories.default.json" |
| 26 | 13 | ||
| 27 | 14 | ||
| 28 | def _default_config_path() -> Path: | 15 | class TrajectoryConfig(config_loader.ConfigModel): |
| 29 | if __package__ in {None, ""}: | 16 | """Packaged trajectory-detect configuration.""" |
| 30 | return Path(__file__).resolve().with_name("trajectories.default.json") | ||
| 31 | return Path(str(resources.files(__package__).joinpath("trajectories.default.json"))) | ||
| 32 | 17 | ||
| 18 | save_npz: bool = False | ||
| 19 | save_ply: bool = False | ||
| 20 | only_within_80_degrees: bool = True | ||
| 21 | pcd_extension: str = "" | ||
| 22 | spline_pcd_extension: str = "_run1_spline_points" | ||
| 23 | trajectory_max_angle: float = 5.0 | ||
| 24 | trajectory_first_downsample: float = 0.01 | ||
| 25 | trajectory_second_downsample: float = 0.005 | ||
| 26 | trajectory_outlier_removal_nb_points: int = 7 | ||
| 27 | trajectory_outlier_removal_search_radius: float = 1.0 | ||
| 33 | 28 | ||
| 34 | def _deep_merge_dicts( | 29 | |
| 35 | base: dict[str, Any], | 30 | class TrajectoryConfigError(config_loader.ConfigError): |
| 36 | overrides: dict[str, Any], | 31 | """Raised when trajectory config contains unsupported keys or values.""" |
| 37 | ) -> dict[str, Any]: | ||
| 38 | for key, value in overrides.items(): | ||
| 39 | if isinstance(value, dict) and isinstance(base.get(key), dict): | ||
| 40 | base[key] = _deep_merge_dicts(dict(base[key]), value) | ||
| 41 | else: | ||
| 42 | base[key] = value | ||
| 43 | return base | ||
| 44 | 32 | ||
| 45 | 33 | ||
| 46 | def _validate_trajectory_config_keys(config: dict[str, Any]) -> None: | 34 | def normalize_trajectory_config(raw_config: dict[str, Any]) -> dict[str, Any]: |
| 47 | unknown_keys = sorted(set(config) - ALLOWED_TRAJECTORY_CONFIG_KEYS) | 35 | """Validate *raw_config* and fill field defaults. |
| 48 | if not unknown_keys: | ||
| 49 | return | ||
| 50 | 36 | ||
| 51 | allowed_keys = ", ".join(sorted(ALLOWED_TRAJECTORY_CONFIG_KEYS)) | 37 | Args: |
| 52 | raise TrajectoryConfigError( | 38 | raw_config: A raw or partial trajectory config mapping. |
| 53 | "Unknown trajectory config key(s): " | ||
| 54 | f"{', '.join(unknown_keys)}. " | ||
| 55 | f"Allowed keys: {allowed_keys}" | ||
| 56 | ) | ||
| 57 | 39 | ||
| 40 | Returns: | ||
| 41 | A plain dict of the validated config. | ||
| 58 | 42 | ||
| 59 | def normalize_trajectory_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 43 | Raises: |
| 60 | config = dict(raw_config) | 44 | TrajectoryConfigError: Unknown keys or values that cannot be coerced. |
| 61 | _validate_trajectory_config_keys(config) | 45 | """ |
| 62 | config.setdefault("save_npz", False) | 46 | logger.debug("Normalizing trajectory config keys=%s", sorted(raw_config)) |
| 63 | config.setdefault("save_ply", False) | 47 | return config_loader.validate_config( |
| 64 | config.setdefault("only_within_80_degrees", True) | 48 | TrajectoryConfig, |
| 65 | config.setdefault("pcd_extension", "") | 49 | raw_config, |
| 66 | config.setdefault("spline_pcd_extension", "_run1_spline_points") | 50 | context="trajectory config", |
| 67 | config.setdefault("trajectory_max_angle", 5.0) | 51 | error_cls=TrajectoryConfigError, |
| 68 | config.setdefault("trajectory_first_downsample", 0.01) | 52 | ).model_dump() |
| 69 | config.setdefault("trajectory_second_downsample", 0.005) | ||
| 70 | config.setdefault("trajectory_outlier_removal_nb_points", 7) | ||
| 71 | config.setdefault("trajectory_outlier_removal_search_radius", 1.0) | ||
| 72 | return config | ||
| 73 | 53 | ||
| 74 | 54 | ||
| 75 | def load_trajectory_config(config_path: str | Path | None = None) -> dict[str, Any]: | 55 | def load_trajectory_config(config_path: str | Path | None = None) -> dict[str, Any]: |
| 76 | resolved_path = Path(config_path) if config_path is not None else _default_config_path() | 56 | """Load the packaged default JSON, or *config_path* in its place. |
| 77 | with resolved_path.open("r", encoding="utf-8") as handle: | 57 | |
| 78 | raw_config: dict[str, Any] = json.load(handle) | 58 | Args: |
| 79 | return normalize_trajectory_config(raw_config) | 59 | config_path: Optional JSON file used instead of the packaged defaults. |
| 60 | |||
| 61 | Returns: | ||
| 62 | A plain dict of the validated config. | ||
| 63 | |||
| 64 | Raises: | ||
| 65 | TrajectoryConfigError: Malformed JSON, unknown keys, or bad values. | ||
| 66 | OSError: The config file could not be read. | ||
| 67 | """ | ||
| 68 | return config_loader.load_config( | ||
| 69 | TrajectoryConfig, | ||
| 70 | package=_PACKAGE, | ||
| 71 | filename=_DEFAULT_FILENAME, | ||
| 72 | config_path=config_path, | ||
| 73 | context="trajectory config", | ||
| 74 | error_cls=TrajectoryConfigError, | ||
| 75 | ).model_dump() | ||
| 80 | 76 | ||
| 81 | 77 | ||
| 82 | def build_trajectory_config( | 78 | def build_trajectory_config( |
| 83 | *, | 79 | *, |
| 84 | overrides: dict[str, Any] | None = None, | 80 | overrides: dict[str, Any] | None = None, |
| 85 | config_path: str | Path | None = None, | 81 | config_path: str | Path | None = None, |
| 86 | ) -> dict[str, Any]: | 82 | ) -> dict[str, Any]: |
| 87 | config = load_trajectory_config(config_path) | 83 | """Load defaults (or *config_path*) and deep-merge *overrides* on top. |
| 88 | if overrides: | 84 | |
| 89 | config = _deep_merge_dicts(config, dict(overrides)) | 85 | Args: |
| 90 | return normalize_trajectory_config(config) | 86 | overrides: Mapping merged onto the defaults, e.g. from ``--set``. |
| 87 | config_path: Optional JSON file used instead of the packaged defaults. | ||
| 88 | |||
| 89 | Returns: | ||
| 90 | A plain dict of the validated config. | ||
| 91 | |||
| 92 | Raises: | ||
| 93 | TrajectoryConfigError: Malformed JSON, unknown keys, or bad values. | ||
| 94 | OSError: The config file could not be read. | ||
| 95 | """ | ||
| 96 | return config_loader.load_config( | ||
| 97 | TrajectoryConfig, | ||
| 98 | package=_PACKAGE, | ||
| 99 | filename=_DEFAULT_FILENAME, | ||
| 100 | overrides=overrides, | ||
| 101 | config_path=config_path, | ||
| 102 | context="trajectory config", | ||
| 103 | error_cls=TrajectoryConfigError, | ||
| 104 | ).model_dump() |
| 13 | 13 | ||
| 14 | from iolabs.common.version_info import save_version_json | 14 | from iolabs.common.version_info import save_version_json |
| 15 | from iolabs.logstash import get_props_logger | 15 | from iolabs.logstash import get_props_logger |
| 16 | from ._log_props import LOG_PROPS | 16 | from ._log_props import LOG_PROPS |
| 17 | from ._config import load_trajectory_config, normalize_trajectory_config | 17 | # load_trajectory_config is re-exported for callers doing trajectory_finder.load_trajectory_config() |
| 18 | from ._config import load_trajectory_config, normalize_trajectory_config # noqa: F401 | ||
| 18 | 19 | ||
| 19 | 20 | ||
| 20 | def _write_empty_ply(output_path: Path) -> None: | 21 | def _write_empty_ply(output_path: Path) -> None: |
| 21 | output_path.parent.mkdir(parents=True, exist_ok=True) | 22 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 1 | """Trajectory extraction from LIDAR point clouds.""" | 1 | """Trajectory extraction from LIDAR point clouds.""" |
| 2 | 2 | ||
| 3 | from ._config import ( | 3 | from ._config import ( |
| 4 | TrajectoryConfig, | ||
| 4 | TrajectoryConfigError, | 5 | TrajectoryConfigError, |
| 5 | build_trajectory_config, | 6 | build_trajectory_config, |
| 6 | load_trajectory_config, | 7 | load_trajectory_config, |
| 7 | normalize_trajectory_config, | 8 | normalize_trajectory_config, |
| 9 | from .trajectory_finder import TrajectoryFinder | 10 | from .trajectory_finder import TrajectoryFinder |
| 10 | 11 | ||
| 11 | __all__ = [ | 12 | __all__ = [ |
| 12 | "TrajectoryFinder", | 13 | "TrajectoryFinder", |
| 14 | "TrajectoryConfig", | ||
| 13 | "TrajectoryConfigError", | 15 | "TrajectoryConfigError", |
| 14 | "build_trajectory_config", | 16 | "build_trajectory_config", |
| 15 | "load_trajectory_config", | 17 | "load_trajectory_config", |
| 16 | "normalize_trajectory_config", | 18 | "normalize_trajectory_config", |
| 1 | { | 1 | { |
| 2 | "save_npz": false, | ||
| 3 | "save_ply": false, | ||
| 4 | "only_within_80_degrees": true, | ||
| 2 | "pcd_extension": "", | 5 | "pcd_extension": "", |
| 3 | "spline_pcd_extension": "_run1_spline_points", | 6 | "spline_pcd_extension": "_run1_spline_points", |
| 4 | "trajectory_max_angle": 5.0, | 7 | "trajectory_max_angle": 5.0, |
| 5 | "trajectory_first_downsample": 0.01, | 8 | "trajectory_first_downsample": 0.01, |
| 1 | """Install lightweight stubs so the package imports without Open3D/LAS/Logstash.""" | ||
| 2 | |||
| 3 | from __future__ import annotations | ||
| 4 | |||
| 5 | import logging | ||
| 6 | import sys | ||
| 7 | import types | ||
| 8 | |||
| 9 | |||
| 10 | def _install_import_stubs() -> None: | ||
| 11 | open3d_module = types.ModuleType("open3d") | ||
| 12 | open3d_t_module = types.ModuleType("open3d.t") | ||
| 13 | open3d_t_io_module = types.ModuleType("open3d.t.io") | ||
| 14 | open3d_core_module = types.ModuleType("open3d.core") | ||
| 15 | open3d_geometry_module = types.ModuleType("open3d.t.geometry") | ||
| 16 | open3d_core_module.Dtype = types.SimpleNamespace(Float64=object()) | ||
| 17 | open3d_core_module.Tensor = object | ||
| 18 | open3d_geometry_module.PointCloud = object | ||
| 19 | open3d_t_module.io = open3d_t_io_module | ||
| 20 | open3d_t_module.geometry = open3d_geometry_module | ||
| 21 | open3d_module.t = open3d_t_module | ||
| 22 | sys.modules.setdefault("open3d", open3d_module) | ||
| 23 | sys.modules.setdefault("open3d.core", open3d_core_module) | ||
| 24 | sys.modules.setdefault("open3d.t", open3d_t_module) | ||
| 25 | sys.modules.setdefault("open3d.t.io", open3d_t_io_module) | ||
| 26 | sys.modules.setdefault("open3d.t.geometry", open3d_geometry_module) | ||
| 27 | |||
| 28 | laspy_module = types.ModuleType("laspy") | ||
| 29 | laspy_module.LasData = object | ||
| 30 | laspy_module.open = lambda las_file: None | ||
| 31 | sys.modules.setdefault("laspy", laspy_module) | ||
| 32 | |||
| 33 | import iolabs # noqa: F401 — namespace from PYTHONPATH overlay | ||
| 34 | |||
| 35 | iolabs_logstash_module = types.ModuleType("iolabs.logstash") | ||
| 36 | iolabs_logstash_module.get_props_logger = lambda name, props: logging.getLogger(name) | ||
| 37 | sys.modules.setdefault("iolabs.logstash", iolabs_logstash_module) | ||
| 38 | |||
| 39 | |||
| 40 | _install_import_stubs() | ||
| 0 |
| 1 | from __future__ import annotations | ||
| 2 | |||
| 3 | import json | ||
| 4 | import sys | ||
| 5 | from pathlib import Path | ||
| 6 | |||
| 7 | import pytest | ||
| 8 | |||
| 9 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | ||
| 10 | |||
| 11 | from iolabs.common import config_loader # noqa: E402 | ||
| 12 | from iolabs_point_cloud_trajectory_detect._config import ( # noqa: E402 | ||
| 13 | TrajectoryConfigError, | ||
| 14 | build_trajectory_config, | ||
| 15 | load_trajectory_config, | ||
| 16 | normalize_trajectory_config, | ||
| 17 | ) | ||
| 18 | |||
| 19 | _EXPECTED_DEFAULTS = { | ||
| 20 | "save_npz": False, | ||
| 21 | "save_ply": False, | ||
| 22 | "only_within_80_degrees": True, | ||
| 23 | "pcd_extension": "", | ||
| 24 | "spline_pcd_extension": "_run1_spline_points", | ||
| 25 | "trajectory_max_angle": 5.0, | ||
| 26 | "trajectory_first_downsample": 0.01, | ||
| 27 | "trajectory_second_downsample": 0.005, | ||
| 28 | "trajectory_outlier_removal_nb_points": 7, | ||
| 29 | "trajectory_outlier_removal_search_radius": 1.0, | ||
| 30 | } | ||
| 31 | |||
| 32 | |||
| 33 | def test_error_class_is_config_error() -> None: | ||
| 34 | assert issubclass(TrajectoryConfigError, config_loader.ConfigError) | ||
| 35 | assert issubclass(TrajectoryConfigError, ValueError) | ||
| 36 | |||
| 37 | |||
| 38 | def test_load_trajectory_config_returns_packaged_defaults() -> None: | ||
| 39 | config = load_trajectory_config() | ||
| 40 | assert isinstance(config, dict) | ||
| 41 | assert config == _EXPECTED_DEFAULTS | ||
| 42 | |||
| 43 | |||
| 44 | def test_normalize_fills_defaults_and_rejects_unknown_keys() -> None: | ||
| 45 | config = normalize_trajectory_config({"pcd_extension": "_custom"}) | ||
| 46 | assert config["pcd_extension"] == "_custom" | ||
| 47 | assert config["trajectory_max_angle"] == 5.0 | ||
| 48 | with pytest.raises(TrajectoryConfigError, match="Unknown trajectory config key") as exc: | ||
| 49 | normalize_trajectory_config({"not_a_real_key": 1}) | ||
| 50 | message = str(exc.value) | ||
| 51 | assert "not_a_real_key" in message | ||
| 52 | assert "Allowed keys:" in message | ||
| 53 | |||
| 54 | |||
| 55 | def test_build_deep_merges_overrides_and_coerces_set_strings() -> None: | ||
| 56 | overrides = config_loader.parse_set_overrides( | ||
| 57 | [ | ||
| 58 | "save_npz=true", | ||
| 59 | "trajectory_max_angle=2.5", | ||
| 60 | "trajectory_outlier_removal_nb_points=9", | ||
| 61 | ] | ||
| 62 | ) | ||
| 63 | config = build_trajectory_config(overrides=overrides) | ||
| 64 | assert config["save_npz"] is True | ||
| 65 | assert config["trajectory_max_angle"] == 2.5 | ||
| 66 | assert config["trajectory_outlier_removal_nb_points"] == 9 | ||
| 67 | assert config["spline_pcd_extension"] == "_run1_spline_points" | ||
| 68 | |||
| 69 | |||
| 70 | def test_build_rejects_unknown_override_key() -> None: | ||
| 71 | with pytest.raises(TrajectoryConfigError, match="Unknown trajectory config key"): | ||
| 72 | build_trajectory_config(overrides={"typo_max_angle": 1.0}) | ||
| 73 | |||
| 74 | |||
| 75 | def test_load_config_path_replaces_packaged_defaults(tmp_path: Path) -> None: | ||
| 76 | path = tmp_path / "custom.json" | ||
| 77 | path.write_text(json.dumps({"trajectory_max_angle": 12.0}), encoding="utf-8") | ||
| 78 | config = load_trajectory_config(path) | ||
| 79 | assert config["trajectory_max_angle"] == 12.0 | ||
| 80 | assert config["save_npz"] is False | ||
| 81 | |||
| 82 | |||
| 83 | def test_bad_value_is_rejected() -> None: | ||
| 84 | with pytest.raises(TrajectoryConfigError): | ||
| 85 | normalize_trajectory_config({"trajectory_max_angle": "not-a-float"}) | ||
| 86 | with pytest.raises(TrajectoryConfigError): | ||
| 87 | normalize_trajectory_config({"save_npz": "flase"}) | ||
| 0 |
| 8 | import numpy as np | 8 | import numpy as np |
| 9 | import pytest | 9 | import pytest |
| 10 | 10 | ||
| 11 | 11 | ||
| 12 | def _install_dependency_stubs() -> None: | ||
| 13 | open3d_module = types.ModuleType("open3d") | ||
| 14 | open3d_t_module = types.ModuleType("open3d.t") | ||
| 15 | open3d_t_io_module = types.ModuleType("open3d.t.io") | ||
| 16 | open3d_core_module = types.ModuleType("open3d.core") | ||
| 17 | open3d_geometry_module = types.ModuleType("open3d.t.geometry") | ||
| 18 | open3d_core_module.Dtype = types.SimpleNamespace(Float64=object()) | ||
| 19 | open3d_core_module.Tensor = object | ||
| 20 | open3d_geometry_module.PointCloud = object | ||
| 21 | open3d_t_module.io = open3d_t_io_module | ||
| 22 | open3d_t_module.geometry = open3d_geometry_module | ||
| 23 | open3d_module.t = open3d_t_module | ||
| 24 | sys.modules.setdefault("open3d", open3d_module) | ||
| 25 | sys.modules.setdefault("open3d.core", open3d_core_module) | ||
| 26 | sys.modules.setdefault("open3d.t", open3d_t_module) | ||
| 27 | sys.modules.setdefault("open3d.t.io", open3d_t_io_module) | ||
| 28 | sys.modules.setdefault("open3d.t.geometry", open3d_geometry_module) | ||
| 29 | |||
| 30 | iolabs_module = types.ModuleType("iolabs") | ||
| 31 | iolabs_common_module = types.ModuleType("iolabs.common") | ||
| 32 | iolabs_version_module = types.ModuleType("iolabs.common.version_info") | ||
| 33 | iolabs_version_module.save_version_json = lambda *args, **kwargs: None | ||
| 34 | iolabs_logstash_module = types.ModuleType("iolabs.logstash") | ||
| 35 | iolabs_logstash_module.get_props_logger = lambda name, props: logging.getLogger(name) | ||
| 36 | sys.modules.setdefault("iolabs", iolabs_module) | ||
| 37 | sys.modules.setdefault("iolabs.common", iolabs_common_module) | ||
| 38 | sys.modules.setdefault("iolabs.common.version_info", iolabs_version_module) | ||
| 39 | sys.modules.setdefault("iolabs.logstash", iolabs_logstash_module) | ||
| 40 | |||
| 41 | laspy_module = types.ModuleType("laspy") | ||
| 42 | laspy_module.LasData = _FakeLasData | ||
| 43 | laspy_module.open = lambda las_file: None | ||
| 44 | sys.modules.setdefault("laspy", laspy_module) | ||
| 45 | |||
| 46 | |||
| 47 | class _FakeHeader: | 12 | class _FakeHeader: |
| 48 | def __init__(self, point_count: int) -> None: | 13 | def __init__(self, point_count: int) -> None: |
| 49 | self.point_count = point_count | 14 | self.point_count = point_count |
| 50 | 15 |
| 109 | dimensions=[_FakeDimension("X"), _FakeDimension("Y"), _FakeDimension("Z")] | 74 | dimensions=[_FakeDimension("X"), _FakeDimension("Y"), _FakeDimension("Z")] |
| 110 | ) | 75 | ) |
| 111 | 76 | ||
| 112 | 77 | ||
| 113 | _install_dependency_stubs() | ||
| 114 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | 78 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) |
| 115 | 79 | ||
| 116 | from iolabs_point_cloud_trajectory_detect.trajectory_finder import TrajectoryFinder # noqa: E402 | 80 | from iolabs_point_cloud_trajectory_detect.trajectory_finder import TrajectoryFinder # noqa: E402 |
| 117 | import iolabs_point_cloud_trajectory_detect.trajectory_finder as trajectory_finder # noqa: E402 | 81 | import iolabs_point_cloud_trajectory_detect.trajectory_finder as trajectory_finder # noqa: E402 |
| 118 | 82 | ||
| 83 | trajectory_finder.laspy.LasData = _FakeLasData | ||
| 84 | |||
| 119 | 85 | ||
| 120 | def test_read_las_window_constructs_lasdata_from_window_points(monkeypatch, tmp_path): | 86 | def test_read_las_window_constructs_lasdata_from_window_points(monkeypatch, tmp_path): |
| 121 | reader = _FakeReader(total_points=957_683_297) | 87 | reader = _FakeReader(total_points=957_683_297) |
| 122 | monkeypatch.setattr(trajectory_finder.laspy, "open", lambda las_file: reader) | 88 | monkeypatch.setattr(trajectory_finder.laspy, "open", lambda las_file: reader) |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-point-cloud-trajectory-detect" | 2 | name = "iolabs-point-cloud-trajectory-detect" |
| 3 | version = "0.5.7" | 3 | version = "0.5.8" |
| 4 | description = "Trajectory extraction from LIDAR point clouds" | 4 | description = "Trajectory extraction from LIDAR 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 | "open3d>=0.19.0", | 8 | "open3d>=0.19.0", |
| 9 | "laspy>=2.0.0", | 9 | "laspy>=2.0.0", |
| 10 | "torch>=2.0.0", | 10 | "torch>=2.0.0", |
| 11 | "pydantic>=2.7", | ||
| 11 | "iolabs-logstash>=0.4.0", | 12 | "iolabs-logstash>=0.4.0", |
| 12 | "iolabs-common", | 13 | "iolabs-common>=0.9.0", |
| 13 | ] | 14 | ] |
| 14 | 15 | ||
| 15 | [tool.uv] | 16 | [tool.uv] |
| 16 | publish-url = "https://nexus.iolabs.ch/repository/pypi-private/" | 17 | publish-url = "https://nexus.iolabs.ch/repository/pypi-private/" |
| 22 | ## Usage | 22 | ## Usage |
| 23 | 23 | ||
| 24 | Use this package to detect and extract trajectories (e.g. scanner path or vehicle path) from LIDAR point cloud data. | 24 | Use this package to detect and extract trajectories (e.g. scanner path or vehicle path) from LIDAR point cloud data. |
| 25 | 25 | ||
| 26 | ## Config | ||
| 27 | |||
| 28 | Defaults live in `src/iolabs_point_cloud_trajectory_detect/trajectories.default.json`. The schema is `TrajectoryConfig` in `_config.py` (a `config_loader.ConfigModel`). To add a key, add the field to the model and the matching default in the JSON; nothing else. Unknown keys are rejected. | ||
| 29 | |||
| 26 | ## Develop locally (Nexus) | 30 | ## Develop locally (Nexus) |
| 27 | 31 | ||
| 28 | Internal `iolabs-*` dependencies resolve through the private Nexus index declared in `pyproject.toml`. Export Nexus credentials before any `uv` command that touches private deps — e.g. by sourcing `../3dai.lanefinder/scripts/nexus_credentials.sh` from your shell rc — then: | 32 | Internal `iolabs-*` dependencies resolve through the private Nexus index declared in `pyproject.toml`. Export Nexus credentials before any `uv` command that touches private deps — e.g. by sourcing `../3dai.lanefinder/scripts/nexus_credentials.sh` from your shell rc — then: |
| 29 | 33 |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-point-cloud-trajectory-detect" | 2 | name = "iolabs-point-cloud-trajectory-detect" |
| 3 | version = "0.5.7" | 3 | version = "0.5.8" |
| 4 | description = "Trajectory extraction from LIDAR point clouds" | 4 | description = "Trajectory extraction from LIDAR 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 | "open3d>=0.19.0", | 8 | "open3d>=0.19.0", |
| 9 | "laspy>=2.0.0", | 9 | "laspy>=2.0.0", |
| 10 | "torch>=2.0.0", | 10 | "torch>=2.0.0", |
| 11 | "pydantic>=2.7", | ||
| 11 | "iolabs-logstash>=0.4.0", | 12 | "iolabs-logstash>=0.4.0", |
| 12 | "iolabs-common", | 13 | "iolabs-common>=0.9.0", |
| 13 | ] | 14 | ] |
| 14 | 15 | ||
| 15 | [tool.uv] | 16 | [tool.uv] |
| 16 | publish-url = "https://nexus.iolabs.ch/repository/pypi-private/" | 17 | publish-url = "https://nexus.iolabs.ch/repository/pypi-private/" |
| 1 | """Trajectory extraction from LIDAR point clouds.""" | 1 | """Trajectory extraction from LIDAR point clouds.""" |
| 2 | 2 | ||
| 3 | from ._config import ( | 3 | from ._config import ( |
| 4 | TrajectoryConfig, | ||
| 4 | TrajectoryConfigError, | 5 | TrajectoryConfigError, |
| 5 | build_trajectory_config, | 6 | build_trajectory_config, |
| 6 | load_trajectory_config, | 7 | load_trajectory_config, |
| 7 | normalize_trajectory_config, | 8 | normalize_trajectory_config, |
| 9 | from .trajectory_finder import TrajectoryFinder | 10 | from .trajectory_finder import TrajectoryFinder |
| 10 | 11 | ||
| 11 | __all__ = [ | 12 | __all__ = [ |
| 12 | "TrajectoryFinder", | 13 | "TrajectoryFinder", |
| 14 | "TrajectoryConfig", | ||
| 13 | "TrajectoryConfigError", | 15 | "TrajectoryConfigError", |
| 14 | "build_trajectory_config", | 16 | "build_trajectory_config", |
| 15 | "load_trajectory_config", | 17 | "load_trajectory_config", |
| 16 | "normalize_trajectory_config", | 18 | "normalize_trajectory_config", |
| 1 | from __future__ import annotations | 1 | from __future__ import annotations |
| 2 | 2 | ||
| 3 | import json | 3 | import logging |
| 4 | from importlib import resources | ||
| 5 | from pathlib import Path | 4 | from pathlib import Path |
| 6 | from typing import Any | 5 | from typing import Any |
| 7 | 6 | ||
| 8 | ALLOWED_TRAJECTORY_CONFIG_KEYS = frozenset( | 7 | from iolabs.common import config_loader |
| 9 | { | ||
| 10 | "save_npz", | ||
| 11 | "save_ply", | ||
| 12 | "only_within_80_degrees", | ||
| 13 | "pcd_extension", | ||
| 14 | "spline_pcd_extension", | ||
| 15 | "trajectory_max_angle", | ||
| 16 | "trajectory_first_downsample", | ||
| 17 | "trajectory_second_downsample", | ||
| 18 | "trajectory_outlier_removal_nb_points", | ||
| 19 | "trajectory_outlier_removal_search_radius", | ||
| 20 | } | ||
| 21 | ) | ||
| 22 | 8 | ||
| 9 | logger = logging.getLogger(__name__) | ||
| 23 | 10 | ||
| 24 | class TrajectoryConfigError(ValueError): | 11 | _PACKAGE = "iolabs_point_cloud_trajectory_detect" |
| 25 | """Raised when trajectory config contains unsupported keys.""" | 12 | _DEFAULT_FILENAME = "trajectories.default.json" |
| 26 | 13 | ||
| 27 | 14 | ||
| 28 | def _default_config_path() -> Path: | 15 | class TrajectoryConfig(config_loader.ConfigModel): |
| 29 | if __package__ in {None, ""}: | 16 | """Packaged trajectory-detect configuration.""" |
| 30 | return Path(__file__).resolve().with_name("trajectories.default.json") | ||
| 31 | return Path(str(resources.files(__package__).joinpath("trajectories.default.json"))) | ||
| 32 | 17 | ||
| 18 | save_npz: bool = False | ||
| 19 | save_ply: bool = False | ||
| 20 | only_within_80_degrees: bool = True | ||
| 21 | pcd_extension: str = "" | ||
| 22 | spline_pcd_extension: str = "_run1_spline_points" | ||
| 23 | trajectory_max_angle: float = 5.0 | ||
| 24 | trajectory_first_downsample: float = 0.01 | ||
| 25 | trajectory_second_downsample: float = 0.005 | ||
| 26 | trajectory_outlier_removal_nb_points: int = 7 | ||
| 27 | trajectory_outlier_removal_search_radius: float = 1.0 | ||
| 33 | 28 | ||
| 34 | def _deep_merge_dicts( | 29 | |
| 35 | base: dict[str, Any], | 30 | class TrajectoryConfigError(config_loader.ConfigError): |
| 36 | overrides: dict[str, Any], | 31 | """Raised when trajectory config contains unsupported keys or values.""" |
| 37 | ) -> dict[str, Any]: | ||
| 38 | for key, value in overrides.items(): | ||
| 39 | if isinstance(value, dict) and isinstance(base.get(key), dict): | ||
| 40 | base[key] = _deep_merge_dicts(dict(base[key]), value) | ||
| 41 | else: | ||
| 42 | base[key] = value | ||
| 43 | return base | ||
| 44 | 32 | ||
| 45 | 33 | ||
| 46 | def _validate_trajectory_config_keys(config: dict[str, Any]) -> None: | 34 | def normalize_trajectory_config(raw_config: dict[str, Any]) -> dict[str, Any]: |
| 47 | unknown_keys = sorted(set(config) - ALLOWED_TRAJECTORY_CONFIG_KEYS) | 35 | """Validate *raw_config* and fill field defaults. |
| 48 | if not unknown_keys: | ||
| 49 | return | ||
| 50 | 36 | ||
| 51 | allowed_keys = ", ".join(sorted(ALLOWED_TRAJECTORY_CONFIG_KEYS)) | 37 | Args: |
| 52 | raise TrajectoryConfigError( | 38 | raw_config: A raw or partial trajectory config mapping. |
| 53 | "Unknown trajectory config key(s): " | ||
| 54 | f"{', '.join(unknown_keys)}. " | ||
| 55 | f"Allowed keys: {allowed_keys}" | ||
| 56 | ) | ||
| 57 | 39 | ||
| 40 | Returns: | ||
| 41 | A plain dict of the validated config. | ||
| 58 | 42 | ||
| 59 | def normalize_trajectory_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 43 | Raises: |
| 60 | config = dict(raw_config) | 44 | TrajectoryConfigError: Unknown keys or values that cannot be coerced. |
| 61 | _validate_trajectory_config_keys(config) | 45 | """ |
| 62 | config.setdefault("save_npz", False) | 46 | logger.debug("Normalizing trajectory config keys=%s", sorted(raw_config)) |
| 63 | config.setdefault("save_ply", False) | 47 | return config_loader.validate_config( |
| 64 | config.setdefault("only_within_80_degrees", True) | 48 | TrajectoryConfig, |
| 65 | config.setdefault("pcd_extension", "") | 49 | raw_config, |
| 66 | config.setdefault("spline_pcd_extension", "_run1_spline_points") | 50 | context="trajectory config", |
| 67 | config.setdefault("trajectory_max_angle", 5.0) | 51 | error_cls=TrajectoryConfigError, |
| 68 | config.setdefault("trajectory_first_downsample", 0.01) | 52 | ).model_dump() |
| 69 | config.setdefault("trajectory_second_downsample", 0.005) | ||
| 70 | config.setdefault("trajectory_outlier_removal_nb_points", 7) | ||
| 71 | config.setdefault("trajectory_outlier_removal_search_radius", 1.0) | ||
| 72 | return config | ||
| 73 | 53 | ||
| 74 | 54 | ||
| 75 | def load_trajectory_config(config_path: str | Path | None = None) -> dict[str, Any]: | 55 | def load_trajectory_config(config_path: str | Path | None = None) -> dict[str, Any]: |
| 76 | resolved_path = Path(config_path) if config_path is not None else _default_config_path() | 56 | """Load the packaged default JSON, or *config_path* in its place. |
| 77 | with resolved_path.open("r", encoding="utf-8") as handle: | 57 | |
| 78 | raw_config: dict[str, Any] = json.load(handle) | 58 | Args: |
| 79 | return normalize_trajectory_config(raw_config) | 59 | config_path: Optional JSON file used instead of the packaged defaults. |
| 60 | |||
| 61 | Returns: | ||
| 62 | A plain dict of the validated config. | ||
| 63 | |||
| 64 | Raises: | ||
| 65 | TrajectoryConfigError: Malformed JSON, unknown keys, or bad values. | ||
| 66 | OSError: The config file could not be read. | ||
| 67 | """ | ||
| 68 | return config_loader.load_config( | ||
| 69 | TrajectoryConfig, | ||
| 70 | package=_PACKAGE, | ||
| 71 | filename=_DEFAULT_FILENAME, | ||
| 72 | config_path=config_path, | ||
| 73 | context="trajectory config", | ||
| 74 | error_cls=TrajectoryConfigError, | ||
| 75 | ).model_dump() | ||
| 80 | 76 | ||
| 81 | 77 | ||
| 82 | def build_trajectory_config( | 78 | def build_trajectory_config( |
| 83 | *, | 79 | *, |
| 84 | overrides: dict[str, Any] | None = None, | 80 | overrides: dict[str, Any] | None = None, |
| 85 | config_path: str | Path | None = None, | 81 | config_path: str | Path | None = None, |
| 86 | ) -> dict[str, Any]: | 82 | ) -> dict[str, Any]: |
| 87 | config = load_trajectory_config(config_path) | 83 | """Load defaults (or *config_path*) and deep-merge *overrides* on top. |
| 88 | if overrides: | 84 | |
| 89 | config = _deep_merge_dicts(config, dict(overrides)) | 85 | Args: |
| 90 | return normalize_trajectory_config(config) | 86 | overrides: Mapping merged onto the defaults, e.g. from ``--set``. |
| 87 | config_path: Optional JSON file used instead of the packaged defaults. | ||
| 88 | |||
| 89 | Returns: | ||
| 90 | A plain dict of the validated config. | ||
| 91 | |||
| 92 | Raises: | ||
| 93 | TrajectoryConfigError: Malformed JSON, unknown keys, or bad values. | ||
| 94 | OSError: The config file could not be read. | ||
| 95 | """ | ||
| 96 | return config_loader.load_config( | ||
| 97 | TrajectoryConfig, | ||
| 98 | package=_PACKAGE, | ||
| 99 | filename=_DEFAULT_FILENAME, | ||
| 100 | overrides=overrides, | ||
| 101 | config_path=config_path, | ||
| 102 | context="trajectory config", | ||
| 103 | error_cls=TrajectoryConfigError, | ||
| 104 | ).model_dump() |
| 1 | { | 1 | { |
| 2 | "save_npz": false, | ||
| 3 | "save_ply": false, | ||
| 4 | "only_within_80_degrees": true, | ||
| 2 | "pcd_extension": "", | 5 | "pcd_extension": "", |
| 3 | "spline_pcd_extension": "_run1_spline_points", | 6 | "spline_pcd_extension": "_run1_spline_points", |
| 4 | "trajectory_max_angle": 5.0, | 7 | "trajectory_max_angle": 5.0, |
| 5 | "trajectory_first_downsample": 0.01, | 8 | "trajectory_first_downsample": 0.01, |
| 13 | 13 | ||
| 14 | from iolabs.common.version_info import save_version_json | 14 | from iolabs.common.version_info import save_version_json |
| 15 | from iolabs.logstash import get_props_logger | 15 | from iolabs.logstash import get_props_logger |
| 16 | from ._log_props import LOG_PROPS | 16 | from ._log_props import LOG_PROPS |
| 17 | from ._config import load_trajectory_config, normalize_trajectory_config | 17 | # load_trajectory_config is re-exported for callers doing trajectory_finder.load_trajectory_config() |
| 18 | from ._config import load_trajectory_config, normalize_trajectory_config # noqa: F401 | ||
| 18 | 19 | ||
| 19 | 20 | ||
| 20 | def _write_empty_ply(output_path: Path) -> None: | 21 | def _write_empty_ply(output_path: Path) -> None: |
| 21 | output_path.parent.mkdir(parents=True, exist_ok=True) | 22 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 1 | """Install lightweight stubs so the package imports without Open3D/LAS/Logstash.""" | ||
| 2 | |||
| 3 | from __future__ import annotations | ||
| 4 | |||
| 5 | import logging | ||
| 6 | import sys | ||
| 7 | import types | ||
| 8 | |||
| 9 | |||
| 10 | def _install_import_stubs() -> None: | ||
| 11 | open3d_module = types.ModuleType("open3d") | ||
| 12 | open3d_t_module = types.ModuleType("open3d.t") | ||
| 13 | open3d_t_io_module = types.ModuleType("open3d.t.io") | ||
| 14 | open3d_core_module = types.ModuleType("open3d.core") | ||
| 15 | open3d_geometry_module = types.ModuleType("open3d.t.geometry") | ||
| 16 | open3d_core_module.Dtype = types.SimpleNamespace(Float64=object()) | ||
| 17 | open3d_core_module.Tensor = object | ||
| 18 | open3d_geometry_module.PointCloud = object | ||
| 19 | open3d_t_module.io = open3d_t_io_module | ||
| 20 | open3d_t_module.geometry = open3d_geometry_module | ||
| 21 | open3d_module.t = open3d_t_module | ||
| 22 | sys.modules.setdefault("open3d", open3d_module) | ||
| 23 | sys.modules.setdefault("open3d.core", open3d_core_module) | ||
| 24 | sys.modules.setdefault("open3d.t", open3d_t_module) | ||
| 25 | sys.modules.setdefault("open3d.t.io", open3d_t_io_module) | ||
| 26 | sys.modules.setdefault("open3d.t.geometry", open3d_geometry_module) | ||
| 27 | |||
| 28 | laspy_module = types.ModuleType("laspy") | ||
| 29 | laspy_module.LasData = object | ||
| 30 | laspy_module.open = lambda las_file: None | ||
| 31 | sys.modules.setdefault("laspy", laspy_module) | ||
| 32 | |||
| 33 | import iolabs # noqa: F401 — namespace from PYTHONPATH overlay | ||
| 34 | |||
| 35 | iolabs_logstash_module = types.ModuleType("iolabs.logstash") | ||
| 36 | iolabs_logstash_module.get_props_logger = lambda name, props: logging.getLogger(name) | ||
| 37 | sys.modules.setdefault("iolabs.logstash", iolabs_logstash_module) | ||
| 38 | |||
| 39 | |||
| 40 | _install_import_stubs() | ||
| 0 |
| 1 | from __future__ import annotations | ||
| 2 | |||
| 3 | import json | ||
| 4 | import sys | ||
| 5 | from pathlib import Path | ||
| 6 | |||
| 7 | import pytest | ||
| 8 | |||
| 9 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | ||
| 10 | |||
| 11 | from iolabs.common import config_loader # noqa: E402 | ||
| 12 | from iolabs_point_cloud_trajectory_detect._config import ( # noqa: E402 | ||
| 13 | TrajectoryConfigError, | ||
| 14 | build_trajectory_config, | ||
| 15 | load_trajectory_config, | ||
| 16 | normalize_trajectory_config, | ||
| 17 | ) | ||
| 18 | |||
| 19 | _EXPECTED_DEFAULTS = { | ||
| 20 | "save_npz": False, | ||
| 21 | "save_ply": False, | ||
| 22 | "only_within_80_degrees": True, | ||
| 23 | "pcd_extension": "", | ||
| 24 | "spline_pcd_extension": "_run1_spline_points", | ||
| 25 | "trajectory_max_angle": 5.0, | ||
| 26 | "trajectory_first_downsample": 0.01, | ||
| 27 | "trajectory_second_downsample": 0.005, | ||
| 28 | "trajectory_outlier_removal_nb_points": 7, | ||
| 29 | "trajectory_outlier_removal_search_radius": 1.0, | ||
| 30 | } | ||
| 31 | |||
| 32 | |||
| 33 | def test_error_class_is_config_error() -> None: | ||
| 34 | assert issubclass(TrajectoryConfigError, config_loader.ConfigError) | ||
| 35 | assert issubclass(TrajectoryConfigError, ValueError) | ||
| 36 | |||
| 37 | |||
| 38 | def test_load_trajectory_config_returns_packaged_defaults() -> None: | ||
| 39 | config = load_trajectory_config() | ||
| 40 | assert isinstance(config, dict) | ||
| 41 | assert config == _EXPECTED_DEFAULTS | ||
| 42 | |||
| 43 | |||
| 44 | def test_normalize_fills_defaults_and_rejects_unknown_keys() -> None: | ||
| 45 | config = normalize_trajectory_config({"pcd_extension": "_custom"}) | ||
| 46 | assert config["pcd_extension"] == "_custom" | ||
| 47 | assert config["trajectory_max_angle"] == 5.0 | ||
| 48 | with pytest.raises(TrajectoryConfigError, match="Unknown trajectory config key") as exc: | ||
| 49 | normalize_trajectory_config({"not_a_real_key": 1}) | ||
| 50 | message = str(exc.value) | ||
| 51 | assert "not_a_real_key" in message | ||
| 52 | assert "Allowed keys:" in message | ||
| 53 | |||
| 54 | |||
| 55 | def test_build_deep_merges_overrides_and_coerces_set_strings() -> None: | ||
| 56 | overrides = config_loader.parse_set_overrides( | ||
| 57 | [ | ||
| 58 | "save_npz=true", | ||
| 59 | "trajectory_max_angle=2.5", | ||
| 60 | "trajectory_outlier_removal_nb_points=9", | ||
| 61 | ] | ||
| 62 | ) | ||
| 63 | config = build_trajectory_config(overrides=overrides) | ||
| 64 | assert config["save_npz"] is True | ||
| 65 | assert config["trajectory_max_angle"] == 2.5 | ||
| 66 | assert config["trajectory_outlier_removal_nb_points"] == 9 | ||
| 67 | assert config["spline_pcd_extension"] == "_run1_spline_points" | ||
| 68 | |||
| 69 | |||
| 70 | def test_build_rejects_unknown_override_key() -> None: | ||
| 71 | with pytest.raises(TrajectoryConfigError, match="Unknown trajectory config key"): | ||
| 72 | build_trajectory_config(overrides={"typo_max_angle": 1.0}) | ||
| 73 | |||
| 74 | |||
| 75 | def test_load_config_path_replaces_packaged_defaults(tmp_path: Path) -> None: | ||
| 76 | path = tmp_path / "custom.json" | ||
| 77 | path.write_text(json.dumps({"trajectory_max_angle": 12.0}), encoding="utf-8") | ||
| 78 | config = load_trajectory_config(path) | ||
| 79 | assert config["trajectory_max_angle"] == 12.0 | ||
| 80 | assert config["save_npz"] is False | ||
| 81 | |||
| 82 | |||
| 83 | def test_bad_value_is_rejected() -> None: | ||
| 84 | with pytest.raises(TrajectoryConfigError): | ||
| 85 | normalize_trajectory_config({"trajectory_max_angle": "not-a-float"}) | ||
| 86 | with pytest.raises(TrajectoryConfigError): | ||
| 87 | normalize_trajectory_config({"save_npz": "flase"}) | ||
| 0 |
| 8 | import numpy as np | 8 | import numpy as np |
| 9 | import pytest | 9 | import pytest |
| 10 | 10 | ||
| 11 | 11 | ||
| 12 | def _install_dependency_stubs() -> None: | ||
| 13 | open3d_module = types.ModuleType("open3d") | ||
| 14 | open3d_t_module = types.ModuleType("open3d.t") | ||
| 15 | open3d_t_io_module = types.ModuleType("open3d.t.io") | ||
| 16 | open3d_core_module = types.ModuleType("open3d.core") | ||
| 17 | open3d_geometry_module = types.ModuleType("open3d.t.geometry") | ||
| 18 | open3d_core_module.Dtype = types.SimpleNamespace(Float64=object()) | ||
| 19 | open3d_core_module.Tensor = object | ||
| 20 | open3d_geometry_module.PointCloud = object | ||
| 21 | open3d_t_module.io = open3d_t_io_module | ||
| 22 | open3d_t_module.geometry = open3d_geometry_module | ||
| 23 | open3d_module.t = open3d_t_module | ||
| 24 | sys.modules.setdefault("open3d", open3d_module) | ||
| 25 | sys.modules.setdefault("open3d.core", open3d_core_module) | ||
| 26 | sys.modules.setdefault("open3d.t", open3d_t_module) | ||
| 27 | sys.modules.setdefault("open3d.t.io", open3d_t_io_module) | ||
| 28 | sys.modules.setdefault("open3d.t.geometry", open3d_geometry_module) | ||
| 29 | |||
| 30 | iolabs_module = types.ModuleType("iolabs") | ||
| 31 | iolabs_common_module = types.ModuleType("iolabs.common") | ||
| 32 | iolabs_version_module = types.ModuleType("iolabs.common.version_info") | ||
| 33 | iolabs_version_module.save_version_json = lambda *args, **kwargs: None | ||
| 34 | iolabs_logstash_module = types.ModuleType("iolabs.logstash") | ||
| 35 | iolabs_logstash_module.get_props_logger = lambda name, props: logging.getLogger(name) | ||
| 36 | sys.modules.setdefault("iolabs", iolabs_module) | ||
| 37 | sys.modules.setdefault("iolabs.common", iolabs_common_module) | ||
| 38 | sys.modules.setdefault("iolabs.common.version_info", iolabs_version_module) | ||
| 39 | sys.modules.setdefault("iolabs.logstash", iolabs_logstash_module) | ||
| 40 | |||
| 41 | laspy_module = types.ModuleType("laspy") | ||
| 42 | laspy_module.LasData = _FakeLasData | ||
| 43 | laspy_module.open = lambda las_file: None | ||
| 44 | sys.modules.setdefault("laspy", laspy_module) | ||
| 45 | |||
| 46 | |||
| 47 | class _FakeHeader: | 12 | class _FakeHeader: |
| 48 | def __init__(self, point_count: int) -> None: | 13 | def __init__(self, point_count: int) -> None: |
| 49 | self.point_count = point_count | 14 | self.point_count = point_count |
| 50 | 15 |
| 109 | dimensions=[_FakeDimension("X"), _FakeDimension("Y"), _FakeDimension("Z")] | 74 | dimensions=[_FakeDimension("X"), _FakeDimension("Y"), _FakeDimension("Z")] |
| 110 | ) | 75 | ) |
| 111 | 76 | ||
| 112 | 77 | ||
| 113 | _install_dependency_stubs() | ||
| 114 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | 78 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) |
| 115 | 79 | ||
| 116 | from iolabs_point_cloud_trajectory_detect.trajectory_finder import TrajectoryFinder # noqa: E402 | 80 | from iolabs_point_cloud_trajectory_detect.trajectory_finder import TrajectoryFinder # noqa: E402 |
| 117 | import iolabs_point_cloud_trajectory_detect.trajectory_finder as trajectory_finder # noqa: E402 | 81 | import iolabs_point_cloud_trajectory_detect.trajectory_finder as trajectory_finder # noqa: E402 |
| 118 | 82 | ||
| 83 | trajectory_finder.laspy.LasData = _FakeLasData | ||
| 84 | |||
| 119 | 85 | ||
| 120 | def test_read_las_window_constructs_lasdata_from_window_points(monkeypatch, tmp_path): | 86 | def test_read_las_window_constructs_lasdata_from_window_points(monkeypatch, tmp_path): |
| 121 | reader = _FakeReader(total_points=957_683_297) | 87 | reader = _FakeReader(total_points=957_683_297) |
| 122 | monkeypatch.setattr(trajectory_finder.laspy, "open", lambda las_file: reader) | 88 | monkeypatch.setattr(trajectory_finder.laspy, "open", lambda las_file: reader) |
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.