Back to report index

Step 7b visualizationoverlays a4018c3: AI3D-379 Pydantic config models via iolabs-common ConfigModel

Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:36:01+02:00

Commit #45 · 7 snippets

 README.md                                          |  15 ++
 pyproject.toml                                     |   5 +-
 .../_config.py                                     | 162 +++++++++++++++--
 tests/test_config.py                               | 202 +++++++++++++++++++++
 4 files changed, 369 insertions(+), 15 deletions(-)
Importance #1: src/iolabs_point_cloud_visualization_overlays/_config.py @@ -1,31 +1,167 @@
1"""Load, merge and validate XML top-down overlay configuration.
2
3The pydantic model tree below is the schema and mirrors
4``xml_topdown_overlay.default.json`` exactly: unknown keys fail, and string
5choices are ``Literal``-checked here rather than at the point of use. Loading,
6deep-merging and validation are delegated to :mod:`iolabs.common.config_loader`.
7
8:func:`load_xml_topdown_overlay_config` keeps returning a plain dict, because
9callers (CLI, renderer, collage, LaneFinder) pass and mutate the mapping.
10
11``raster_sets`` stays an open name-to-entry map (the run_7b wrapper selects sets
12by name), but each entry is validated by :class:`RasterSetConfig`.
13
14Adding a config key means adding the field to the model here and the same key to
15``xml_topdown_overlay.default.json`` nothing else.
16"""
17
1from __future__ import annotations18from __future__ import annotations
219
3import json20import json
21import logging
4from pathlib import Path22from pathlib import Path
5from typing import Any23from typing import Annotated, Any, Literal
24
25import pydantic
26from iolabs.common import config_loader
627
7from iolabs.common.config_loader import deep_merge_dicts, load_packaged_json28logger = logging.getLogger(__name__)
829
9_PACKAGE_NAME = "iolabs_point_cloud_visualization_overlays"30_PACKAGE_NAME = "iolabs_point_cloud_visualization_overlays"
10_DEFAULT_CONFIG_NAME = "xml_topdown_overlay.default.json"31_DEFAULT_CONFIG_NAME = "xml_topdown_overlay.default.json"
32_CONTEXT = "xml topdown overlay config"
33
34Channel = Annotated[int, pydantic.Field(ge=0, le=255)]
35Rgba = tuple[Channel, Channel, Channel, Channel]
1136
1237
13class XmlTopdownOverlayConfigError(ValueError):38class XmlTopdownOverlayConfigError(config_loader.ConfigError):
14 """Raised when XML top-down overlay config contains unsupported values."""39 """Raised when XML top-down overlay config contains unsupported values."""
1540
1641
17def _load_raw_default_config() -> dict[str, Any]:42class RasterSetConfig(config_loader.ConfigModel):
18 return load_packaged_json(__package__ or _PACKAGE_NAME, _DEFAULT_CONFIG_NAME)43 """One named raster-set: input tiles, overlay output, and image glob."""
1944
45 input_subdir: str
46 output_subdir: str
47 image_glob: str = "*segment_*_intensity.png"
2048
21def load_xml_topdown_overlay_config(49
22 config_path: str | Path | None = None,50class XmlTopdownOverlayConfig(config_loader.ConfigModel):
23) -> dict[str, Any]:51 """Full overlay config; field names match the packaged JSON keys."""
24 defaults = _load_raw_default_config()52
53 raster_sets: dict[str, RasterSetConfig] = {
54 "step4": RasterSetConfig(
55 input_subdir="topdown_tiles",
56 output_subdir="topdown_tiles_run7_overlay",
57 ),
58 "step6b": RasterSetConfig(
59 input_subdir="topdown_tiles_run6_clusters",
60 output_subdir="topdown_tiles_run6_clusters_run7_overlay",
61 ),
62 }
63 default_raster_sets: list[str] = ["step4"]
64 tile_geoshift_mode: Literal["auto", "subtract", "none"] = "auto"
65 include_feature_types: list[str] = [
66 "Axis of the Edge",
67 "Center Lines",
68 "Central Axis",
69 ]
70 include_alternative_axes: bool = False
71 label_mode: Literal["none", "feature", "child", "both"] = "child"
72 line_width_px: int = pydantic.Field(default=4, ge=1)
73 axis_width_px: int = pydantic.Field(default=5, ge=1)
74 use_measured_width: bool = True
75 flagged_color: Rgba = (255, 60, 60, 255)
76 label_show_width: bool = False
77 label_font_size_px: int = pydantic.Field(default=18, ge=1)
78 label_outline_width_px: int = pydantic.Field(default=2, ge=0)
79 missing_metadata: Literal["error", "skip"] = "error"
80 spline_samples_per_segment: int = pydantic.Field(default=20, ge=1)
81 collage_max_dimension_px: int = pydantic.Field(default=8192, ge=1)
82 collage_pixels_per_meter: float | None = pydantic.Field(default=None, gt=0.0)
83 collage_parts: Literal["auto"] | Annotated[int, pydantic.Field(ge=1)] = "auto"
84 collage_max_parts: int = pydantic.Field(default=5, ge=1)
85 collage_background_rgba: Rgba = (0, 0, 0, 255)
86 collage_label_mode: Literal["none", "feature", "lane"] = "feature"
87 collage_label_font_size_px: int = pydantic.Field(default=28, ge=1)
88 collage_lane_label_spacing_m: float = pydantic.Field(default=250.0, gt=0.0)
89 collage_tile_label_font_size_px: int = pydantic.Field(default=32, ge=1)
90 collage_tile_label_rgba: Rgba = (200, 200, 200, 255)
91 collage_tile_frame_rgba: Rgba = (128, 128, 128, 255)
92 collage_tile_frame_width_px: int = pydantic.Field(default=2, ge=0)
93 collage_output_filename: str = "collage_run7_overlay.png"
94 colors: dict[str, Rgba] = {
95 "Axis of the Edge": (255, 0, 220, 255),
96 "Center Lines": (255, 220, 0, 255),
97 "Central Axis": (0, 220, 255, 255),
98 "Both-Sides Central Axis": (120, 170, 210, 255),
99 "Single-Side Central Axis": (120, 170, 210, 255),
100 "default": (255, 255, 255, 255),
101 }
102
103 @pydantic.model_validator(mode="after")
104 def _check_default_raster_sets(self) -> XmlTopdownOverlayConfig:
105 """Reject default raster-set names that no raster set defines."""
106 unknown = sorted(set(self.default_raster_sets) - set(self.raster_sets))
107 if unknown:
108 raise ValueError(
109 f"Unknown default_raster_sets entries: {', '.join(unknown)}. "
110 f"Known raster sets: {', '.join(sorted(self.raster_sets))}"
111 )
112 return self
113
114
115def _load_model(
116 overrides: dict[str, Any] | None = None,
117) -> XmlTopdownOverlayConfig:
118 """Merge *overrides* onto the packaged defaults and validate the result."""
119 return config_loader.load_config(
120 XmlTopdownOverlayConfig,
121 package=__package__ or _PACKAGE_NAME,
122 filename=_DEFAULT_CONFIG_NAME,
123 overrides=overrides,
124 context=_CONTEXT,
125 error_cls=XmlTopdownOverlayConfigError,
126 )
127
128
129def _read_overrides(config_path: str | Path | None) -> dict[str, Any]:
130 """Read a JSON override file, or return an empty mapping when there is none.
131
132 Raises:
133 XmlTopdownOverlayConfigError: The file is not valid JSON, or is not a
134 JSON object.
135 """
25 if config_path is None:136 if config_path is None:
26 return defaults137 return {}
27 with Path(config_path).open("r", encoding="utf-8") as handle:138 path = Path(config_path)
28 raw = json.load(handle)139 try:
140 with path.open(encoding="utf-8") as handle:
141 raw = json.load(handle)
142 except json.JSONDecodeError as exc:
143 raise XmlTopdownOverlayConfigError(
144 f"Invalid JSON in overlay config file {path}: {exc}"
145 ) from exc
29 if not isinstance(raw, dict):146 if not isinstance(raw, dict):
30 raise XmlTopdownOverlayConfigError("Overlay config must be a JSON object")147 raise XmlTopdownOverlayConfigError("Overlay config must be a JSON object")
31 return deep_merge_dicts(defaults, raw)148 logger.debug("Loaded %s overrides from %s", _CONTEXT, path)
149 return raw
150
151
152def load_xml_topdown_overlay_config(
153 config_path: str | Path | None = None,
154) -> dict[str, Any]:
155 """Load packaged defaults, optionally deep-merged with a JSON override file.
156
157 Args:
158 config_path: Path to a JSON object of partial overrides, or ``None`` for
159 packaged defaults only.
160
161 Returns:
162 The merged, validated configuration as a plain dict.
163
164 Raises:
165 XmlTopdownOverlayConfigError: Unknown key, bad value, or non-object JSON.
166 """
167 return _load_model(overrides=_read_overrides(config_path)).model_dump()
Importance #2: tests/test_config.py @@ -0,0 +1,202 @@
1"""Tests for the packaged XML top-down overlay config."""
2
3from __future__ import annotations
4
5import json
6from pathlib import Path
7
8import pytest
9from iolabs.common import config_loader
10
11from iolabs_point_cloud_visualization_overlays import _config
12
13
14def _write_overrides(tmp_path: Path, payload: dict[str, object]) -> Path:
15 path = tmp_path / "overlay.json"
16 path.write_text(json.dumps(payload), encoding="utf-8")
17 return path
18
19
20def test_packaged_defaults_load_as_plain_dict() -> None:
21 config = _config.load_xml_topdown_overlay_config()
22 assert isinstance(config, dict)
23 assert config["tile_geoshift_mode"] == "auto"
24 assert config["label_mode"] == "child"
25 assert config["missing_metadata"] == "error"
26 assert config["collage_parts"] == "auto"
27 assert config["collage_pixels_per_meter"] is None
28 assert config["raster_sets"]["step4"]["input_subdir"] == "topdown_tiles"
29 assert "Axis of the Edge" in config["colors"]
30 assert sorted(config) == sorted(
31 {
32 "raster_sets",
33 "default_raster_sets",
34 "tile_geoshift_mode",
35 "include_feature_types",
36 "include_alternative_axes",
37 "label_mode",
38 "line_width_px",
39 "axis_width_px",
40 "use_measured_width",
41 "flagged_color",
42 "label_show_width",
43 "label_font_size_px",
44 "label_outline_width_px",
45 "missing_metadata",
46 "spline_samples_per_segment",
47 "collage_max_dimension_px",
48 "collage_pixels_per_meter",
49 "collage_parts",
50 "collage_max_parts",
51 "collage_background_rgba",
52 "collage_label_mode",
53 "collage_label_font_size_px",
54 "collage_lane_label_spacing_m",
55 "collage_tile_label_font_size_px",
56 "collage_tile_label_rgba",
57 "collage_tile_frame_rgba",
58 "collage_tile_frame_width_px",
59 "collage_output_filename",
60 "colors",
61 }
62 )
63
64
65def test_config_path_deep_merges_onto_packaged_defaults(tmp_path: Path) -> None:
66 path = _write_overrides(
67 tmp_path,
68 {"line_width_px": 9, "raster_sets": {"step4": {"image_glob": "*.png"}}},
69 )
70 config = _config.load_xml_topdown_overlay_config(path)
71 assert config["line_width_px"] == 9
72 assert config["tile_geoshift_mode"] == "auto"
73 assert config["raster_sets"]["step4"]["image_glob"] == "*.png"
74 assert config["raster_sets"]["step4"]["input_subdir"] == "topdown_tiles"
75 step6b = config["raster_sets"]["step6b"]
76 assert step6b["input_subdir"] == "topdown_tiles_run6_clusters"
77
78
79def test_unknown_top_level_key_is_rejected(tmp_path: Path) -> None:
80 path = _write_overrides(tmp_path, {"nope": 1})
81 error_cls = _config.XmlTopdownOverlayConfigError
82 with pytest.raises(error_cls, match="Unknown") as excinfo:
83 _config.load_xml_topdown_overlay_config(path)
84 assert "nope" in str(excinfo.value)
85 assert isinstance(excinfo.value, config_loader.ConfigError)
86 assert isinstance(excinfo.value, ValueError)
87
88
89def test_unknown_nested_key_is_rejected(tmp_path: Path) -> None:
90 path = _write_overrides(tmp_path, {"raster_sets": {"step4": {"bogus": 1}}})
91 error_cls = _config.XmlTopdownOverlayConfigError
92 with pytest.raises(error_cls, match="Unknown") as excinfo:
93 _config.load_xml_topdown_overlay_config(path)
94 assert "bogus" in str(excinfo.value)
95
96
97@pytest.mark.parametrize(
98 "overrides",
99 [
100 {"tile_geoshift_mode": "sideways"},
101 {"label_mode": "lane"},
102 {"missing_metadata": "warn"},
103 {"collage_label_mode": "child"},
104 {"collage_max_dimension_px": 0},
105 {"include_alternative_axes": "flase"},
106 {"line_width_px": 3.7},
107 ],
108)
109def test_invalid_values_are_rejected(
110 tmp_path: Path, overrides: dict[str, object]
111) -> None:
112 path = _write_overrides(tmp_path, overrides)
113 with pytest.raises(_config.XmlTopdownOverlayConfigError):
114 _config.load_xml_topdown_overlay_config(path)
115
116
117def test_set_style_strings_coerce_like_the_fleet_matrix(tmp_path: Path) -> None:
118 path = _write_overrides(
119 tmp_path,
120 {
121 "include_alternative_axes": "true",
122 "line_width_px": "8",
123 "collage_lane_label_spacing_m": "100",
124 "collage_parts": 2,
125 },
126 )
127 config = _config.load_xml_topdown_overlay_config(path)
128 assert config["include_alternative_axes"] is True
129 assert config["line_width_px"] == 8
130 assert config["collage_lane_label_spacing_m"] == 100.0
131 assert config["collage_parts"] == 2
132
133
134def test_non_object_overlay_file_is_rejected(tmp_path: Path) -> None:
135 path = tmp_path / "overlay.json"
136 path.write_text("[1, 2]", encoding="utf-8")
137 with pytest.raises(_config.XmlTopdownOverlayConfigError, match="JSON object"):
138 _config.load_xml_topdown_overlay_config(path)
139
140
141def test_model_defaults_match_the_packaged_json() -> None:
142 assert (
143 _config.XmlTopdownOverlayConfig().model_dump()
144 == _config.load_xml_topdown_overlay_config()
145 )
146
147
148def test_extra_raster_set_is_accepted(tmp_path: Path) -> None:
149 path = _write_overrides(
150 tmp_path,
151 {
152 "raster_sets": {"step5": {"input_subdir": "a", "output_subdir": "b"}},
153 "default_raster_sets": ["step5"],
154 },
155 )
156 config = _config.load_xml_topdown_overlay_config(path)
157 assert config["raster_sets"]["step5"]["input_subdir"] == "a"
158 assert config["raster_sets"]["step5"]["image_glob"] == "*segment_*_intensity.png"
159 assert config["raster_sets"]["step4"]["input_subdir"] == "topdown_tiles"
160
161
162def test_unknown_default_raster_set_is_rejected(tmp_path: Path) -> None:
163 path = _write_overrides(tmp_path, {"default_raster_sets": ["step9"]})
164 with pytest.raises(_config.XmlTopdownOverlayConfigError, match="step9"):
165 _config.load_xml_topdown_overlay_config(path)
166
167
168@pytest.mark.parametrize(
169 "overrides",
170 [
171 {"flagged_color": [0, 0, 0, 300]},
172 {"colors": {"default": [-1, 0, 0, 255]}},
173 {"line_width_px": 0},
174 {"collage_parts": 0},
175 {"collage_max_parts": 0},
176 {"collage_pixels_per_meter": 0},
177 {"collage_lane_label_spacing_m": 0},
178 {"label_font_size_px": 0},
179 ],
180)
181def test_out_of_range_values_are_rejected(
182 tmp_path: Path, overrides: dict[str, object]
183) -> None:
184 path = _write_overrides(tmp_path, overrides)
185 with pytest.raises(_config.XmlTopdownOverlayConfigError):
186 _config.load_xml_topdown_overlay_config(path)
187
188
189def test_zero_widths_that_mean_disabled_stay_valid(tmp_path: Path) -> None:
190 path = _write_overrides(
191 tmp_path, {"label_outline_width_px": 0, "collage_tile_frame_width_px": 0}
192 )
193 config = _config.load_xml_topdown_overlay_config(path)
194 assert config["label_outline_width_px"] == 0
195 assert config["collage_tile_frame_width_px"] == 0
196
197
198def test_malformed_overlay_file_raises_config_error(tmp_path: Path) -> None:
199 path = tmp_path / "overlay.json"
200 path.write_text("{oops", encoding="utf-8")
201 with pytest.raises(_config.XmlTopdownOverlayConfigError, match="Invalid JSON"):
202 _config.load_xml_topdown_overlay_config(path)
0
Importance #3: pyproject.toml @@ -1,14 +1,15 @@
1[project]1[project]
2name = "iolabs-point-cloud-visualization-overlays"2name = "iolabs-point-cloud-visualization-overlays"
3version = "0.3.0"3version = "0.3.1"
4description = "2D visualization overlays for point-cloud pipeline outputs"4description = "2D visualization overlays for point-cloud pipeline outputs"
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 "Pillow>=10.0",8 "Pillow>=10.0",
9 "pydantic>=2.7",
9 "iolabs-logstash>=0.5.1",10 "iolabs-logstash>=0.5.1",
10 "iolabs-common>=0.7.0",11 "iolabs-common>=0.8.0",
11 "iolabs-geometry-geometry>=0.11.0",12 "iolabs-geometry-geometry>=0.11.0",
12 "iolabs-geometry-raster>=0.2.0",13 "iolabs-geometry-raster>=0.2.0",
13]14]
1415
Importance #4: README.md @@ -52,4 +52,19 @@
52 [--image-glob "*segment_*_intensity.png"] [--label-mode lane] \52 [--image-glob "*segment_*_intensity.png"] [--label-mode lane] \
53 [--tile-geoshift-mode auto] \53 [--tile-geoshift-mode auto] \
54 [--pixels-per-meter 8.7] [--max-dimension 22000] [--parts auto]54 [--pixels-per-meter 8.7] [--max-dimension 22000] [--parts auto]
55```55```
56
57## Configuration
58
59Defaults live in `src/iolabs_point_cloud_visualization_overlays/xml_topdown_overlay.default.json`
60and are typed by the pydantic model tree in `_config.py`
61(`iolabs.common.config_loader.ConfigModel`). Unknown keys and invalid values
62fail loudly; a JSON override file is deep-merged onto the packaged defaults
63and re-validated.
64
65`raster_sets` is an open name-to-entry map, so an override file can add a set;
66`default_raster_sets` must name sets that exist.
67
68Adding a config key: add the field (with its type, default and any range
69constraint) to the matching model in `_config.py`, and add the same key to
70`xml_topdown_overlay.default.json`. Nothing else.
Importance #5: pyproject.toml @@ -1,14 +1,15 @@
1[project]1[project]
2name = "iolabs-point-cloud-visualization-overlays"2name = "iolabs-point-cloud-visualization-overlays"
3version = "0.3.0"3version = "0.3.1"
4description = "2D visualization overlays for point-cloud pipeline outputs"4description = "2D visualization overlays for point-cloud pipeline outputs"
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 "Pillow>=10.0",8 "Pillow>=10.0",
9 "pydantic>=2.7",
9 "iolabs-logstash>=0.5.1",10 "iolabs-logstash>=0.5.1",
10 "iolabs-common>=0.7.0",11 "iolabs-common>=0.8.0",
11 "iolabs-geometry-geometry>=0.11.0",12 "iolabs-geometry-geometry>=0.11.0",
12 "iolabs-geometry-raster>=0.2.0",13 "iolabs-geometry-raster>=0.2.0",
13]14]
1415
Importance #6: src/iolabs_point_cloud_visualization_overlays/_config.py @@ -1,31 +1,167 @@
1"""Load, merge and validate XML top-down overlay configuration.
2
3The pydantic model tree below is the schema and mirrors
4``xml_topdown_overlay.default.json`` exactly: unknown keys fail, and string
5choices are ``Literal``-checked here rather than at the point of use. Loading,
6deep-merging and validation are delegated to :mod:`iolabs.common.config_loader`.
7
8:func:`load_xml_topdown_overlay_config` keeps returning a plain dict, because
9callers (CLI, renderer, collage, LaneFinder) pass and mutate the mapping.
10
11``raster_sets`` stays an open name-to-entry map (the run_7b wrapper selects sets
12by name), but each entry is validated by :class:`RasterSetConfig`.
13
14Adding a config key means adding the field to the model here and the same key to
15``xml_topdown_overlay.default.json`` nothing else.
16"""
17
1from __future__ import annotations18from __future__ import annotations
219
3import json20import json
21import logging
4from pathlib import Path22from pathlib import Path
5from typing import Any23from typing import Annotated, Any, Literal
24
25import pydantic
26from iolabs.common import config_loader
627
7from iolabs.common.config_loader import deep_merge_dicts, load_packaged_json28logger = logging.getLogger(__name__)
829
9_PACKAGE_NAME = "iolabs_point_cloud_visualization_overlays"30_PACKAGE_NAME = "iolabs_point_cloud_visualization_overlays"
10_DEFAULT_CONFIG_NAME = "xml_topdown_overlay.default.json"31_DEFAULT_CONFIG_NAME = "xml_topdown_overlay.default.json"
32_CONTEXT = "xml topdown overlay config"
33
34Channel = Annotated[int, pydantic.Field(ge=0, le=255)]
35Rgba = tuple[Channel, Channel, Channel, Channel]
1136
1237
13class XmlTopdownOverlayConfigError(ValueError):38class XmlTopdownOverlayConfigError(config_loader.ConfigError):
14 """Raised when XML top-down overlay config contains unsupported values."""39 """Raised when XML top-down overlay config contains unsupported values."""
1540
1641
17def _load_raw_default_config() -> dict[str, Any]:42class RasterSetConfig(config_loader.ConfigModel):
18 return load_packaged_json(__package__ or _PACKAGE_NAME, _DEFAULT_CONFIG_NAME)43 """One named raster-set: input tiles, overlay output, and image glob."""
1944
45 input_subdir: str
46 output_subdir: str
47 image_glob: str = "*segment_*_intensity.png"
2048
21def load_xml_topdown_overlay_config(49
22 config_path: str | Path | None = None,50class XmlTopdownOverlayConfig(config_loader.ConfigModel):
23) -> dict[str, Any]:51 """Full overlay config; field names match the packaged JSON keys."""
24 defaults = _load_raw_default_config()52
53 raster_sets: dict[str, RasterSetConfig] = {
54 "step4": RasterSetConfig(
55 input_subdir="topdown_tiles",
56 output_subdir="topdown_tiles_run7_overlay",
57 ),
58 "step6b": RasterSetConfig(
59 input_subdir="topdown_tiles_run6_clusters",
60 output_subdir="topdown_tiles_run6_clusters_run7_overlay",
61 ),
62 }
63 default_raster_sets: list[str] = ["step4"]
64 tile_geoshift_mode: Literal["auto", "subtract", "none"] = "auto"
65 include_feature_types: list[str] = [
66 "Axis of the Edge",
67 "Center Lines",
68 "Central Axis",
69 ]
70 include_alternative_axes: bool = False
71 label_mode: Literal["none", "feature", "child", "both"] = "child"
72 line_width_px: int = pydantic.Field(default=4, ge=1)
73 axis_width_px: int = pydantic.Field(default=5, ge=1)
74 use_measured_width: bool = True
75 flagged_color: Rgba = (255, 60, 60, 255)
76 label_show_width: bool = False
77 label_font_size_px: int = pydantic.Field(default=18, ge=1)
78 label_outline_width_px: int = pydantic.Field(default=2, ge=0)
79 missing_metadata: Literal["error", "skip"] = "error"
80 spline_samples_per_segment: int = pydantic.Field(default=20, ge=1)
81 collage_max_dimension_px: int = pydantic.Field(default=8192, ge=1)
82 collage_pixels_per_meter: float | None = pydantic.Field(default=None, gt=0.0)
83 collage_parts: Literal["auto"] | Annotated[int, pydantic.Field(ge=1)] = "auto"
84 collage_max_parts: int = pydantic.Field(default=5, ge=1)
85 collage_background_rgba: Rgba = (0, 0, 0, 255)
86 collage_label_mode: Literal["none", "feature", "lane"] = "feature"
87 collage_label_font_size_px: int = pydantic.Field(default=28, ge=1)
88 collage_lane_label_spacing_m: float = pydantic.Field(default=250.0, gt=0.0)
89 collage_tile_label_font_size_px: int = pydantic.Field(default=32, ge=1)
90 collage_tile_label_rgba: Rgba = (200, 200, 200, 255)
91 collage_tile_frame_rgba: Rgba = (128, 128, 128, 255)
92 collage_tile_frame_width_px: int = pydantic.Field(default=2, ge=0)
93 collage_output_filename: str = "collage_run7_overlay.png"
94 colors: dict[str, Rgba] = {
95 "Axis of the Edge": (255, 0, 220, 255),
96 "Center Lines": (255, 220, 0, 255),
97 "Central Axis": (0, 220, 255, 255),
98 "Both-Sides Central Axis": (120, 170, 210, 255),
99 "Single-Side Central Axis": (120, 170, 210, 255),
100 "default": (255, 255, 255, 255),
101 }
102
103 @pydantic.model_validator(mode="after")
104 def _check_default_raster_sets(self) -> XmlTopdownOverlayConfig:
105 """Reject default raster-set names that no raster set defines."""
106 unknown = sorted(set(self.default_raster_sets) - set(self.raster_sets))
107 if unknown:
108 raise ValueError(
109 f"Unknown default_raster_sets entries: {', '.join(unknown)}. "
110 f"Known raster sets: {', '.join(sorted(self.raster_sets))}"
111 )
112 return self
113
114
115def _load_model(
116 overrides: dict[str, Any] | None = None,
117) -> XmlTopdownOverlayConfig:
118 """Merge *overrides* onto the packaged defaults and validate the result."""
119 return config_loader.load_config(
120 XmlTopdownOverlayConfig,
121 package=__package__ or _PACKAGE_NAME,
122 filename=_DEFAULT_CONFIG_NAME,
123 overrides=overrides,
124 context=_CONTEXT,
125 error_cls=XmlTopdownOverlayConfigError,
126 )
127
128
129def _read_overrides(config_path: str | Path | None) -> dict[str, Any]:
130 """Read a JSON override file, or return an empty mapping when there is none.
131
132 Raises:
133 XmlTopdownOverlayConfigError: The file is not valid JSON, or is not a
134 JSON object.
135 """
25 if config_path is None:136 if config_path is None:
26 return defaults137 return {}
27 with Path(config_path).open("r", encoding="utf-8") as handle:138 path = Path(config_path)
28 raw = json.load(handle)139 try:
140 with path.open(encoding="utf-8") as handle:
141 raw = json.load(handle)
142 except json.JSONDecodeError as exc:
143 raise XmlTopdownOverlayConfigError(
144 f"Invalid JSON in overlay config file {path}: {exc}"
145 ) from exc
29 if not isinstance(raw, dict):146 if not isinstance(raw, dict):
30 raise XmlTopdownOverlayConfigError("Overlay config must be a JSON object")147 raise XmlTopdownOverlayConfigError("Overlay config must be a JSON object")
31 return deep_merge_dicts(defaults, raw)148 logger.debug("Loaded %s overrides from %s", _CONTEXT, path)
149 return raw
150
151
152def load_xml_topdown_overlay_config(
153 config_path: str | Path | None = None,
154) -> dict[str, Any]:
155 """Load packaged defaults, optionally deep-merged with a JSON override file.
156
157 Args:
158 config_path: Path to a JSON object of partial overrides, or ``None`` for
159 packaged defaults only.
160
161 Returns:
162 The merged, validated configuration as a plain dict.
163
164 Raises:
165 XmlTopdownOverlayConfigError: Unknown key, bad value, or non-object JSON.
166 """
167 return _load_model(overrides=_read_overrides(config_path)).model_dump()
Importance #7: tests/test_config.py @@ -0,0 +1,202 @@
1"""Tests for the packaged XML top-down overlay config."""
2
3from __future__ import annotations
4
5import json
6from pathlib import Path
7
8import pytest
9from iolabs.common import config_loader
10
11from iolabs_point_cloud_visualization_overlays import _config
12
13
14def _write_overrides(tmp_path: Path, payload: dict[str, object]) -> Path:
15 path = tmp_path / "overlay.json"
16 path.write_text(json.dumps(payload), encoding="utf-8")
17 return path
18
19
20def test_packaged_defaults_load_as_plain_dict() -> None:
21 config = _config.load_xml_topdown_overlay_config()
22 assert isinstance(config, dict)
23 assert config["tile_geoshift_mode"] == "auto"
24 assert config["label_mode"] == "child"
25 assert config["missing_metadata"] == "error"
26 assert config["collage_parts"] == "auto"
27 assert config["collage_pixels_per_meter"] is None
28 assert config["raster_sets"]["step4"]["input_subdir"] == "topdown_tiles"
29 assert "Axis of the Edge" in config["colors"]
30 assert sorted(config) == sorted(
31 {
32 "raster_sets",
33 "default_raster_sets",
34 "tile_geoshift_mode",
35 "include_feature_types",
36 "include_alternative_axes",
37 "label_mode",
38 "line_width_px",
39 "axis_width_px",
40 "use_measured_width",
41 "flagged_color",
42 "label_show_width",
43 "label_font_size_px",
44 "label_outline_width_px",
45 "missing_metadata",
46 "spline_samples_per_segment",
47 "collage_max_dimension_px",
48 "collage_pixels_per_meter",
49 "collage_parts",
50 "collage_max_parts",
51 "collage_background_rgba",
52 "collage_label_mode",
53 "collage_label_font_size_px",
54 "collage_lane_label_spacing_m",
55 "collage_tile_label_font_size_px",
56 "collage_tile_label_rgba",
57 "collage_tile_frame_rgba",
58 "collage_tile_frame_width_px",
59 "collage_output_filename",
60 "colors",
61 }
62 )
63
64
65def test_config_path_deep_merges_onto_packaged_defaults(tmp_path: Path) -> None:
66 path = _write_overrides(
67 tmp_path,
68 {"line_width_px": 9, "raster_sets": {"step4": {"image_glob": "*.png"}}},
69 )
70 config = _config.load_xml_topdown_overlay_config(path)
71 assert config["line_width_px"] == 9
72 assert config["tile_geoshift_mode"] == "auto"
73 assert config["raster_sets"]["step4"]["image_glob"] == "*.png"
74 assert config["raster_sets"]["step4"]["input_subdir"] == "topdown_tiles"
75 step6b = config["raster_sets"]["step6b"]
76 assert step6b["input_subdir"] == "topdown_tiles_run6_clusters"
77
78
79def test_unknown_top_level_key_is_rejected(tmp_path: Path) -> None:
80 path = _write_overrides(tmp_path, {"nope": 1})
81 error_cls = _config.XmlTopdownOverlayConfigError
82 with pytest.raises(error_cls, match="Unknown") as excinfo:
83 _config.load_xml_topdown_overlay_config(path)
84 assert "nope" in str(excinfo.value)
85 assert isinstance(excinfo.value, config_loader.ConfigError)
86 assert isinstance(excinfo.value, ValueError)
87
88
89def test_unknown_nested_key_is_rejected(tmp_path: Path) -> None:
90 path = _write_overrides(tmp_path, {"raster_sets": {"step4": {"bogus": 1}}})
91 error_cls = _config.XmlTopdownOverlayConfigError
92 with pytest.raises(error_cls, match="Unknown") as excinfo:
93 _config.load_xml_topdown_overlay_config(path)
94 assert "bogus" in str(excinfo.value)
95
96
97@pytest.mark.parametrize(
98 "overrides",
99 [
100 {"tile_geoshift_mode": "sideways"},
101 {"label_mode": "lane"},
102 {"missing_metadata": "warn"},
103 {"collage_label_mode": "child"},
104 {"collage_max_dimension_px": 0},
105 {"include_alternative_axes": "flase"},
106 {"line_width_px": 3.7},
107 ],
108)
109def test_invalid_values_are_rejected(
110 tmp_path: Path, overrides: dict[str, object]
111) -> None:
112 path = _write_overrides(tmp_path, overrides)
113 with pytest.raises(_config.XmlTopdownOverlayConfigError):
114 _config.load_xml_topdown_overlay_config(path)
115
116
117def test_set_style_strings_coerce_like_the_fleet_matrix(tmp_path: Path) -> None:
118 path = _write_overrides(
119 tmp_path,
120 {
121 "include_alternative_axes": "true",
122 "line_width_px": "8",
123 "collage_lane_label_spacing_m": "100",
124 "collage_parts": 2,
125 },
126 )
127 config = _config.load_xml_topdown_overlay_config(path)
128 assert config["include_alternative_axes"] is True
129 assert config["line_width_px"] == 8
130 assert config["collage_lane_label_spacing_m"] == 100.0
131 assert config["collage_parts"] == 2
132
133
134def test_non_object_overlay_file_is_rejected(tmp_path: Path) -> None:
135 path = tmp_path / "overlay.json"
136 path.write_text("[1, 2]", encoding="utf-8")
137 with pytest.raises(_config.XmlTopdownOverlayConfigError, match="JSON object"):
138 _config.load_xml_topdown_overlay_config(path)
139
140
141def test_model_defaults_match_the_packaged_json() -> None:
142 assert (
143 _config.XmlTopdownOverlayConfig().model_dump()
144 == _config.load_xml_topdown_overlay_config()
145 )
146
147
148def test_extra_raster_set_is_accepted(tmp_path: Path) -> None:
149 path = _write_overrides(
150 tmp_path,
151 {
152 "raster_sets": {"step5": {"input_subdir": "a", "output_subdir": "b"}},
153 "default_raster_sets": ["step5"],
154 },
155 )
156 config = _config.load_xml_topdown_overlay_config(path)
157 assert config["raster_sets"]["step5"]["input_subdir"] == "a"
158 assert config["raster_sets"]["step5"]["image_glob"] == "*segment_*_intensity.png"
159 assert config["raster_sets"]["step4"]["input_subdir"] == "topdown_tiles"
160
161
162def test_unknown_default_raster_set_is_rejected(tmp_path: Path) -> None:
163 path = _write_overrides(tmp_path, {"default_raster_sets": ["step9"]})
164 with pytest.raises(_config.XmlTopdownOverlayConfigError, match="step9"):
165 _config.load_xml_topdown_overlay_config(path)
166
167
168@pytest.mark.parametrize(
169 "overrides",
170 [
171 {"flagged_color": [0, 0, 0, 300]},
172 {"colors": {"default": [-1, 0, 0, 255]}},
173 {"line_width_px": 0},
174 {"collage_parts": 0},
175 {"collage_max_parts": 0},
176 {"collage_pixels_per_meter": 0},
177 {"collage_lane_label_spacing_m": 0},
178 {"label_font_size_px": 0},
179 ],
180)
181def test_out_of_range_values_are_rejected(
182 tmp_path: Path, overrides: dict[str, object]
183) -> None:
184 path = _write_overrides(tmp_path, overrides)
185 with pytest.raises(_config.XmlTopdownOverlayConfigError):
186 _config.load_xml_topdown_overlay_config(path)
187
188
189def test_zero_widths_that_mean_disabled_stay_valid(tmp_path: Path) -> None:
190 path = _write_overrides(
191 tmp_path, {"label_outline_width_px": 0, "collage_tile_frame_width_px": 0}
192 )
193 config = _config.load_xml_topdown_overlay_config(path)
194 assert config["label_outline_width_px"] == 0
195 assert config["collage_tile_frame_width_px"] == 0
196
197
198def test_malformed_overlay_file_raises_config_error(tmp_path: Path) -> None:
199 path = tmp_path / "overlay.json"
200 path.write_text("{oops", encoding="utf-8")
201 with pytest.raises(_config.XmlTopdownOverlayConfigError, match="Invalid JSON"):
202 _config.load_xml_topdown_overlay_config(path)
0