Back to report index

Step 1 trajectorydetect 8c001f9: AI3D-379 Pydantic config models via iolabs-common ConfigModel

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(-)
Importance #1: src/iolabs_point_cloud_trajectory_detect/_config.py @@ -1,90 +1,104 @@
1from __future__ import annotations1from __future__ import annotations
22
3import json3import logging
4from importlib import resources
5from pathlib import Path4from pathlib import Path
6from typing import Any5from typing import Any
76
8ALLOWED_TRAJECTORY_CONFIG_KEYS = frozenset(7from 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)
228
9logger = logging.getLogger(__name__)
2310
24class TrajectoryConfigError(ValueError):11_PACKAGE = "iolabs_point_cloud_trajectory_detect"
25 """Raised when trajectory config contains unsupported keys."""12_DEFAULT_FILENAME = "trajectories.default.json"
2613
2714
28def _default_config_path() -> Path:15class 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")))
3217
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
3328
34def _deep_merge_dicts(29
35 base: dict[str, Any],30class 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
4432
4533
46def _validate_trajectory_config_keys(config: dict[str, Any]) -> None:34def 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
5036
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 )
5739
40 Returns:
41 A plain dict of the validated config.
5842
59def 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
7353
7454
75def load_trajectory_config(config_path: str | Path | None = None) -> dict[str, Any]:55def 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()
8076
8177
82def build_trajectory_config(78def 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()
Importance #2: src/iolabs_point_cloud_trajectory_detect/trajectory_finder.py @@ -13,9 +13,10 @@
1313
14from iolabs.common.version_info import save_version_json14from iolabs.common.version_info import save_version_json
15from iolabs.logstash import get_props_logger15from iolabs.logstash import get_props_logger
16from ._log_props import LOG_PROPS16from ._log_props import LOG_PROPS
17from ._config import load_trajectory_config, normalize_trajectory_config17# load_trajectory_config is re-exported for callers doing trajectory_finder.load_trajectory_config()
18from ._config import load_trajectory_config, normalize_trajectory_config # noqa: F401
1819
1920
20def _write_empty_ply(output_path: Path) -> None:21def _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)
Importance #3: src/iolabs_point_cloud_trajectory_detect/__init__.py @@ -1,7 +1,8 @@
1"""Trajectory extraction from LIDAR point clouds."""1"""Trajectory extraction from LIDAR point clouds."""
22
3from ._config import (3from ._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,
Importance #4: src/iolabs_point_cloud_trajectory_detect/__init__.py @@ -9,8 +10,9 @@
9from .trajectory_finder import TrajectoryFinder10from .trajectory_finder import TrajectoryFinder
1011
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",
Importance #5: src/iolabs_point_cloud_trajectory_detect/trajectories.default.json @@ -1,5 +1,8 @@
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,
Importance #6: tests/conftest.py @@ -0,0 +1,40 @@
1"""Install lightweight stubs so the package imports without Open3D/LAS/Logstash."""
2
3from __future__ import annotations
4
5import logging
6import sys
7import types
8
9
10def _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
Importance #7: tests/test_config.py @@ -0,0 +1,87 @@
1from __future__ import annotations
2
3import json
4import sys
5from pathlib import Path
6
7import pytest
8
9sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
11from iolabs.common import config_loader # noqa: E402
12from 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
33def test_error_class_is_config_error() -> None:
34 assert issubclass(TrajectoryConfigError, config_loader.ConfigError)
35 assert issubclass(TrajectoryConfigError, ValueError)
36
37
38def 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
44def 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
55def 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
70def 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
75def 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
83def 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
Importance #8: tests/test_trajectory_finder_window.py @@ -8,43 +8,8 @@
8import numpy as np8import numpy as np
9import pytest9import pytest
1010
1111
12def _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
47class _FakeHeader:12class _FakeHeader:
48 def __init__(self, point_count: int) -> None:13 def __init__(self, point_count: int) -> None:
49 self.point_count = point_count14 self.point_count = point_count
5015
Importance #9: tests/test_trajectory_finder_window.py @@ -109,14 +74,15 @@
109 dimensions=[_FakeDimension("X"), _FakeDimension("Y"), _FakeDimension("Z")]74 dimensions=[_FakeDimension("X"), _FakeDimension("Y"), _FakeDimension("Z")]
110 )75 )
11176
11277
113_install_dependency_stubs()
114sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))78sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
11579
116from iolabs_point_cloud_trajectory_detect.trajectory_finder import TrajectoryFinder # noqa: E40280from iolabs_point_cloud_trajectory_detect.trajectory_finder import TrajectoryFinder # noqa: E402
117import iolabs_point_cloud_trajectory_detect.trajectory_finder as trajectory_finder # noqa: E40281import iolabs_point_cloud_trajectory_detect.trajectory_finder as trajectory_finder # noqa: E402
11882
83trajectory_finder.laspy.LasData = _FakeLasData
84
11985
120def test_read_las_window_constructs_lasdata_from_window_points(monkeypatch, tmp_path):86def 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)
Importance #10: pyproject.toml @@ -1,16 +1,17 @@
1[project]1[project]
2name = "iolabs-point-cloud-trajectory-detect"2name = "iolabs-point-cloud-trajectory-detect"
3version = "0.5.7"3version = "0.5.8"
4description = "Trajectory extraction from LIDAR point clouds"4description = "Trajectory extraction from LIDAR point clouds"
5requires-python = ">=3.11,<3.13"5requires-python = ">=3.11,<3.13"
6dependencies = [6dependencies = [
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]
1415
15[tool.uv]16[tool.uv]
16publish-url = "https://nexus.iolabs.ch/repository/pypi-private/"17publish-url = "https://nexus.iolabs.ch/repository/pypi-private/"
Importance #11: README.md @@ -22,8 +22,12 @@
22## Usage22## Usage
2323
24Use this package to detect and extract trajectories (e.g. scanner path or vehicle path) from LIDAR point cloud data.24Use this package to detect and extract trajectories (e.g. scanner path or vehicle path) from LIDAR point cloud data.
2525
26## Config
27
28Defaults 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)
2731
28Internal `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:32Internal `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:
2933
Importance #12: pyproject.toml @@ -1,16 +1,17 @@
1[project]1[project]
2name = "iolabs-point-cloud-trajectory-detect"2name = "iolabs-point-cloud-trajectory-detect"
3version = "0.5.7"3version = "0.5.8"
4description = "Trajectory extraction from LIDAR point clouds"4description = "Trajectory extraction from LIDAR point clouds"
5requires-python = ">=3.11,<3.13"5requires-python = ">=3.11,<3.13"
6dependencies = [6dependencies = [
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]
1415
15[tool.uv]16[tool.uv]
16publish-url = "https://nexus.iolabs.ch/repository/pypi-private/"17publish-url = "https://nexus.iolabs.ch/repository/pypi-private/"
Importance #13: src/iolabs_point_cloud_trajectory_detect/__init__.py @@ -1,7 +1,8 @@
1"""Trajectory extraction from LIDAR point clouds."""1"""Trajectory extraction from LIDAR point clouds."""
22
3from ._config import (3from ._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,
Importance #14: src/iolabs_point_cloud_trajectory_detect/__init__.py @@ -9,8 +10,9 @@
9from .trajectory_finder import TrajectoryFinder10from .trajectory_finder import TrajectoryFinder
1011
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",
Importance #15: src/iolabs_point_cloud_trajectory_detect/_config.py @@ -1,90 +1,104 @@
1from __future__ import annotations1from __future__ import annotations
22
3import json3import logging
4from importlib import resources
5from pathlib import Path4from pathlib import Path
6from typing import Any5from typing import Any
76
8ALLOWED_TRAJECTORY_CONFIG_KEYS = frozenset(7from 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)
228
9logger = logging.getLogger(__name__)
2310
24class TrajectoryConfigError(ValueError):11_PACKAGE = "iolabs_point_cloud_trajectory_detect"
25 """Raised when trajectory config contains unsupported keys."""12_DEFAULT_FILENAME = "trajectories.default.json"
2613
2714
28def _default_config_path() -> Path:15class 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")))
3217
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
3328
34def _deep_merge_dicts(29
35 base: dict[str, Any],30class 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
4432
4533
46def _validate_trajectory_config_keys(config: dict[str, Any]) -> None:34def 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
5036
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 )
5739
40 Returns:
41 A plain dict of the validated config.
5842
59def 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
7353
7454
75def load_trajectory_config(config_path: str | Path | None = None) -> dict[str, Any]:55def 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()
8076
8177
82def build_trajectory_config(78def 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()
Importance #16: src/iolabs_point_cloud_trajectory_detect/trajectories.default.json @@ -1,5 +1,8 @@
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,
Importance #17: src/iolabs_point_cloud_trajectory_detect/trajectory_finder.py @@ -13,9 +13,10 @@
1313
14from iolabs.common.version_info import save_version_json14from iolabs.common.version_info import save_version_json
15from iolabs.logstash import get_props_logger15from iolabs.logstash import get_props_logger
16from ._log_props import LOG_PROPS16from ._log_props import LOG_PROPS
17from ._config import load_trajectory_config, normalize_trajectory_config17# load_trajectory_config is re-exported for callers doing trajectory_finder.load_trajectory_config()
18from ._config import load_trajectory_config, normalize_trajectory_config # noqa: F401
1819
1920
20def _write_empty_ply(output_path: Path) -> None:21def _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)
Importance #18: tests/conftest.py @@ -0,0 +1,40 @@
1"""Install lightweight stubs so the package imports without Open3D/LAS/Logstash."""
2
3from __future__ import annotations
4
5import logging
6import sys
7import types
8
9
10def _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
Importance #19: tests/test_config.py @@ -0,0 +1,87 @@
1from __future__ import annotations
2
3import json
4import sys
5from pathlib import Path
6
7import pytest
8
9sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
11from iolabs.common import config_loader # noqa: E402
12from 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
33def test_error_class_is_config_error() -> None:
34 assert issubclass(TrajectoryConfigError, config_loader.ConfigError)
35 assert issubclass(TrajectoryConfigError, ValueError)
36
37
38def 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
44def 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
55def 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
70def 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
75def 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
83def 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
Importance #20: tests/test_trajectory_finder_window.py @@ -8,43 +8,8 @@
8import numpy as np8import numpy as np
9import pytest9import pytest
1010
1111
12def _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
47class _FakeHeader:12class _FakeHeader:
48 def __init__(self, point_count: int) -> None:13 def __init__(self, point_count: int) -> None:
49 self.point_count = point_count14 self.point_count = point_count
5015
Importance #21: tests/test_trajectory_finder_window.py @@ -109,14 +74,15 @@
109 dimensions=[_FakeDimension("X"), _FakeDimension("Y"), _FakeDimension("Z")]74 dimensions=[_FakeDimension("X"), _FakeDimension("Y"), _FakeDimension("Z")]
110 )75 )
11176
11277
113_install_dependency_stubs()
114sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))78sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
11579
116from iolabs_point_cloud_trajectory_detect.trajectory_finder import TrajectoryFinder # noqa: E40280from iolabs_point_cloud_trajectory_detect.trajectory_finder import TrajectoryFinder # noqa: E402
117import iolabs_point_cloud_trajectory_detect.trajectory_finder as trajectory_finder # noqa: E40281import iolabs_point_cloud_trajectory_detect.trajectory_finder as trajectory_finder # noqa: E402
11882
83trajectory_finder.laspy.LasData = _FakeLasData
84
11985
120def test_read_las_window_constructs_lasdata_from_window_points(monkeypatch, tmp_path):86def 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)