Back to report index

Step 5 filteringintensity 448b7a8: AI3D-379 Align config module with fleet pattern

Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:38:09+02:00

Commit #27 · 9 snippets

 README.md                                          |   9 +-
 .../_config.py                                     | 188 +++++++++++++++------
 tests/test_bright_points_config.py                 | 116 -------------
 tests/test_config.py                               | 142 ++++++++++++++++
 4 files changed, 282 insertions(+), 173 deletions(-)
Importance #1: src/iolabs_point_cloud_filtering_intensity/_config.py @@ -1,96 +1,139 @@
1"""Bright-points config: packaged JSON defaults, overrides, pydantic validation."""1"""Bright-points config: packaged JSON defaults, overrides, pydantic validation.
2
3The schema is `BrightPointsConfig` (a `config_loader.ConfigModel`), mirroring
4`bright_points.default.json` key for key.
5
6Adding a config key means adding the field to the model and the same key to
7`bright_points.default.json` nothing else. Unknown keys are rejected.
8
9The entry points return a plain ``dict[str, Any]``; the filter reads it by key.
10"""
11
12from __future__ import annotations
213
3import logging14import logging
4from collections.abc import Mapping15from collections.abc import Mapping
5from pathlib import Path16from pathlib import Path
6from typing import Any, Literal17from typing import Any, Literal, TypeAlias
718
8import pydantic19import pydantic
920
10from iolabs.common import config_loader21from iolabs.common import config_loader
1122
12logger = logging.getLogger(__name__)23logger = logging.getLogger(__name__)
1324
14_PACKAGE = "iolabs_point_cloud_filtering_intensity"25_PACKAGE_NAME = "iolabs_point_cloud_filtering_intensity"
15_DEFAULT_FILENAME = "bright_points.default.json"26_DEFAULT_FILENAME = "bright_points.default.json"
16_CONTEXT = "bright-points config"27_CONTEXT = "bright-points config"
1728
29FilterMode: TypeAlias = Literal[
30 "laser_intensity",
31 "color_intensity",
32 "color_cuts",
33 "simple_intensity_cutoff",
34]
35
1836
19class FileNamingConfig(config_loader.ConfigModel):37class BrightPointsFileNamingConfig(config_loader.ConfigModel):
20 """Output filename suffixes for road-surface input and bright-filtered output."""38 """Output filename suffixes for road-surface input and bright-filtered output."""
2139
22 road_surface_suffix: str = "_run4_road_surface"40 road_surface_suffix: str = "_run4_road_surface"
23 bright_filtered_suffix: str = "_run5_bright_filtered"41 bright_filtered_suffix: str = "_run5_bright_filtered"
2442
2543
26class LaserIntensityFittingConfig(config_loader.ConfigModel):44class BrightPointsLaserIntensityFittingConfig(config_loader.ConfigModel):
27 """Per-scan-angle Gaussian fitting for laser intensity in [0, 65535]."""45 """Per-scan-angle Gaussian fitting for laser intensity in [0, 65535]."""
2846
29 bins: int = 10047 bins: int = pydantic.Field(default=100, ge=1)
30 prominence: float = 30.048 prominence: float = pydantic.Field(default=30.0, ge=0.0)
31 fit_width: float = 10000.049 fit_width: float = pydantic.Field(default=10000.0, gt=0.0)
32 n_sigma: float = 4.050 n_sigma: float = pydantic.Field(default=4.0, gt=0.0)
33 cutoff_n_sigma: float = 7.051 cutoff_n_sigma: float = pydantic.Field(default=7.0, gt=0.0)
34 max_sigma: float = 5000.052 max_sigma: float = pydantic.Field(default=5000.0, gt=0.0)
35 # None means "fall back to the top-level default_sigma".53 # None means "fall back to the top-level default_sigma".
36 default_sigma: float | None = None54 default_sigma: float | None = pydantic.Field(default=None, gt=0.0)
37 min_mu: float = 0.055 min_mu: float = pydantic.Field(default=0.0, ge=0.0)
38 max_mu: float = 68000.056 max_mu: float = pydantic.Field(default=68000.0, ge=0.0)
39 min_sigma: float = 200.057 min_sigma: float = pydantic.Field(default=200.0, gt=0.0)
40 angle_min: float = -80.058 angle_min: float = pydantic.Field(default=-80.0, ge=-180.0, le=180.0)
41 angle_max: float = 80.059 angle_max: float = pydantic.Field(default=80.0, ge=-180.0, le=180.0)
42 angle_step: float = 10.060 angle_step: float = pydantic.Field(default=10.0, gt=0.0)
4361
62 @pydantic.model_validator(mode="after")
63 def _check_bounds(self) -> BrightPointsLaserIntensityFittingConfig:
64 """Reject inverted mu, sigma or scan-angle ranges."""
65 return _check_fitting_bounds(self)
4466
45class ColorIntensityFittingConfig(config_loader.ConfigModel):67
68class BrightPointsColorIntensityFittingConfig(config_loader.ConfigModel):
46 """Per-scan-angle Gaussian fitting for HSI intensity in [0, 100] percent."""69 """Per-scan-angle Gaussian fitting for HSI intensity in [0, 100] percent."""
4770
48 bins: int = 10071 bins: int = pydantic.Field(default=100, ge=1)
49 prominence: float = 30.072 prominence: float = pydantic.Field(default=30.0, ge=0.0)
50 fit_width: float = 15.2673 fit_width: float = pydantic.Field(default=15.26, gt=0.0)
51 n_sigma: float = 4.074 n_sigma: float = pydantic.Field(default=4.0, gt=0.0)
52 cutoff_n_sigma: float = 7.075 cutoff_n_sigma: float = pydantic.Field(default=7.0, gt=0.0)
53 max_sigma: float = 7.6376 max_sigma: float = pydantic.Field(default=7.63, gt=0.0)
54 default_sigma: float = 7.6377 default_sigma: float = pydantic.Field(default=7.63, gt=0.0)
55 min_mu: float = 0.078 min_mu: float = pydantic.Field(default=0.0, ge=0.0)
56 max_mu: float = 100.079 max_mu: float = pydantic.Field(default=100.0, ge=0.0)
57 min_sigma: float = 0.3180 min_sigma: float = pydantic.Field(default=0.31, gt=0.0)
58 angle_min: float = -80.081 angle_min: float = pydantic.Field(default=-80.0, ge=-180.0, le=180.0)
59 angle_max: float = 80.082 angle_max: float = pydantic.Field(default=80.0, ge=-180.0, le=180.0)
60 angle_step: float = 10.083 angle_step: float = pydantic.Field(default=10.0, gt=0.0)
84
85 @pydantic.model_validator(mode="after")
86 def _check_bounds(self) -> BrightPointsColorIntensityFittingConfig:
87 """Reject inverted mu, sigma or scan-angle ranges."""
88 return _check_fitting_bounds(self)
89
90
91_FittingConfig: TypeAlias = (
92 "BrightPointsLaserIntensityFittingConfig | BrightPointsColorIntensityFittingConfig"
93)
94
95
96def _check_fitting_bounds(model: _FittingConfig) -> _FittingConfig:
97 """Reject inverted mu, sigma or scan-angle ranges on a fitting section."""
98 if model.min_mu > model.max_mu:
99 raise ValueError("min_mu must not exceed max_mu")
100 if model.min_sigma > model.max_sigma:
101 raise ValueError("min_sigma must not exceed max_sigma")
102 if model.angle_min >= model.angle_max:
103 raise ValueError("angle_min must be below angle_max")
104 return model
61105
62106
63class BrightPointsConfig(config_loader.ConfigModel):107class BrightPointsConfig(config_loader.ConfigModel):
64 """Top-level bright-points filter config; keys match ``bright_points.default.json``."""108 """Top-level bright-points filter config; keys match ``bright_points.default.json``."""
65109
66 filter_mode: Literal[110 filter_mode: FilterMode = "color_cuts"
67 "laser_intensity",
68 "color_intensity",
69 "color_cuts",
70 "simple_intensity_cutoff",
71 ] = "color_cuts"
72 allow_missing_rgb: bool = False111 allow_missing_rgb: bool = False
73 save_bright_points_pcd: bool = False112 save_bright_points_pcd: bool = False
74 save_all_delta_ply: bool = True113 save_all_delta_ply: bool = True
75 device: str = "cpu"114 device: str = "cpu"
76 intensity_min_cut: float = 20.0115 intensity_min_cut: float = pydantic.Field(default=20.0, ge=0.0, le=100.0)
77 intensity_max_cut: float = 100.0116 intensity_max_cut: float = pydantic.Field(default=100.0, ge=0.0, le=100.0)
78 saturation_min_cut: float = 0.0117 saturation_min_cut: float = pydantic.Field(default=0.0, ge=0.0, le=100.0)
79 saturation_max_cut: float = 30.0118 saturation_max_cut: float = pydantic.Field(default=30.0, ge=0.0, le=100.0)
80 hue_min_cut: float = 0.0119 hue_min_cut: float = pydantic.Field(default=0.0, ge=0.0, le=360.0)
81 hue_max_cut: float = 360.0120 hue_max_cut: float = pydantic.Field(default=360.0, ge=0.0, le=360.0)
82 laser_intensity_min_cut: float = 43000.0121 laser_intensity_min_cut: float = pydantic.Field(default=43000.0, ge=0.0, le=65535.0)
83 laser_intensity_max_cut: float = 65535.0122 laser_intensity_max_cut: float = pydantic.Field(default=65535.0, ge=0.0, le=65535.0)
84 laser_intensity_in_range: bool = True123 laser_intensity_in_range: bool = True
85 intensity_in_range: bool = True124 intensity_in_range: bool = True
86 saturation_in_range: bool = True125 saturation_in_range: bool = True
87 hue_in_range: bool = True126 hue_in_range: bool = True
88 intensity_cutoff: float = 43000.0127 intensity_cutoff: float = pydantic.Field(default=43000.0, ge=0.0, le=65535.0)
89 default_sigma: float = 5000.0128 default_sigma: float = pydantic.Field(default=5000.0, gt=0.0)
90 laser_intensity_fitting: LaserIntensityFittingConfig = LaserIntensityFittingConfig()129 laser_intensity_fitting: BrightPointsLaserIntensityFittingConfig = (
91 color_intensity_fitting: ColorIntensityFittingConfig = ColorIntensityFittingConfig()130 BrightPointsLaserIntensityFittingConfig()
92 file_naming: FileNamingConfig = FileNamingConfig()131 )
132 color_intensity_fitting: BrightPointsColorIntensityFittingConfig = (
133 BrightPointsColorIntensityFittingConfig()
134 )
135 file_naming: BrightPointsFileNamingConfig = BrightPointsFileNamingConfig()
93136
94 @pydantic.field_validator(137 @pydantic.field_validator(
95 "laser_intensity_fitting",138 "laser_intensity_fitting",
96 "color_intensity_fitting",139 "color_intensity_fitting",
Importance #2: src/iolabs_point_cloud_filtering_intensity/_config.py @@ -101,14 +144,32 @@
101 def _null_section_means_defaults(cls, value: Any) -> Any:144 def _null_section_means_defaults(cls, value: Any) -> Any:
102 """Treat an explicit JSON ``null`` section as "use the section defaults"."""145 """Treat an explicit JSON ``null`` section as "use the section defaults"."""
103 return {} if value is None else value146 return {} if value is None else value
104147
148 @pydantic.model_validator(mode="after")
149 def _check_cut_ranges(self) -> BrightPointsConfig:
150 """Reject inverted intensity, saturation, hue or laser-intensity cut ranges."""
151 pairs = (
152 ("intensity", self.intensity_min_cut, self.intensity_max_cut),
153 ("saturation", self.saturation_min_cut, self.saturation_max_cut),
154 ("hue", self.hue_min_cut, self.hue_max_cut),
155 (
156 "laser_intensity",
157 self.laser_intensity_min_cut,
158 self.laser_intensity_max_cut,
159 ),
160 )
161 for name, low, high in pairs:
162 if low > high:
163 raise ValueError(f"{name}_min_cut must not exceed {name}_max_cut")
164 return self
165
105166
106class BrightPointsConfigError(config_loader.ConfigError):167class BrightPointsConfigError(config_loader.ConfigError):
107 """Raised when bright-points config contains unsupported keys or values."""168 """Raised when bright-points config contains unsupported keys or values."""
108169
109170
110def normalize_bright_points_config(raw_config: dict[str, Any]) -> dict[str, Any]:171def normalize_bright_points_config(raw_config: Mapping[str, Any]) -> dict[str, Any]:
111 """Validate *raw_config* and return a complete dict with model defaults filled in."""172 """Validate *raw_config* and return a complete dict with model defaults filled in."""
112 return config_loader.validate_config(173 return config_loader.validate_config(
113 BrightPointsConfig,174 BrightPointsConfig,
114 raw_config,175 raw_config,
Importance #3: src/iolabs_point_cloud_filtering_intensity/_config.py @@ -126,15 +187,30 @@
126 *,187 *,
127 overrides: Mapping[str, Any] | None = None,188 overrides: Mapping[str, Any] | None = None,
128 config_path: str | Path | None = None,189 config_path: str | Path | None = None,
129) -> dict[str, Any]:190) -> dict[str, Any]:
130 """Load defaults, deep-merge *overrides*, and return a validated config dict."""191 """Load defaults, deep-merge *overrides*, and return a validated config dict.
131 logger.debug("Loading bright-points config (config_path=%s)", config_path)192
193 *config_path* replaces the packaged defaults; it does not merge onto them.
194 """
195 return _load_model(overrides=overrides, config_path=config_path).model_dump()
196
197
198def _load_model(
199 *,
200 overrides: Mapping[str, Any] | None = None,
201 config_path: str | Path | None = None,
202) -> BrightPointsConfig:
203 """Load, merge and validate the config, returning the frozen model."""
204 if config_path is not None:
205 logger.info("Config file applied: %s", config_path)
206 if overrides:
207 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
132 return config_loader.load_config(208 return config_loader.load_config(
133 BrightPointsConfig,209 BrightPointsConfig,
134 package=_PACKAGE,210 package=_PACKAGE_NAME,
135 filename=_DEFAULT_FILENAME,211 filename=_DEFAULT_FILENAME,
136 overrides=overrides,212 overrides=overrides,
137 config_path=config_path,213 config_path=config_path,
138 context=_CONTEXT,214 context=_CONTEXT,
139 error_cls=BrightPointsConfigError,215 error_cls=BrightPointsConfigError,
140 ).model_dump()216 )
Importance #4: tests/test_config.py @@ -0,0 +1,142 @@
1import json
2from importlib import resources
3
4import pytest
5
6from iolabs.common import config_loader
7from iolabs_point_cloud_filtering_intensity import _config
8from iolabs_point_cloud_filtering_intensity import (
9 BrightPointsConfigError,
10 build_bright_points_config,
11 load_bright_points_config,
12 normalize_bright_points_config,
13)
14
15
16def test_error_class_is_config_error():
17 assert issubclass(BrightPointsConfigError, config_loader.ConfigError)
18 assert issubclass(BrightPointsConfigError, ValueError)
19
20
21def test_unknown_top_level_key_is_rejected():
22 with pytest.raises(BrightPointsConfigError, match="Unknown bright-points config key"):
23 normalize_bright_points_config({"random_seed": 42})
24
25
26def test_unknown_nested_key_is_rejected():
27 with pytest.raises(
28 BrightPointsConfigError,
29 match="Unknown bright-points config.laser_intensity_fitting key",
30 ):
31 normalize_bright_points_config(
32 {
33 "laser_intensity_fitting": {
34 "unexpected_flag": True,
35 }
36 }
37 )
38
39
40def test_normalize_bright_points_config_uses_current_package_defaults():
41 config = normalize_bright_points_config({})
42
43 assert config["filter_mode"] == "color_cuts"
44 assert config["allow_missing_rgb"] is False
45 assert config["save_all_delta_ply"] is True
46 assert config["default_sigma"] == 5000.0
47 assert config["laser_intensity_fitting"]["bins"] == 100
48 assert config["color_intensity_fitting"]["default_sigma"] == 7.63
49
50
51def test_normalize_bright_points_config_defaults_allow_missing_rgb_false():
52 config = normalize_bright_points_config({})
53
54 assert config["allow_missing_rgb"] is False
55
56
57def test_normalize_bright_points_config_accepts_allow_missing_rgb():
58 config = normalize_bright_points_config({"allow_missing_rgb": True})
59
60 assert config["allow_missing_rgb"] is True
61
62
63def test_load_bright_points_config_returns_packaged_defaults():
64 config = load_bright_points_config()
65
66 assert type(config) is dict
67 assert config["filter_mode"] == "color_cuts"
68 assert "laser_intensity_fitting" in config
69 assert "color_intensity_fitting" in config
70 assert "file_naming" in config
71
72
73def test_overrides_deep_merge_onto_defaults():
74 config = build_bright_points_config(
75 overrides={"laser_intensity_fitting": {"bins": 50}}
76 )
77
78 assert config["laser_intensity_fitting"]["bins"] == 50
79 assert config["laser_intensity_fitting"]["prominence"] == 30.0
80 assert config["filter_mode"] == "color_cuts"
81
82
83def _packaged_json() -> dict:
84 path = resources.files("iolabs_point_cloud_filtering_intensity").joinpath(
85 "bright_points.default.json"
86 )
87 return json.loads(path.read_text(encoding="utf-8"))
88
89
90def test_model_defaults_match_packaged_json():
91 assert _config.BrightPointsConfig().model_dump() == _packaged_json()
92
93
94def test_laser_intensity_fitting_accepts_default_sigma():
95 config = build_bright_points_config(
96 overrides={"laser_intensity_fitting": {"default_sigma": 1234.0}}
97 )
98
99 assert config["laser_intensity_fitting"]["default_sigma"] == 1234.0
100 assert load_bright_points_config()["laser_intensity_fitting"]["default_sigma"] is None
101
102
103@pytest.mark.parametrize(
104 "section",
105 ["laser_intensity_fitting", "color_intensity_fitting", "file_naming"],
106)
107def test_normalize_bright_points_config_accepts_null_section(section):
108 config = normalize_bright_points_config({section: None})
109
110 assert config[section] == _config.BrightPointsConfig().model_dump()[section]
111
112
113def test_set_override_coercion_and_rejection():
114 flat = config_loader.parse_set_overrides(
115 ["allow_missing_rgb=on"], error_cls=BrightPointsConfigError
116 )
117 nested = config_loader.parse_set_overrides(
118 ["laser_intensity_fitting.bins=1e3"],
119 error_cls=BrightPointsConfigError,
120 nested=True,
121 )
122 config = build_bright_points_config(overrides={**flat, **nested})
123
124 assert config["allow_missing_rgb"] is True
125 assert config["laser_intensity_fitting"]["bins"] == 1000
126
127 with pytest.raises(BrightPointsConfigError):
128 build_bright_points_config(
129 overrides=config_loader.parse_set_overrides(
130 ["allow_missing_rgb=flase"], error_cls=BrightPointsConfigError
131 )
132 )
133
134
135def test_out_of_range_value_is_rejected():
136 with pytest.raises(BrightPointsConfigError, match="laser_intensity_fitting.bins"):
137 normalize_bright_points_config({"laser_intensity_fitting": {"bins": 0}})
138
139
140def test_inverted_cut_range_is_rejected():
141 with pytest.raises(BrightPointsConfigError, match="hue_min_cut"):
142 normalize_bright_points_config({"hue_min_cut": 300.0, "hue_max_cut": 10.0})
0
Importance #5: README.md @@ -24,9 +24,16 @@
24Identifies bright lane markings using intensity filtering on top of surface detection. Feeds into cluster-based middle lane detection and trajectory segmentation.24Identifies bright lane markings using intensity filtering on top of surface detection. Feeds into cluster-based middle lane detection and trajectory segmentation.
2525
26## Configuration26## Configuration
2727
28Defaults live in `src/iolabs_point_cloud_filtering_intensity/bright_points.default.json`. The schema is the pydantic model tree in `_config.py` (`BrightPointsConfig` and nested section models). To add a config key, add the field to the model **and** the JSON default; unknown keys are rejected.28Defaults live in `src/iolabs_point_cloud_filtering_intensity/bright_points.default.json`.
29The schema is `BrightPointsConfig` in `_config.py` (a `config_loader.ConfigModel`);
30nested JSON sections are nested models and unknown keys are rejected. **To add a config
31key: add the field (with its type, default and any `Field` range) to the model and the
32same key with the same default to the JSON — nothing else.**
33`load_bright_points_config`, `build_bright_points_config` and
34`normalize_bright_points_config` return a plain `dict`. Runtime overrides come from
35repeatable `--set KEY=VALUE`, never repo-local JSON.
2936
30## Develop locally (Nexus)37## Develop locally (Nexus)
3138
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:39Internal `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:
Importance #6: src/iolabs_point_cloud_filtering_intensity/_config.py @@ -1,96 +1,139 @@
1"""Bright-points config: packaged JSON defaults, overrides, pydantic validation."""1"""Bright-points config: packaged JSON defaults, overrides, pydantic validation.
2
3The schema is `BrightPointsConfig` (a `config_loader.ConfigModel`), mirroring
4`bright_points.default.json` key for key.
5
6Adding a config key means adding the field to the model and the same key to
7`bright_points.default.json` nothing else. Unknown keys are rejected.
8
9The entry points return a plain ``dict[str, Any]``; the filter reads it by key.
10"""
11
12from __future__ import annotations
213
3import logging14import logging
4from collections.abc import Mapping15from collections.abc import Mapping
5from pathlib import Path16from pathlib import Path
6from typing import Any, Literal17from typing import Any, Literal, TypeAlias
718
8import pydantic19import pydantic
920
10from iolabs.common import config_loader21from iolabs.common import config_loader
1122
12logger = logging.getLogger(__name__)23logger = logging.getLogger(__name__)
1324
14_PACKAGE = "iolabs_point_cloud_filtering_intensity"25_PACKAGE_NAME = "iolabs_point_cloud_filtering_intensity"
15_DEFAULT_FILENAME = "bright_points.default.json"26_DEFAULT_FILENAME = "bright_points.default.json"
16_CONTEXT = "bright-points config"27_CONTEXT = "bright-points config"
1728
29FilterMode: TypeAlias = Literal[
30 "laser_intensity",
31 "color_intensity",
32 "color_cuts",
33 "simple_intensity_cutoff",
34]
35
1836
19class FileNamingConfig(config_loader.ConfigModel):37class BrightPointsFileNamingConfig(config_loader.ConfigModel):
20 """Output filename suffixes for road-surface input and bright-filtered output."""38 """Output filename suffixes for road-surface input and bright-filtered output."""
2139
22 road_surface_suffix: str = "_run4_road_surface"40 road_surface_suffix: str = "_run4_road_surface"
23 bright_filtered_suffix: str = "_run5_bright_filtered"41 bright_filtered_suffix: str = "_run5_bright_filtered"
2442
2543
26class LaserIntensityFittingConfig(config_loader.ConfigModel):44class BrightPointsLaserIntensityFittingConfig(config_loader.ConfigModel):
27 """Per-scan-angle Gaussian fitting for laser intensity in [0, 65535]."""45 """Per-scan-angle Gaussian fitting for laser intensity in [0, 65535]."""
2846
29 bins: int = 10047 bins: int = pydantic.Field(default=100, ge=1)
30 prominence: float = 30.048 prominence: float = pydantic.Field(default=30.0, ge=0.0)
31 fit_width: float = 10000.049 fit_width: float = pydantic.Field(default=10000.0, gt=0.0)
32 n_sigma: float = 4.050 n_sigma: float = pydantic.Field(default=4.0, gt=0.0)
33 cutoff_n_sigma: float = 7.051 cutoff_n_sigma: float = pydantic.Field(default=7.0, gt=0.0)
34 max_sigma: float = 5000.052 max_sigma: float = pydantic.Field(default=5000.0, gt=0.0)
35 # None means "fall back to the top-level default_sigma".53 # None means "fall back to the top-level default_sigma".
36 default_sigma: float | None = None54 default_sigma: float | None = pydantic.Field(default=None, gt=0.0)
37 min_mu: float = 0.055 min_mu: float = pydantic.Field(default=0.0, ge=0.0)
38 max_mu: float = 68000.056 max_mu: float = pydantic.Field(default=68000.0, ge=0.0)
39 min_sigma: float = 200.057 min_sigma: float = pydantic.Field(default=200.0, gt=0.0)
40 angle_min: float = -80.058 angle_min: float = pydantic.Field(default=-80.0, ge=-180.0, le=180.0)
41 angle_max: float = 80.059 angle_max: float = pydantic.Field(default=80.0, ge=-180.0, le=180.0)
42 angle_step: float = 10.060 angle_step: float = pydantic.Field(default=10.0, gt=0.0)
4361
62 @pydantic.model_validator(mode="after")
63 def _check_bounds(self) -> BrightPointsLaserIntensityFittingConfig:
64 """Reject inverted mu, sigma or scan-angle ranges."""
65 return _check_fitting_bounds(self)
4466
45class ColorIntensityFittingConfig(config_loader.ConfigModel):67
68class BrightPointsColorIntensityFittingConfig(config_loader.ConfigModel):
46 """Per-scan-angle Gaussian fitting for HSI intensity in [0, 100] percent."""69 """Per-scan-angle Gaussian fitting for HSI intensity in [0, 100] percent."""
4770
48 bins: int = 10071 bins: int = pydantic.Field(default=100, ge=1)
49 prominence: float = 30.072 prominence: float = pydantic.Field(default=30.0, ge=0.0)
50 fit_width: float = 15.2673 fit_width: float = pydantic.Field(default=15.26, gt=0.0)
51 n_sigma: float = 4.074 n_sigma: float = pydantic.Field(default=4.0, gt=0.0)
52 cutoff_n_sigma: float = 7.075 cutoff_n_sigma: float = pydantic.Field(default=7.0, gt=0.0)
53 max_sigma: float = 7.6376 max_sigma: float = pydantic.Field(default=7.63, gt=0.0)
54 default_sigma: float = 7.6377 default_sigma: float = pydantic.Field(default=7.63, gt=0.0)
55 min_mu: float = 0.078 min_mu: float = pydantic.Field(default=0.0, ge=0.0)
56 max_mu: float = 100.079 max_mu: float = pydantic.Field(default=100.0, ge=0.0)
57 min_sigma: float = 0.3180 min_sigma: float = pydantic.Field(default=0.31, gt=0.0)
58 angle_min: float = -80.081 angle_min: float = pydantic.Field(default=-80.0, ge=-180.0, le=180.0)
59 angle_max: float = 80.082 angle_max: float = pydantic.Field(default=80.0, ge=-180.0, le=180.0)
60 angle_step: float = 10.083 angle_step: float = pydantic.Field(default=10.0, gt=0.0)
84
85 @pydantic.model_validator(mode="after")
86 def _check_bounds(self) -> BrightPointsColorIntensityFittingConfig:
87 """Reject inverted mu, sigma or scan-angle ranges."""
88 return _check_fitting_bounds(self)
89
90
91_FittingConfig: TypeAlias = (
92 "BrightPointsLaserIntensityFittingConfig | BrightPointsColorIntensityFittingConfig"
93)
94
95
96def _check_fitting_bounds(model: _FittingConfig) -> _FittingConfig:
97 """Reject inverted mu, sigma or scan-angle ranges on a fitting section."""
98 if model.min_mu > model.max_mu:
99 raise ValueError("min_mu must not exceed max_mu")
100 if model.min_sigma > model.max_sigma:
101 raise ValueError("min_sigma must not exceed max_sigma")
102 if model.angle_min >= model.angle_max:
103 raise ValueError("angle_min must be below angle_max")
104 return model
61105
62106
63class BrightPointsConfig(config_loader.ConfigModel):107class BrightPointsConfig(config_loader.ConfigModel):
64 """Top-level bright-points filter config; keys match ``bright_points.default.json``."""108 """Top-level bright-points filter config; keys match ``bright_points.default.json``."""
65109
66 filter_mode: Literal[110 filter_mode: FilterMode = "color_cuts"
67 "laser_intensity",
68 "color_intensity",
69 "color_cuts",
70 "simple_intensity_cutoff",
71 ] = "color_cuts"
72 allow_missing_rgb: bool = False111 allow_missing_rgb: bool = False
73 save_bright_points_pcd: bool = False112 save_bright_points_pcd: bool = False
74 save_all_delta_ply: bool = True113 save_all_delta_ply: bool = True
75 device: str = "cpu"114 device: str = "cpu"
76 intensity_min_cut: float = 20.0115 intensity_min_cut: float = pydantic.Field(default=20.0, ge=0.0, le=100.0)
77 intensity_max_cut: float = 100.0116 intensity_max_cut: float = pydantic.Field(default=100.0, ge=0.0, le=100.0)
78 saturation_min_cut: float = 0.0117 saturation_min_cut: float = pydantic.Field(default=0.0, ge=0.0, le=100.0)
79 saturation_max_cut: float = 30.0118 saturation_max_cut: float = pydantic.Field(default=30.0, ge=0.0, le=100.0)
80 hue_min_cut: float = 0.0119 hue_min_cut: float = pydantic.Field(default=0.0, ge=0.0, le=360.0)
81 hue_max_cut: float = 360.0120 hue_max_cut: float = pydantic.Field(default=360.0, ge=0.0, le=360.0)
82 laser_intensity_min_cut: float = 43000.0121 laser_intensity_min_cut: float = pydantic.Field(default=43000.0, ge=0.0, le=65535.0)
83 laser_intensity_max_cut: float = 65535.0122 laser_intensity_max_cut: float = pydantic.Field(default=65535.0, ge=0.0, le=65535.0)
84 laser_intensity_in_range: bool = True123 laser_intensity_in_range: bool = True
85 intensity_in_range: bool = True124 intensity_in_range: bool = True
86 saturation_in_range: bool = True125 saturation_in_range: bool = True
87 hue_in_range: bool = True126 hue_in_range: bool = True
88 intensity_cutoff: float = 43000.0127 intensity_cutoff: float = pydantic.Field(default=43000.0, ge=0.0, le=65535.0)
89 default_sigma: float = 5000.0128 default_sigma: float = pydantic.Field(default=5000.0, gt=0.0)
90 laser_intensity_fitting: LaserIntensityFittingConfig = LaserIntensityFittingConfig()129 laser_intensity_fitting: BrightPointsLaserIntensityFittingConfig = (
91 color_intensity_fitting: ColorIntensityFittingConfig = ColorIntensityFittingConfig()130 BrightPointsLaserIntensityFittingConfig()
92 file_naming: FileNamingConfig = FileNamingConfig()131 )
132 color_intensity_fitting: BrightPointsColorIntensityFittingConfig = (
133 BrightPointsColorIntensityFittingConfig()
134 )
135 file_naming: BrightPointsFileNamingConfig = BrightPointsFileNamingConfig()
93136
94 @pydantic.field_validator(137 @pydantic.field_validator(
95 "laser_intensity_fitting",138 "laser_intensity_fitting",
96 "color_intensity_fitting",139 "color_intensity_fitting",
Importance #7: src/iolabs_point_cloud_filtering_intensity/_config.py @@ -101,14 +144,32 @@
101 def _null_section_means_defaults(cls, value: Any) -> Any:144 def _null_section_means_defaults(cls, value: Any) -> Any:
102 """Treat an explicit JSON ``null`` section as "use the section defaults"."""145 """Treat an explicit JSON ``null`` section as "use the section defaults"."""
103 return {} if value is None else value146 return {} if value is None else value
104147
148 @pydantic.model_validator(mode="after")
149 def _check_cut_ranges(self) -> BrightPointsConfig:
150 """Reject inverted intensity, saturation, hue or laser-intensity cut ranges."""
151 pairs = (
152 ("intensity", self.intensity_min_cut, self.intensity_max_cut),
153 ("saturation", self.saturation_min_cut, self.saturation_max_cut),
154 ("hue", self.hue_min_cut, self.hue_max_cut),
155 (
156 "laser_intensity",
157 self.laser_intensity_min_cut,
158 self.laser_intensity_max_cut,
159 ),
160 )
161 for name, low, high in pairs:
162 if low > high:
163 raise ValueError(f"{name}_min_cut must not exceed {name}_max_cut")
164 return self
165
105166
106class BrightPointsConfigError(config_loader.ConfigError):167class BrightPointsConfigError(config_loader.ConfigError):
107 """Raised when bright-points config contains unsupported keys or values."""168 """Raised when bright-points config contains unsupported keys or values."""
108169
109170
110def normalize_bright_points_config(raw_config: dict[str, Any]) -> dict[str, Any]:171def normalize_bright_points_config(raw_config: Mapping[str, Any]) -> dict[str, Any]:
111 """Validate *raw_config* and return a complete dict with model defaults filled in."""172 """Validate *raw_config* and return a complete dict with model defaults filled in."""
112 return config_loader.validate_config(173 return config_loader.validate_config(
113 BrightPointsConfig,174 BrightPointsConfig,
114 raw_config,175 raw_config,
Importance #8: src/iolabs_point_cloud_filtering_intensity/_config.py @@ -126,15 +187,30 @@
126 *,187 *,
127 overrides: Mapping[str, Any] | None = None,188 overrides: Mapping[str, Any] | None = None,
128 config_path: str | Path | None = None,189 config_path: str | Path | None = None,
129) -> dict[str, Any]:190) -> dict[str, Any]:
130 """Load defaults, deep-merge *overrides*, and return a validated config dict."""191 """Load defaults, deep-merge *overrides*, and return a validated config dict.
131 logger.debug("Loading bright-points config (config_path=%s)", config_path)192
193 *config_path* replaces the packaged defaults; it does not merge onto them.
194 """
195 return _load_model(overrides=overrides, config_path=config_path).model_dump()
196
197
198def _load_model(
199 *,
200 overrides: Mapping[str, Any] | None = None,
201 config_path: str | Path | None = None,
202) -> BrightPointsConfig:
203 """Load, merge and validate the config, returning the frozen model."""
204 if config_path is not None:
205 logger.info("Config file applied: %s", config_path)
206 if overrides:
207 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
132 return config_loader.load_config(208 return config_loader.load_config(
133 BrightPointsConfig,209 BrightPointsConfig,
134 package=_PACKAGE,210 package=_PACKAGE_NAME,
135 filename=_DEFAULT_FILENAME,211 filename=_DEFAULT_FILENAME,
136 overrides=overrides,212 overrides=overrides,
137 config_path=config_path,213 config_path=config_path,
138 context=_CONTEXT,214 context=_CONTEXT,
139 error_cls=BrightPointsConfigError,215 error_cls=BrightPointsConfigError,
140 ).model_dump()216 )
Importance #9: tests/test_config.py @@ -0,0 +1,142 @@
1import json
2from importlib import resources
3
4import pytest
5
6from iolabs.common import config_loader
7from iolabs_point_cloud_filtering_intensity import _config
8from iolabs_point_cloud_filtering_intensity import (
9 BrightPointsConfigError,
10 build_bright_points_config,
11 load_bright_points_config,
12 normalize_bright_points_config,
13)
14
15
16def test_error_class_is_config_error():
17 assert issubclass(BrightPointsConfigError, config_loader.ConfigError)
18 assert issubclass(BrightPointsConfigError, ValueError)
19
20
21def test_unknown_top_level_key_is_rejected():
22 with pytest.raises(BrightPointsConfigError, match="Unknown bright-points config key"):
23 normalize_bright_points_config({"random_seed": 42})
24
25
26def test_unknown_nested_key_is_rejected():
27 with pytest.raises(
28 BrightPointsConfigError,
29 match="Unknown bright-points config.laser_intensity_fitting key",
30 ):
31 normalize_bright_points_config(
32 {
33 "laser_intensity_fitting": {
34 "unexpected_flag": True,
35 }
36 }
37 )
38
39
40def test_normalize_bright_points_config_uses_current_package_defaults():
41 config = normalize_bright_points_config({})
42
43 assert config["filter_mode"] == "color_cuts"
44 assert config["allow_missing_rgb"] is False
45 assert config["save_all_delta_ply"] is True
46 assert config["default_sigma"] == 5000.0
47 assert config["laser_intensity_fitting"]["bins"] == 100
48 assert config["color_intensity_fitting"]["default_sigma"] == 7.63
49
50
51def test_normalize_bright_points_config_defaults_allow_missing_rgb_false():
52 config = normalize_bright_points_config({})
53
54 assert config["allow_missing_rgb"] is False
55
56
57def test_normalize_bright_points_config_accepts_allow_missing_rgb():
58 config = normalize_bright_points_config({"allow_missing_rgb": True})
59
60 assert config["allow_missing_rgb"] is True
61
62
63def test_load_bright_points_config_returns_packaged_defaults():
64 config = load_bright_points_config()
65
66 assert type(config) is dict
67 assert config["filter_mode"] == "color_cuts"
68 assert "laser_intensity_fitting" in config
69 assert "color_intensity_fitting" in config
70 assert "file_naming" in config
71
72
73def test_overrides_deep_merge_onto_defaults():
74 config = build_bright_points_config(
75 overrides={"laser_intensity_fitting": {"bins": 50}}
76 )
77
78 assert config["laser_intensity_fitting"]["bins"] == 50
79 assert config["laser_intensity_fitting"]["prominence"] == 30.0
80 assert config["filter_mode"] == "color_cuts"
81
82
83def _packaged_json() -> dict:
84 path = resources.files("iolabs_point_cloud_filtering_intensity").joinpath(
85 "bright_points.default.json"
86 )
87 return json.loads(path.read_text(encoding="utf-8"))
88
89
90def test_model_defaults_match_packaged_json():
91 assert _config.BrightPointsConfig().model_dump() == _packaged_json()
92
93
94def test_laser_intensity_fitting_accepts_default_sigma():
95 config = build_bright_points_config(
96 overrides={"laser_intensity_fitting": {"default_sigma": 1234.0}}
97 )
98
99 assert config["laser_intensity_fitting"]["default_sigma"] == 1234.0
100 assert load_bright_points_config()["laser_intensity_fitting"]["default_sigma"] is None
101
102
103@pytest.mark.parametrize(
104 "section",
105 ["laser_intensity_fitting", "color_intensity_fitting", "file_naming"],
106)
107def test_normalize_bright_points_config_accepts_null_section(section):
108 config = normalize_bright_points_config({section: None})
109
110 assert config[section] == _config.BrightPointsConfig().model_dump()[section]
111
112
113def test_set_override_coercion_and_rejection():
114 flat = config_loader.parse_set_overrides(
115 ["allow_missing_rgb=on"], error_cls=BrightPointsConfigError
116 )
117 nested = config_loader.parse_set_overrides(
118 ["laser_intensity_fitting.bins=1e3"],
119 error_cls=BrightPointsConfigError,
120 nested=True,
121 )
122 config = build_bright_points_config(overrides={**flat, **nested})
123
124 assert config["allow_missing_rgb"] is True
125 assert config["laser_intensity_fitting"]["bins"] == 1000
126
127 with pytest.raises(BrightPointsConfigError):
128 build_bright_points_config(
129 overrides=config_loader.parse_set_overrides(
130 ["allow_missing_rgb=flase"], error_cls=BrightPointsConfigError
131 )
132 )
133
134
135def test_out_of_range_value_is_rejected():
136 with pytest.raises(BrightPointsConfigError, match="laser_intensity_fitting.bins"):
137 normalize_bright_points_config({"laser_intensity_fitting": {"bins": 0}})
138
139
140def test_inverted_cut_range_is_rejected():
141 with pytest.raises(BrightPointsConfigError, match="hue_min_cut"):
142 normalize_bright_points_config({"hue_min_cut": 300.0, "hue_max_cut": 10.0})
0