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(-)
| 1 | """Bright-points config: packaged JSON defaults, overrides, pydantic validation.""" | 1 | """Bright-points config: packaged JSON defaults, overrides, pydantic validation. |
| 2 | |||
| 3 | The schema is `BrightPointsConfig` (a `config_loader.ConfigModel`), mirroring | ||
| 4 | `bright_points.default.json` key for key. | ||
| 5 | |||
| 6 | Adding 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 | |||
| 9 | The entry points return a plain ``dict[str, Any]``; the filter reads it by key. | ||
| 10 | """ | ||
| 11 | |||
| 12 | from __future__ import annotations | ||
| 2 | 13 | ||
| 3 | import logging | 14 | import logging |
| 4 | from collections.abc import Mapping | 15 | from collections.abc import Mapping |
| 5 | from pathlib import Path | 16 | from pathlib import Path |
| 6 | from typing import Any, Literal | 17 | from typing import Any, Literal, TypeAlias |
| 7 | 18 | ||
| 8 | import pydantic | 19 | import pydantic |
| 9 | 20 | ||
| 10 | from iolabs.common import config_loader | 21 | from iolabs.common import config_loader |
| 11 | 22 | ||
| 12 | logger = logging.getLogger(__name__) | 23 | logger = logging.getLogger(__name__) |
| 13 | 24 | ||
| 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" |
| 17 | 28 | ||
| 29 | FilterMode: TypeAlias = Literal[ | ||
| 30 | "laser_intensity", | ||
| 31 | "color_intensity", | ||
| 32 | "color_cuts", | ||
| 33 | "simple_intensity_cutoff", | ||
| 34 | ] | ||
| 35 | |||
| 18 | 36 | ||
| 19 | class FileNamingConfig(config_loader.ConfigModel): | 37 | class 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.""" |
| 21 | 39 | ||
| 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" |
| 24 | 42 | ||
| 25 | 43 | ||
| 26 | class LaserIntensityFittingConfig(config_loader.ConfigModel): | 44 | class 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].""" |
| 28 | 46 | ||
| 29 | bins: int = 100 | 47 | bins: int = pydantic.Field(default=100, ge=1) |
| 30 | prominence: float = 30.0 | 48 | prominence: float = pydantic.Field(default=30.0, ge=0.0) |
| 31 | fit_width: float = 10000.0 | 49 | fit_width: float = pydantic.Field(default=10000.0, gt=0.0) |
| 32 | n_sigma: float = 4.0 | 50 | n_sigma: float = pydantic.Field(default=4.0, gt=0.0) |
| 33 | cutoff_n_sigma: float = 7.0 | 51 | cutoff_n_sigma: float = pydantic.Field(default=7.0, gt=0.0) |
| 34 | max_sigma: float = 5000.0 | 52 | 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 = None | 54 | default_sigma: float | None = pydantic.Field(default=None, gt=0.0) |
| 37 | min_mu: float = 0.0 | 55 | min_mu: float = pydantic.Field(default=0.0, ge=0.0) |
| 38 | max_mu: float = 68000.0 | 56 | max_mu: float = pydantic.Field(default=68000.0, ge=0.0) |
| 39 | min_sigma: float = 200.0 | 57 | min_sigma: float = pydantic.Field(default=200.0, gt=0.0) |
| 40 | angle_min: float = -80.0 | 58 | angle_min: float = pydantic.Field(default=-80.0, ge=-180.0, le=180.0) |
| 41 | angle_max: float = 80.0 | 59 | angle_max: float = pydantic.Field(default=80.0, ge=-180.0, le=180.0) |
| 42 | angle_step: float = 10.0 | 60 | angle_step: float = pydantic.Field(default=10.0, gt=0.0) |
| 43 | 61 | ||
| 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) | ||
| 44 | 66 | ||
| 45 | class ColorIntensityFittingConfig(config_loader.ConfigModel): | 67 | |
| 68 | class 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.""" |
| 47 | 70 | ||
| 48 | bins: int = 100 | 71 | bins: int = pydantic.Field(default=100, ge=1) |
| 49 | prominence: float = 30.0 | 72 | prominence: float = pydantic.Field(default=30.0, ge=0.0) |
| 50 | fit_width: float = 15.26 | 73 | fit_width: float = pydantic.Field(default=15.26, gt=0.0) |
| 51 | n_sigma: float = 4.0 | 74 | n_sigma: float = pydantic.Field(default=4.0, gt=0.0) |
| 52 | cutoff_n_sigma: float = 7.0 | 75 | cutoff_n_sigma: float = pydantic.Field(default=7.0, gt=0.0) |
| 53 | max_sigma: float = 7.63 | 76 | max_sigma: float = pydantic.Field(default=7.63, gt=0.0) |
| 54 | default_sigma: float = 7.63 | 77 | default_sigma: float = pydantic.Field(default=7.63, gt=0.0) |
| 55 | min_mu: float = 0.0 | 78 | min_mu: float = pydantic.Field(default=0.0, ge=0.0) |
| 56 | max_mu: float = 100.0 | 79 | max_mu: float = pydantic.Field(default=100.0, ge=0.0) |
| 57 | min_sigma: float = 0.31 | 80 | min_sigma: float = pydantic.Field(default=0.31, gt=0.0) |
| 58 | angle_min: float = -80.0 | 81 | angle_min: float = pydantic.Field(default=-80.0, ge=-180.0, le=180.0) |
| 59 | angle_max: float = 80.0 | 82 | angle_max: float = pydantic.Field(default=80.0, ge=-180.0, le=180.0) |
| 60 | angle_step: float = 10.0 | 83 | 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 | |||
| 96 | def _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 | ||
| 61 | 105 | ||
| 62 | 106 | ||
| 63 | class BrightPointsConfig(config_loader.ConfigModel): | 107 | class 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``.""" |
| 65 | 109 | ||
| 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 = False | 111 | allow_missing_rgb: bool = False |
| 73 | save_bright_points_pcd: bool = False | 112 | save_bright_points_pcd: bool = False |
| 74 | save_all_delta_ply: bool = True | 113 | save_all_delta_ply: bool = True |
| 75 | device: str = "cpu" | 114 | device: str = "cpu" |
| 76 | intensity_min_cut: float = 20.0 | 115 | intensity_min_cut: float = pydantic.Field(default=20.0, ge=0.0, le=100.0) |
| 77 | intensity_max_cut: float = 100.0 | 116 | intensity_max_cut: float = pydantic.Field(default=100.0, ge=0.0, le=100.0) |
| 78 | saturation_min_cut: float = 0.0 | 117 | saturation_min_cut: float = pydantic.Field(default=0.0, ge=0.0, le=100.0) |
| 79 | saturation_max_cut: float = 30.0 | 118 | saturation_max_cut: float = pydantic.Field(default=30.0, ge=0.0, le=100.0) |
| 80 | hue_min_cut: float = 0.0 | 119 | hue_min_cut: float = pydantic.Field(default=0.0, ge=0.0, le=360.0) |
| 81 | hue_max_cut: float = 360.0 | 120 | hue_max_cut: float = pydantic.Field(default=360.0, ge=0.0, le=360.0) |
| 82 | laser_intensity_min_cut: float = 43000.0 | 121 | laser_intensity_min_cut: float = pydantic.Field(default=43000.0, ge=0.0, le=65535.0) |
| 83 | laser_intensity_max_cut: float = 65535.0 | 122 | laser_intensity_max_cut: float = pydantic.Field(default=65535.0, ge=0.0, le=65535.0) |
| 84 | laser_intensity_in_range: bool = True | 123 | laser_intensity_in_range: bool = True |
| 85 | intensity_in_range: bool = True | 124 | intensity_in_range: bool = True |
| 86 | saturation_in_range: bool = True | 125 | saturation_in_range: bool = True |
| 87 | hue_in_range: bool = True | 126 | hue_in_range: bool = True |
| 88 | intensity_cutoff: float = 43000.0 | 127 | intensity_cutoff: float = pydantic.Field(default=43000.0, ge=0.0, le=65535.0) |
| 89 | default_sigma: float = 5000.0 | 128 | 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() | ||
| 93 | 136 | ||
| 94 | @pydantic.field_validator( | 137 | @pydantic.field_validator( |
| 95 | "laser_intensity_fitting", | 138 | "laser_intensity_fitting", |
| 96 | "color_intensity_fitting", | 139 | "color_intensity_fitting", |
| 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 value | 146 | return {} if value is None else value |
| 104 | 147 | ||
| 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 | |||
| 105 | 166 | ||
| 106 | class BrightPointsConfigError(config_loader.ConfigError): | 167 | class 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.""" |
| 108 | 169 | ||
| 109 | 170 | ||
| 110 | def normalize_bright_points_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 171 | def 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, |
| 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 | |||
| 198 | def _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 | ) |
| 1 | import json | ||
| 2 | from importlib import resources | ||
| 3 | |||
| 4 | import pytest | ||
| 5 | |||
| 6 | from iolabs.common import config_loader | ||
| 7 | from iolabs_point_cloud_filtering_intensity import _config | ||
| 8 | from 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 | |||
| 16 | def test_error_class_is_config_error(): | ||
| 17 | assert issubclass(BrightPointsConfigError, config_loader.ConfigError) | ||
| 18 | assert issubclass(BrightPointsConfigError, ValueError) | ||
| 19 | |||
| 20 | |||
| 21 | def 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 | |||
| 26 | def 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 | |||
| 40 | def 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 | |||
| 51 | def 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 | |||
| 57 | def 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 | |||
| 63 | def 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 | |||
| 73 | def 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 | |||
| 83 | def _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 | |||
| 90 | def test_model_defaults_match_packaged_json(): | ||
| 91 | assert _config.BrightPointsConfig().model_dump() == _packaged_json() | ||
| 92 | |||
| 93 | |||
| 94 | def 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 | ) | ||
| 107 | def 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 | |||
| 113 | def 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 | |||
| 135 | def 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 | |||
| 140 | def 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 |
| 24 | Identifies bright lane markings using intensity filtering on top of surface detection. Feeds into cluster-based middle lane detection and trajectory segmentation. | 24 | Identifies bright lane markings using intensity filtering on top of surface detection. Feeds into cluster-based middle lane detection and trajectory segmentation. |
| 25 | 25 | ||
| 26 | ## Configuration | 26 | ## Configuration |
| 27 | 27 | ||
| 28 | Defaults 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. | 28 | Defaults live in `src/iolabs_point_cloud_filtering_intensity/bright_points.default.json`. |
| 29 | The schema is `BrightPointsConfig` in `_config.py` (a `config_loader.ConfigModel`); | ||
| 30 | nested JSON sections are nested models and unknown keys are rejected. **To add a config | ||
| 31 | key: add the field (with its type, default and any `Field` range) to the model and the | ||
| 32 | same 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 | ||
| 35 | repeatable `--set KEY=VALUE`, never repo-local JSON. | ||
| 29 | 36 | ||
| 30 | ## Develop locally (Nexus) | 37 | ## Develop locally (Nexus) |
| 31 | 38 | ||
| 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: | 39 | 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: |
| 1 | """Bright-points config: packaged JSON defaults, overrides, pydantic validation.""" | 1 | """Bright-points config: packaged JSON defaults, overrides, pydantic validation. |
| 2 | |||
| 3 | The schema is `BrightPointsConfig` (a `config_loader.ConfigModel`), mirroring | ||
| 4 | `bright_points.default.json` key for key. | ||
| 5 | |||
| 6 | Adding 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 | |||
| 9 | The entry points return a plain ``dict[str, Any]``; the filter reads it by key. | ||
| 10 | """ | ||
| 11 | |||
| 12 | from __future__ import annotations | ||
| 2 | 13 | ||
| 3 | import logging | 14 | import logging |
| 4 | from collections.abc import Mapping | 15 | from collections.abc import Mapping |
| 5 | from pathlib import Path | 16 | from pathlib import Path |
| 6 | from typing import Any, Literal | 17 | from typing import Any, Literal, TypeAlias |
| 7 | 18 | ||
| 8 | import pydantic | 19 | import pydantic |
| 9 | 20 | ||
| 10 | from iolabs.common import config_loader | 21 | from iolabs.common import config_loader |
| 11 | 22 | ||
| 12 | logger = logging.getLogger(__name__) | 23 | logger = logging.getLogger(__name__) |
| 13 | 24 | ||
| 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" |
| 17 | 28 | ||
| 29 | FilterMode: TypeAlias = Literal[ | ||
| 30 | "laser_intensity", | ||
| 31 | "color_intensity", | ||
| 32 | "color_cuts", | ||
| 33 | "simple_intensity_cutoff", | ||
| 34 | ] | ||
| 35 | |||
| 18 | 36 | ||
| 19 | class FileNamingConfig(config_loader.ConfigModel): | 37 | class 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.""" |
| 21 | 39 | ||
| 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" |
| 24 | 42 | ||
| 25 | 43 | ||
| 26 | class LaserIntensityFittingConfig(config_loader.ConfigModel): | 44 | class 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].""" |
| 28 | 46 | ||
| 29 | bins: int = 100 | 47 | bins: int = pydantic.Field(default=100, ge=1) |
| 30 | prominence: float = 30.0 | 48 | prominence: float = pydantic.Field(default=30.0, ge=0.0) |
| 31 | fit_width: float = 10000.0 | 49 | fit_width: float = pydantic.Field(default=10000.0, gt=0.0) |
| 32 | n_sigma: float = 4.0 | 50 | n_sigma: float = pydantic.Field(default=4.0, gt=0.0) |
| 33 | cutoff_n_sigma: float = 7.0 | 51 | cutoff_n_sigma: float = pydantic.Field(default=7.0, gt=0.0) |
| 34 | max_sigma: float = 5000.0 | 52 | 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 = None | 54 | default_sigma: float | None = pydantic.Field(default=None, gt=0.0) |
| 37 | min_mu: float = 0.0 | 55 | min_mu: float = pydantic.Field(default=0.0, ge=0.0) |
| 38 | max_mu: float = 68000.0 | 56 | max_mu: float = pydantic.Field(default=68000.0, ge=0.0) |
| 39 | min_sigma: float = 200.0 | 57 | min_sigma: float = pydantic.Field(default=200.0, gt=0.0) |
| 40 | angle_min: float = -80.0 | 58 | angle_min: float = pydantic.Field(default=-80.0, ge=-180.0, le=180.0) |
| 41 | angle_max: float = 80.0 | 59 | angle_max: float = pydantic.Field(default=80.0, ge=-180.0, le=180.0) |
| 42 | angle_step: float = 10.0 | 60 | angle_step: float = pydantic.Field(default=10.0, gt=0.0) |
| 43 | 61 | ||
| 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) | ||
| 44 | 66 | ||
| 45 | class ColorIntensityFittingConfig(config_loader.ConfigModel): | 67 | |
| 68 | class 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.""" |
| 47 | 70 | ||
| 48 | bins: int = 100 | 71 | bins: int = pydantic.Field(default=100, ge=1) |
| 49 | prominence: float = 30.0 | 72 | prominence: float = pydantic.Field(default=30.0, ge=0.0) |
| 50 | fit_width: float = 15.26 | 73 | fit_width: float = pydantic.Field(default=15.26, gt=0.0) |
| 51 | n_sigma: float = 4.0 | 74 | n_sigma: float = pydantic.Field(default=4.0, gt=0.0) |
| 52 | cutoff_n_sigma: float = 7.0 | 75 | cutoff_n_sigma: float = pydantic.Field(default=7.0, gt=0.0) |
| 53 | max_sigma: float = 7.63 | 76 | max_sigma: float = pydantic.Field(default=7.63, gt=0.0) |
| 54 | default_sigma: float = 7.63 | 77 | default_sigma: float = pydantic.Field(default=7.63, gt=0.0) |
| 55 | min_mu: float = 0.0 | 78 | min_mu: float = pydantic.Field(default=0.0, ge=0.0) |
| 56 | max_mu: float = 100.0 | 79 | max_mu: float = pydantic.Field(default=100.0, ge=0.0) |
| 57 | min_sigma: float = 0.31 | 80 | min_sigma: float = pydantic.Field(default=0.31, gt=0.0) |
| 58 | angle_min: float = -80.0 | 81 | angle_min: float = pydantic.Field(default=-80.0, ge=-180.0, le=180.0) |
| 59 | angle_max: float = 80.0 | 82 | angle_max: float = pydantic.Field(default=80.0, ge=-180.0, le=180.0) |
| 60 | angle_step: float = 10.0 | 83 | 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 | |||
| 96 | def _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 | ||
| 61 | 105 | ||
| 62 | 106 | ||
| 63 | class BrightPointsConfig(config_loader.ConfigModel): | 107 | class 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``.""" |
| 65 | 109 | ||
| 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 = False | 111 | allow_missing_rgb: bool = False |
| 73 | save_bright_points_pcd: bool = False | 112 | save_bright_points_pcd: bool = False |
| 74 | save_all_delta_ply: bool = True | 113 | save_all_delta_ply: bool = True |
| 75 | device: str = "cpu" | 114 | device: str = "cpu" |
| 76 | intensity_min_cut: float = 20.0 | 115 | intensity_min_cut: float = pydantic.Field(default=20.0, ge=0.0, le=100.0) |
| 77 | intensity_max_cut: float = 100.0 | 116 | intensity_max_cut: float = pydantic.Field(default=100.0, ge=0.0, le=100.0) |
| 78 | saturation_min_cut: float = 0.0 | 117 | saturation_min_cut: float = pydantic.Field(default=0.0, ge=0.0, le=100.0) |
| 79 | saturation_max_cut: float = 30.0 | 118 | saturation_max_cut: float = pydantic.Field(default=30.0, ge=0.0, le=100.0) |
| 80 | hue_min_cut: float = 0.0 | 119 | hue_min_cut: float = pydantic.Field(default=0.0, ge=0.0, le=360.0) |
| 81 | hue_max_cut: float = 360.0 | 120 | hue_max_cut: float = pydantic.Field(default=360.0, ge=0.0, le=360.0) |
| 82 | laser_intensity_min_cut: float = 43000.0 | 121 | laser_intensity_min_cut: float = pydantic.Field(default=43000.0, ge=0.0, le=65535.0) |
| 83 | laser_intensity_max_cut: float = 65535.0 | 122 | laser_intensity_max_cut: float = pydantic.Field(default=65535.0, ge=0.0, le=65535.0) |
| 84 | laser_intensity_in_range: bool = True | 123 | laser_intensity_in_range: bool = True |
| 85 | intensity_in_range: bool = True | 124 | intensity_in_range: bool = True |
| 86 | saturation_in_range: bool = True | 125 | saturation_in_range: bool = True |
| 87 | hue_in_range: bool = True | 126 | hue_in_range: bool = True |
| 88 | intensity_cutoff: float = 43000.0 | 127 | intensity_cutoff: float = pydantic.Field(default=43000.0, ge=0.0, le=65535.0) |
| 89 | default_sigma: float = 5000.0 | 128 | 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() | ||
| 93 | 136 | ||
| 94 | @pydantic.field_validator( | 137 | @pydantic.field_validator( |
| 95 | "laser_intensity_fitting", | 138 | "laser_intensity_fitting", |
| 96 | "color_intensity_fitting", | 139 | "color_intensity_fitting", |
| 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 value | 146 | return {} if value is None else value |
| 104 | 147 | ||
| 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 | |||
| 105 | 166 | ||
| 106 | class BrightPointsConfigError(config_loader.ConfigError): | 167 | class 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.""" |
| 108 | 169 | ||
| 109 | 170 | ||
| 110 | def normalize_bright_points_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 171 | def 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, |
| 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 | |||
| 198 | def _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 | ) |
| 1 | import json | ||
| 2 | from importlib import resources | ||
| 3 | |||
| 4 | import pytest | ||
| 5 | |||
| 6 | from iolabs.common import config_loader | ||
| 7 | from iolabs_point_cloud_filtering_intensity import _config | ||
| 8 | from 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 | |||
| 16 | def test_error_class_is_config_error(): | ||
| 17 | assert issubclass(BrightPointsConfigError, config_loader.ConfigError) | ||
| 18 | assert issubclass(BrightPointsConfigError, ValueError) | ||
| 19 | |||
| 20 | |||
| 21 | def 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 | |||
| 26 | def 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 | |||
| 40 | def 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 | |||
| 51 | def 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 | |||
| 57 | def 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 | |||
| 63 | def 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 | |||
| 73 | def 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 | |||
| 83 | def _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 | |||
| 90 | def test_model_defaults_match_packaged_json(): | ||
| 91 | assert _config.BrightPointsConfig().model_dump() == _packaged_json() | ||
| 92 | |||
| 93 | |||
| 94 | def 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 | ) | ||
| 107 | def 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 | |||
| 113 | def 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 | |||
| 135 | def 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 | |||
| 140 | def 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 |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.