Back to report index

Step 7b visualizationoverlays 28ca211: AI3D-379 Align config module with fleet pattern

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

Commit #47 · 39 snippets

 README.md                                          |  26 ++--
 .../__init__.py                                    |  14 ++
 .../_config.py                                     | 154 +++++++++++++++------
 .../cli.py                                         |  11 +-
 .../collage_cli.py                                 |  11 +-
 tests/test_config.py                               |  71 +++++++++-
 6 files changed, 215 insertions(+), 72 deletions(-)
Importance #1: src/iolabs_point_cloud_visualization_overlays/_config.py @@ -1,47 +1,51 @@
1"""Load, merge and validate XML top-down overlay configuration.1"""Load, merge and validate the XML top-down overlay configuration.
22
3The pydantic model tree below is the schema and mirrors3The schema is `XmlTopdownOverlayConfig` (a `config_loader.ConfigModel`),
4``xml_topdown_overlay.default.json`` exactly: unknown keys fail, and string4mirroring ``xml_topdown_overlay.default.json`` key for key: unknown keys fail,
5choices are ``Literal``-checked here rather than at the point of use. Loading,5and string choices are ``Literal``-checked here rather than at the point of use.
6deep-merging and validation are delegated to :mod:`iolabs.common.config_loader`.
76
8:func:`load_xml_topdown_overlay_config` keeps returning a plain dict, because7Adding a config key means adding the field to the model and the same key to
9callers (CLI, renderer, collage, LaneFinder) pass and mutate the mapping.8``xml_topdown_overlay.default.json`` nothing else. Unknown keys are rejected.
109
11``raster_sets`` stays an open name-to-entry map (the run_7b wrapper selects sets10The entry points return a plain ``dict``, because callers (CLI, renderer,
12by name), but each entry is validated by :class:`RasterSetConfig`.11collage, LaneFinder ``run_7b``) copy and mutate the mapping; sequence fields
1312therefore stay ``list`` so the dump stays mutable. ``raster_sets`` stays an open
14Adding a config key means adding the field to the model here and the same key to13name-to-entry map (the run_7b wrapper selects sets by name), but each entry is
15``xml_topdown_overlay.default.json`` nothing else.14validated by :class:`XmlTopdownOverlayRasterSetConfig`.
16"""15"""
1716
18from __future__ import annotations17from __future__ import annotations
1918
20import json19import json
21import logging20import logging
22from collections.abc import Mapping21from collections.abc import Mapping
23from pathlib import Path22from pathlib import Path
24from typing import Annotated, Any, Literal23from typing import Annotated, Any, Literal, TypeAlias
2524
26import pydantic25import pydantic
27from iolabs.common import config_loader26from iolabs.common import config_loader
2827
29logger = logging.getLogger(__name__)28logger = logging.getLogger(__name__)
3029
31_PACKAGE_NAME = "iolabs_point_cloud_visualization_overlays"30_PACKAGE_NAME = "iolabs_point_cloud_visualization_overlays"
32_DEFAULT_CONFIG_NAME = "xml_topdown_overlay.default.json"31_DEFAULT_FILENAME = "xml_topdown_overlay.default.json"
33_CONTEXT = "xml topdown overlay config"32_CONTEXT = "xml topdown overlay config"
3433
35Channel = Annotated[int, pydantic.Field(ge=0, le=255)]34Channel: TypeAlias = Annotated[int, pydantic.Field(ge=0, le=255)]
36Rgba = tuple[Channel, Channel, Channel, Channel]35Rgba: TypeAlias = tuple[Channel, Channel, Channel, Channel]
36
37TileGeoshiftMode: TypeAlias = Literal["auto", "subtract", "none"]
38LabelMode: TypeAlias = Literal["none", "feature", "child", "both"]
39MissingMetadata: TypeAlias = Literal["error", "skip"]
40CollageLabelMode: TypeAlias = Literal["none", "feature", "lane"]
3741
3842
39class XmlTopdownOverlayConfigError(config_loader.ConfigError):43class XmlTopdownOverlayConfigError(config_loader.ConfigError):
40 """Raised when XML top-down overlay config contains unsupported values."""44 """Raised when xml topdown overlay config contains unsupported keys or values."""
4145
4246
43class RasterSetConfig(config_loader.ConfigModel):47class XmlTopdownOverlayRasterSetConfig(config_loader.ConfigModel):
44 """One named raster-set: input tiles, overlay output, and image glob."""48 """One named raster-set: input tiles, overlay output, and image glob."""
4549
46 input_subdir: str50 input_subdir: str
47 output_subdir: str51 output_subdir: str
Importance #2: src/iolabs_point_cloud_visualization_overlays/_config.py @@ -50,42 +54,42 @@
5054
51class XmlTopdownOverlayConfig(config_loader.ConfigModel):55class XmlTopdownOverlayConfig(config_loader.ConfigModel):
52 """Full overlay config; field names match the packaged JSON keys."""56 """Full overlay config; field names match the packaged JSON keys."""
5357
54 raster_sets: dict[str, RasterSetConfig] = {58 raster_sets: dict[str, XmlTopdownOverlayRasterSetConfig] = {
55 "step4": RasterSetConfig(59 "step4": XmlTopdownOverlayRasterSetConfig(
56 input_subdir="topdown_tiles",60 input_subdir="topdown_tiles",
57 output_subdir="topdown_tiles_run7_overlay",61 output_subdir="topdown_tiles_run7_overlay",
58 ),62 ),
59 "step6b": RasterSetConfig(63 "step6b": XmlTopdownOverlayRasterSetConfig(
60 input_subdir="topdown_tiles_run6_clusters",64 input_subdir="topdown_tiles_run6_clusters",
61 output_subdir="topdown_tiles_run6_clusters_run7_overlay",65 output_subdir="topdown_tiles_run6_clusters_run7_overlay",
62 ),66 ),
63 }67 }
64 default_raster_sets: list[str] = ["step4"]68 default_raster_sets: list[str] = ["step4"]
65 tile_geoshift_mode: Literal["auto", "subtract", "none"] = "auto"69 tile_geoshift_mode: TileGeoshiftMode = "auto"
66 include_feature_types: list[str] = [70 include_feature_types: list[str] = [
67 "Axis of the Edge",71 "Axis of the Edge",
68 "Center Lines",72 "Center Lines",
69 "Central Axis",73 "Central Axis",
70 ]74 ]
71 include_alternative_axes: bool = False75 include_alternative_axes: bool = False
72 label_mode: Literal["none", "feature", "child", "both"] = "child"76 label_mode: LabelMode = "child"
73 line_width_px: int = pydantic.Field(default=4, ge=1)77 line_width_px: int = pydantic.Field(default=4, ge=1)
74 axis_width_px: int = pydantic.Field(default=5, ge=1)78 axis_width_px: int = pydantic.Field(default=5, ge=1)
75 use_measured_width: bool = True79 use_measured_width: bool = True
76 flagged_color: Rgba = (255, 60, 60, 255)80 flagged_color: Rgba = (255, 60, 60, 255)
77 label_show_width: bool = False81 label_show_width: bool = False
78 label_font_size_px: int = pydantic.Field(default=18, ge=1)82 label_font_size_px: int = pydantic.Field(default=18, ge=1)
79 label_outline_width_px: int = pydantic.Field(default=2, ge=0)83 label_outline_width_px: int = pydantic.Field(default=2, ge=0)
80 missing_metadata: Literal["error", "skip"] = "error"84 missing_metadata: MissingMetadata = "error"
81 spline_samples_per_segment: int = pydantic.Field(default=20, ge=1)85 spline_samples_per_segment: int = pydantic.Field(default=20, ge=1)
82 collage_max_dimension_px: int = pydantic.Field(default=8192, ge=1)86 collage_max_dimension_px: int = pydantic.Field(default=8192, ge=1)
83 collage_pixels_per_meter: float | None = pydantic.Field(default=None, gt=0.0)87 collage_pixels_per_meter: float | None = pydantic.Field(default=None, gt=0.0)
84 collage_parts: Literal["auto"] | Annotated[int, pydantic.Field(ge=1)] = "auto"88 collage_parts: Literal["auto"] | Annotated[int, pydantic.Field(ge=1)] = "auto"
85 collage_max_parts: int = pydantic.Field(default=5, ge=1)89 collage_max_parts: int = pydantic.Field(default=5, ge=1)
86 collage_background_rgba: Rgba = (0, 0, 0, 255)90 collage_background_rgba: Rgba = (0, 0, 0, 255)
87 collage_label_mode: Literal["none", "feature", "lane"] = "feature"91 collage_label_mode: CollageLabelMode = "feature"
88 collage_label_font_size_px: int = pydantic.Field(default=28, ge=1)92 collage_label_font_size_px: int = pydantic.Field(default=28, ge=1)
89 collage_lane_label_spacing_m: float = pydantic.Field(default=250.0, gt=0.0)93 collage_lane_label_spacing_m: float = pydantic.Field(default=250.0, gt=0.0)
90 collage_tile_label_font_size_px: int = pydantic.Field(default=32, ge=1)94 collage_tile_label_font_size_px: int = pydantic.Field(default=32, ge=1)
91 collage_tile_label_rgba: Rgba = (200, 200, 200, 255)95 collage_tile_label_rgba: Rgba = (200, 200, 200, 255)
Importance #3: src/iolabs_point_cloud_visualization_overlays/_config.py @@ -187,16 +180,85 @@
187 with path.open(encoding="utf-8") as handle:180 with path.open(encoding="utf-8") as handle:
188 raw = json.load(handle)181 raw = json.load(handle)
189 except json.JSONDecodeError as exc:182 except json.JSONDecodeError as exc:
190 raise XmlTopdownOverlayConfigError(183 raise XmlTopdownOverlayConfigError(
191 f"Invalid JSON in overlay config file {path}: {exc}"184 f"Invalid JSON in config file {path}: {exc}"
192 ) from exc185 ) from exc
193 if not isinstance(raw, dict):186 if not isinstance(raw, dict):
194 raise XmlTopdownOverlayConfigError("Overlay config must be a JSON object")187 raise XmlTopdownOverlayConfigError(
195 logger.debug("Loaded %s overrides from %s", _CONTEXT, path)188 f"Config file {path} must hold a JSON object, got {type(raw).__name__}"
189 )
196 return raw190 return raw
197191
198192
193def _load_model(
194 *,
195 overrides: Mapping[str, Any] | None = None,
196 config_path: str | Path | None = None,
197) -> XmlTopdownOverlayConfig:
198 """Deep-merge the config file and *overrides* onto the packaged defaults."""
199 merged: dict[str, Any] = _load_json_overrides(config_path)
200 if config_path is not None:
201 logger.info("Config file applied: %s", config_path)
202 if overrides:
203 merged = config_loader.deep_merge_dicts(merged, dict(overrides))
204 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
205 return config_loader.load_config(
206 XmlTopdownOverlayConfig,
207 package=_PACKAGE_NAME,
208 filename=_DEFAULT_FILENAME,
209 overrides=merged,
210 context=_CONTEXT,
211 error_cls=XmlTopdownOverlayConfigError,
212 )
213
214
215def normalize_xml_topdown_overlay_config(
216 raw_config: Mapping[str, Any],
217) -> dict[str, Any]:
218 """Validate *raw_config* and fill in the model defaults.
219
220 Args:
221 raw_config: A full or partial overlay config mapping.
222
223 Returns:
224 The validated configuration as a plain dict.
225
226 Raises:
227 XmlTopdownOverlayConfigError: Unknown key or bad value.
228 """
229 return config_loader.validate_config(
230 XmlTopdownOverlayConfig,
231 raw_config,
232 context=_CONTEXT,
233 error_cls=XmlTopdownOverlayConfigError,
234 ).model_dump()
235
236
237def build_xml_topdown_overlay_config(
238 *,
239 overrides: Mapping[str, Any] | None = None,
240 config_path: str | Path | None = None,
241) -> dict[str, Any]:
242 """Build the overlay config from defaults, a config file and *overrides*.
243
244 Unlike the replace semantics of the shared loader, *config_path* is
245 deep-merged **onto** the packaged defaults; *overrides* (e.g. the result of
246 ``config_loader.parse_set_overrides``) is merged on top of that.
247
248 Args:
249 overrides: Mapping deep-merged onto the defaults, or ``None``.
250 config_path: Path to a JSON object of partial overrides, or ``None``.
251
252 Returns:
253 The merged, validated configuration as a plain dict.
254
255 Raises:
256 XmlTopdownOverlayConfigError: Unknown key, bad value, or non-object JSON.
257 """
258 return _load_model(overrides=overrides, config_path=config_path).model_dump()
259
260
199def load_xml_topdown_overlay_config(261def load_xml_topdown_overlay_config(
200 config_path: str | Path | None = None,262 config_path: str | Path | None = None,
201) -> dict[str, Any]:263) -> dict[str, Any]:
202 """Load packaged defaults, optionally deep-merged with a JSON override file.264 """Load packaged defaults, optionally deep-merged with a JSON override file.
Importance #4: src/iolabs_point_cloud_visualization_overlays/_config.py @@ -158,28 +162,17 @@
158 )162 )
159 return self163 return self
160164
161165
162def _load_model(166def _load_json_overrides(config_path: str | Path | None) -> dict[str, Any]:
163 overrides: dict[str, Any] | None = None,
164) -> XmlTopdownOverlayConfig:
165 """Merge *overrides* onto the packaged defaults and validate the result."""
166 return config_loader.load_config(
167 XmlTopdownOverlayConfig,
168 package=__package__ or _PACKAGE_NAME,
169 filename=_DEFAULT_CONFIG_NAME,
170 overrides=overrides,
171 context=_CONTEXT,
172 error_cls=XmlTopdownOverlayConfigError,
173 )
174
175
176def _read_overrides(config_path: str | Path | None) -> dict[str, Any]:
177 """Read a JSON override file, or return an empty mapping when there is none.167 """Read a JSON override file, or return an empty mapping when there is none.
178168
169 Local stand-in for the shared ``config_loader.load_json_overrides`` helper;
170 drop it once the shared layer exports one.
171
179 Raises:172 Raises:
180 XmlTopdownOverlayConfigError: The file is not valid JSON, or is not a173 XmlTopdownOverlayConfigError: The file is not valid JSON, or does not
181 JSON object.174 hold a JSON object.
182 """175 """
183 if config_path is None:176 if config_path is None:
184 return {}177 return {}
185 path = Path(config_path)178 path = Path(config_path)
Importance #5: src/iolabs_point_cloud_visualization_overlays/_config.py @@ -210,5 +272,5 @@
210272
211 Raises:273 Raises:
212 XmlTopdownOverlayConfigError: Unknown key, bad value, or non-object JSON.274 XmlTopdownOverlayConfigError: Unknown key, bad value, or non-object JSON.
213 """275 """
214 return _load_model(overrides=_read_overrides(config_path)).model_dump()276 return build_xml_topdown_overlay_config(config_path=config_path)
Importance #6: src/iolabs_point_cloud_visualization_overlays/__init__.py @@ -1,11 +1,25 @@
1"""Visualization overlay helpers for point-cloud pipeline outputs."""1"""Visualization overlay helpers for point-cloud pipeline outputs."""
22
3from ._config import (
4 XmlTopdownOverlayConfig,
5 XmlTopdownOverlayConfigError,
6 XmlTopdownOverlayRasterSetConfig,
7 build_xml_topdown_overlay_config,
8 load_xml_topdown_overlay_config,
9 normalize_xml_topdown_overlay_config,
10)
3from .collage import CollageSummary, overlay_xml_on_topdown_collage11from .collage import CollageSummary, overlay_xml_on_topdown_collage
4from .renderer import OverlaySummary, overlay_xml_on_raster_tiles12from .renderer import OverlaySummary, overlay_xml_on_raster_tiles
513
6__all__ = [14__all__ = [
7 "CollageSummary",15 "CollageSummary",
8 "OverlaySummary",16 "OverlaySummary",
17 "XmlTopdownOverlayConfig",
18 "XmlTopdownOverlayConfigError",
19 "XmlTopdownOverlayRasterSetConfig",
20 "build_xml_topdown_overlay_config",
21 "load_xml_topdown_overlay_config",
22 "normalize_xml_topdown_overlay_config",
9 "overlay_xml_on_raster_tiles",23 "overlay_xml_on_raster_tiles",
10 "overlay_xml_on_topdown_collage",24 "overlay_xml_on_topdown_collage",
11]25]
Importance #7: src/iolabs_point_cloud_visualization_overlays/cli.py @@ -1,14 +1,15 @@
1from __future__ import annotations1from __future__ import annotations
22
3import argparse3import argparse
4import logging4import logging
5import typing
5from collections.abc import Sequence6from collections.abc import Sequence
6from pathlib import Path7from pathlib import Path
78
8from iolabs.common.cli import add_log_level_argument, configure_logging9from iolabs.common.cli import add_log_level_argument, configure_logging
910
10from ._config import load_xml_topdown_overlay_config11from . import _config
11from .renderer import overlay_xml_on_raster_tiles12from .renderer import overlay_xml_on_raster_tiles
1213
13logger = logging.getLogger(__name__)14logger = logging.getLogger(__name__)
1415
Importance #8: src/iolabs_point_cloud_visualization_overlays/cli.py @@ -24,19 +25,19 @@
24 parser.add_argument("--to-segment", type=int, default=None)25 parser.add_argument("--to-segment", type=int, default=None)
25 parser.add_argument("--image-glob", default=None)26 parser.add_argument("--image-glob", default=None)
26 parser.add_argument(27 parser.add_argument(
27 "--label-mode",28 "--label-mode",
28 choices=["none", "feature", "child", "both"],29 choices=list(typing.get_args(_config.LabelMode)),
29 default=None,30 default=None,
30 )31 )
31 parser.add_argument(32 parser.add_argument(
32 "--tile-geoshift-mode",33 "--tile-geoshift-mode",
33 choices=["auto", "subtract", "none"],34 choices=list(typing.get_args(_config.TileGeoshiftMode)),
34 default=None,35 default=None,
35 )36 )
36 parser.add_argument(37 parser.add_argument(
37 "--missing-metadata",38 "--missing-metadata",
38 choices=["error", "skip"],39 choices=list(typing.get_args(_config.MissingMetadata)),
39 default=None,40 default=None,
40 )41 )
41 parser.add_argument("--include-alternative-axes", action="store_true")42 parser.add_argument("--include-alternative-axes", action="store_true")
42 add_log_level_argument(parser)43 add_log_level_argument(parser)
Importance #9: src/iolabs_point_cloud_visualization_overlays/cli.py @@ -45,9 +46,9 @@
4546
46def main(argv: Sequence[str] | None = None) -> int:47def main(argv: Sequence[str] | None = None) -> int:
47 args = build_arg_parser().parse_args(argv)48 args = build_arg_parser().parse_args(argv)
48 configure_logging(args.log_level)49 configure_logging(args.log_level)
49 config = load_xml_topdown_overlay_config()50 config = _config.load_xml_topdown_overlay_config()
50 if args.image_glob:51 if args.image_glob:
51 config["image_glob"] = args.image_glob52 config["image_glob"] = args.image_glob
52 if args.label_mode:53 if args.label_mode:
53 config["label_mode"] = args.label_mode54 config["label_mode"] = args.label_mode
Importance #10: src/iolabs_point_cloud_visualization_overlays/collage_cli.py @@ -1,14 +1,15 @@
1from __future__ import annotations1from __future__ import annotations
22
3import argparse3import argparse
4import logging4import logging
5import typing
5from collections.abc import Sequence6from collections.abc import Sequence
6from pathlib import Path7from pathlib import Path
78
8from iolabs.common.cli import add_log_level_argument, configure_logging9from iolabs.common.cli import add_log_level_argument, configure_logging
910
10from ._config import load_xml_topdown_overlay_config11from . import _config
11from .collage import overlay_xml_on_topdown_collage12from .collage import overlay_xml_on_topdown_collage
1213
13logger = logging.getLogger(__name__)14logger = logging.getLogger(__name__)
1415
Importance #11: src/iolabs_point_cloud_visualization_overlays/collage_cli.py @@ -27,9 +28,9 @@
27 parser.add_argument("--to-segment", type=int, default=None)28 parser.add_argument("--to-segment", type=int, default=None)
28 parser.add_argument("--image-glob", default=None)29 parser.add_argument("--image-glob", default=None)
29 parser.add_argument(30 parser.add_argument(
30 "--label-mode",31 "--label-mode",
31 choices=["none", "feature", "lane"],32 choices=list(typing.get_args(_config.CollageLabelMode)),
32 default=None,33 default=None,
33 help=(34 help=(
34 "Lane label mode: one label per lane feature, one per LaneID, "35 "Lane label mode: one label per lane feature, one per LaneID, "
35 "or none."36 "or none."
Importance #12: src/iolabs_point_cloud_visualization_overlays/collage_cli.py @@ -79,15 +80,15 @@
79 )80 )
80 parser.add_argument("--output-filename", default=None)81 parser.add_argument("--output-filename", default=None)
81 parser.add_argument(82 parser.add_argument(
82 "--tile-geoshift-mode",83 "--tile-geoshift-mode",
83 choices=["auto", "subtract", "none"],84 choices=list(typing.get_args(_config.TileGeoshiftMode)),
84 default=None,85 default=None,
85 help="How to handle raster metadata geoshift when placing tiles.",86 help="How to handle raster metadata geoshift when placing tiles.",
86 )87 )
87 parser.add_argument(88 parser.add_argument(
88 "--missing-metadata",89 "--missing-metadata",
89 choices=["error", "skip"],90 choices=list(typing.get_args(_config.MissingMetadata)),
90 default=None,91 default=None,
91 )92 )
92 parser.add_argument("--include-alternative-axes", action="store_true")93 parser.add_argument("--include-alternative-axes", action="store_true")
93 add_log_level_argument(parser)94 add_log_level_argument(parser)
Importance #13: src/iolabs_point_cloud_visualization_overlays/collage_cli.py @@ -96,9 +97,9 @@
9697
97def main(argv: Sequence[str] | None = None) -> int:98def main(argv: Sequence[str] | None = None) -> int:
98 args = build_arg_parser().parse_args(argv)99 args = build_arg_parser().parse_args(argv)
99 configure_logging(args.log_level)100 configure_logging(args.log_level)
100 config = load_xml_topdown_overlay_config()101 config = _config.load_xml_topdown_overlay_config()
101 if args.image_glob:102 if args.image_glob:
102 config["image_glob"] = args.image_glob103 config["image_glob"] = args.image_glob
103 if args.label_mode:104 if args.label_mode:
104 config["collage_label_mode"] = args.label_mode105 config["collage_label_mode"] = args.label_mode
Importance #14: tests/test_config.py @@ -7,18 +7,20 @@
77
8import pytest8import pytest
9from iolabs.common import config_loader9from iolabs.common import config_loader
1010
11import iolabs_point_cloud_visualization_overlays as overlays
11from iolabs_point_cloud_visualization_overlays import _config12from iolabs_point_cloud_visualization_overlays import _config
1213
1314
14def _write_overrides(tmp_path: Path, payload: dict[str, object]) -> Path:15def _write_overrides(tmp_path: Path, payload: dict[str, object]) -> Path:
16 tmp_path.mkdir(parents=True, exist_ok=True)
15 path = tmp_path / "overlay.json"17 path = tmp_path / "overlay.json"
16 path.write_text(json.dumps(payload), encoding="utf-8")18 path.write_text(json.dumps(payload), encoding="utf-8")
17 return path19 return path
1820
1921
20def test_packaged_defaults_load_as_plain_dict() -> None:22def test_load_xml_topdown_overlay_config_returns_packaged_defaults() -> None:
21 config = _config.load_xml_topdown_overlay_config()23 config = _config.load_xml_topdown_overlay_config()
22 assert isinstance(config, dict)24 assert isinstance(config, dict)
23 assert config["tile_geoshift_mode"] == "auto"25 assert config["tile_geoshift_mode"] == "auto"
24 assert config["label_mode"] == "child"26 assert config["label_mode"] == "child"
Importance #15: tests/test_config.py @@ -61,9 +63,9 @@
61 }63 }
62 )64 )
6365
6466
65def test_config_path_deep_merges_onto_packaged_defaults(tmp_path: Path) -> None:67def test_overrides_deep_merge_onto_defaults(tmp_path: Path) -> None:
66 path = _write_overrides(68 path = _write_overrides(
67 tmp_path,69 tmp_path,
68 {"line_width_px": 9, "raster_sets": {"step4": {"image_glob": "*.png"}}},70 {"line_width_px": 9, "raster_sets": {"step4": {"image_glob": "*.png"}}},
69 )71 )
Importance #16: tests/test_config.py @@ -113,9 +115,9 @@
113 with pytest.raises(_config.XmlTopdownOverlayConfigError):115 with pytest.raises(_config.XmlTopdownOverlayConfigError):
114 _config.load_xml_topdown_overlay_config(path)116 _config.load_xml_topdown_overlay_config(path)
115117
116118
117def test_set_style_strings_coerce_like_the_fleet_matrix(tmp_path: Path) -> None:119def test_set_override_coercion_and_rejection(tmp_path: Path) -> None:
118 path = _write_overrides(120 path = _write_overrides(
119 tmp_path,121 tmp_path,
120 {122 {
121 "include_alternative_axes": "true",123 "include_alternative_axes": "true",
Importance #17: tests/test_config.py @@ -128,8 +130,11 @@
128 assert config["include_alternative_axes"] is True130 assert config["include_alternative_axes"] is True
129 assert config["line_width_px"] == 8131 assert config["line_width_px"] == 8
130 assert config["collage_lane_label_spacing_m"] == 100.0132 assert config["collage_lane_label_spacing_m"] == 100.0
131 assert config["collage_parts"] == 2133 assert config["collage_parts"] == 2
134 rejected = _write_overrides(tmp_path / "bad", {"include_alternative_axes": "flase"})
135 with pytest.raises(_config.XmlTopdownOverlayConfigError, match="flase"):
136 _config.load_xml_topdown_overlay_config(rejected)
132137
133138
134def test_non_object_overlay_file_is_rejected(tmp_path: Path) -> None:139def test_non_object_overlay_file_is_rejected(tmp_path: Path) -> None:
135 path = tmp_path / "overlay.json"140 path = tmp_path / "overlay.json"
Importance #18: tests/test_config.py @@ -137,13 +142,17 @@
137 with pytest.raises(_config.XmlTopdownOverlayConfigError, match="JSON object"):142 with pytest.raises(_config.XmlTopdownOverlayConfigError, match="JSON object"):
138 _config.load_xml_topdown_overlay_config(path)143 _config.load_xml_topdown_overlay_config(path)
139144
140145
141def test_model_defaults_match_the_packaged_json() -> None:146def test_model_defaults_match_packaged_json() -> None:
142 assert (147 packaged = config_loader.load_packaged_json(
143 _config.XmlTopdownOverlayConfig().model_dump()148 "iolabs_point_cloud_visualization_overlays",
144 == _config.load_xml_topdown_overlay_config()149 "xml_topdown_overlay.default.json",
145 )150 )
151 dumped = _config.XmlTopdownOverlayConfig().model_dump()
152 assert dumped == _config.normalize_xml_topdown_overlay_config(packaged)
153 assert dumped == _config.load_xml_topdown_overlay_config()
154 assert sorted(packaged) == sorted(dumped)
146155
147156
148def test_extra_raster_set_is_accepted(tmp_path: Path) -> None:157def test_extra_raster_set_is_accepted(tmp_path: Path) -> None:
149 path = _write_overrides(158 path = _write_overrides(
Importance #19: tests/test_config.py @@ -243,4 +252,52 @@
243def test_color_map_accepts_set_style_strings(tmp_path: Path) -> None:252def test_color_map_accepts_set_style_strings(tmp_path: Path) -> None:
244 path = _write_overrides(tmp_path, {"colors": {"default": ["10", "20", "30", "40"]}})253 path = _write_overrides(tmp_path, {"colors": {"default": ["10", "20", "30", "40"]}})
245 config = _config.load_xml_topdown_overlay_config(path)254 config = _config.load_xml_topdown_overlay_config(path)
246 assert config["colors"]["default"] == (10, 20, 30, 40)255 assert config["colors"]["default"] == (10, 20, 30, 40)
256
257
258def test_error_class_is_config_error() -> None:
259 assert issubclass(_config.XmlTopdownOverlayConfigError, config_loader.ConfigError)
260 assert issubclass(_config.XmlTopdownOverlayConfigError, ValueError)
261
262
263def test_build_config_merges_overrides_over_the_config_file(tmp_path: Path) -> None:
264 path = _write_overrides(tmp_path, {"line_width_px": 9, "axis_width_px": 7})
265 config = _config.build_xml_topdown_overlay_config(
266 overrides={"line_width_px": 11},
267 config_path=path,
268 )
269 assert config["line_width_px"] == 11
270 assert config["axis_width_px"] == 7
271 assert config["tile_geoshift_mode"] == "auto"
272
273
274def test_build_config_accepts_set_overrides() -> None:
275 overrides = config_loader.parse_set_overrides(
276 ["line_width_px=1e1", "use_measured_width=off"],
277 error_cls=_config.XmlTopdownOverlayConfigError,
278 )
279 config = _config.build_xml_topdown_overlay_config(overrides=overrides)
280 assert config["line_width_px"] == 10
281 assert config["use_measured_width"] is False
282
283
284def test_normalize_fills_defaults_and_rejects_unknown_keys() -> None:
285 config = _config.normalize_xml_topdown_overlay_config({"line_width_px": 6})
286 assert config["line_width_px"] == 6
287 assert config["label_mode"] == "child"
288 with pytest.raises(_config.XmlTopdownOverlayConfigError, match="nope"):
289 _config.normalize_xml_topdown_overlay_config({"nope": 1})
290
291
292def test_entry_points_are_re_exported_from_the_package() -> None:
293 assert (
294 overlays.load_xml_topdown_overlay_config
295 is _config.load_xml_topdown_overlay_config
296 )
297 assert (
298 overlays.build_xml_topdown_overlay_config
299 is _config.build_xml_topdown_overlay_config
300 )
301 assert overlays.XmlTopdownOverlayConfigError is (
302 _config.XmlTopdownOverlayConfigError
303 )
Importance #20: README.md @@ -55,16 +55,24 @@
55```55```
5656
57## Configuration57## Configuration
5858
59Defaults live in `src/iolabs_point_cloud_visualization_overlays/xml_topdown_overlay.default.json`59Defaults live in
60and are typed by the pydantic model tree in `_config.py`60`src/iolabs_point_cloud_visualization_overlays/xml_topdown_overlay.default.json`.
61(`iolabs.common.config_loader.ConfigModel`). Unknown keys and invalid values61The schema is `XmlTopdownOverlayConfig` in
62fail loudly; a JSON override file is deep-merged onto the packaged defaults62`iolabs_point_cloud_visualization_overlays._config` (a
63and re-validated.63`config_loader.ConfigModel`); nested JSON sections are nested models and
64unknown keys are rejected. **To add a config key: add the field (with its type,
65default and any `Field` range) to the model and the same key with the same
66default to the JSON — nothing else.**
67`load_xml_topdown_overlay_config`, `build_xml_topdown_overlay_config` and
68`normalize_xml_topdown_overlay_config` (re-exported from the package) return a
69plain `dict`, because callers copy and mutate it. Runtime overrides come from
70repeatable `--set KEY=VALUE`, never repo-local JSON.
71
72`build_xml_topdown_overlay_config(overrides=..., config_path=...)` deep-merges
73the config file **onto** the packaged defaults (it does not replace them), then
74merges `overrides` on top; `load_xml_topdown_overlay_config(config_path)` is the
75same call without overrides.
6476
65`raster_sets` is an open name-to-entry map, so an override file can add a set;77`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.78`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 #21: src/iolabs_point_cloud_visualization_overlays/__init__.py @@ -1,11 +1,25 @@
1"""Visualization overlay helpers for point-cloud pipeline outputs."""1"""Visualization overlay helpers for point-cloud pipeline outputs."""
22
3from ._config import (
4 XmlTopdownOverlayConfig,
5 XmlTopdownOverlayConfigError,
6 XmlTopdownOverlayRasterSetConfig,
7 build_xml_topdown_overlay_config,
8 load_xml_topdown_overlay_config,
9 normalize_xml_topdown_overlay_config,
10)
3from .collage import CollageSummary, overlay_xml_on_topdown_collage11from .collage import CollageSummary, overlay_xml_on_topdown_collage
4from .renderer import OverlaySummary, overlay_xml_on_raster_tiles12from .renderer import OverlaySummary, overlay_xml_on_raster_tiles
513
6__all__ = [14__all__ = [
7 "CollageSummary",15 "CollageSummary",
8 "OverlaySummary",16 "OverlaySummary",
17 "XmlTopdownOverlayConfig",
18 "XmlTopdownOverlayConfigError",
19 "XmlTopdownOverlayRasterSetConfig",
20 "build_xml_topdown_overlay_config",
21 "load_xml_topdown_overlay_config",
22 "normalize_xml_topdown_overlay_config",
9 "overlay_xml_on_raster_tiles",23 "overlay_xml_on_raster_tiles",
10 "overlay_xml_on_topdown_collage",24 "overlay_xml_on_topdown_collage",
11]25]
Importance #22: src/iolabs_point_cloud_visualization_overlays/_config.py @@ -1,47 +1,51 @@
1"""Load, merge and validate XML top-down overlay configuration.1"""Load, merge and validate the XML top-down overlay configuration.
22
3The pydantic model tree below is the schema and mirrors3The schema is `XmlTopdownOverlayConfig` (a `config_loader.ConfigModel`),
4``xml_topdown_overlay.default.json`` exactly: unknown keys fail, and string4mirroring ``xml_topdown_overlay.default.json`` key for key: unknown keys fail,
5choices are ``Literal``-checked here rather than at the point of use. Loading,5and string choices are ``Literal``-checked here rather than at the point of use.
6deep-merging and validation are delegated to :mod:`iolabs.common.config_loader`.
76
8:func:`load_xml_topdown_overlay_config` keeps returning a plain dict, because7Adding a config key means adding the field to the model and the same key to
9callers (CLI, renderer, collage, LaneFinder) pass and mutate the mapping.8``xml_topdown_overlay.default.json`` nothing else. Unknown keys are rejected.
109
11``raster_sets`` stays an open name-to-entry map (the run_7b wrapper selects sets10The entry points return a plain ``dict``, because callers (CLI, renderer,
12by name), but each entry is validated by :class:`RasterSetConfig`.11collage, LaneFinder ``run_7b``) copy and mutate the mapping; sequence fields
1312therefore stay ``list`` so the dump stays mutable. ``raster_sets`` stays an open
14Adding a config key means adding the field to the model here and the same key to13name-to-entry map (the run_7b wrapper selects sets by name), but each entry is
15``xml_topdown_overlay.default.json`` nothing else.14validated by :class:`XmlTopdownOverlayRasterSetConfig`.
16"""15"""
1716
18from __future__ import annotations17from __future__ import annotations
1918
20import json19import json
21import logging20import logging
22from collections.abc import Mapping21from collections.abc import Mapping
23from pathlib import Path22from pathlib import Path
24from typing import Annotated, Any, Literal23from typing import Annotated, Any, Literal, TypeAlias
2524
26import pydantic25import pydantic
27from iolabs.common import config_loader26from iolabs.common import config_loader
2827
29logger = logging.getLogger(__name__)28logger = logging.getLogger(__name__)
3029
31_PACKAGE_NAME = "iolabs_point_cloud_visualization_overlays"30_PACKAGE_NAME = "iolabs_point_cloud_visualization_overlays"
32_DEFAULT_CONFIG_NAME = "xml_topdown_overlay.default.json"31_DEFAULT_FILENAME = "xml_topdown_overlay.default.json"
33_CONTEXT = "xml topdown overlay config"32_CONTEXT = "xml topdown overlay config"
3433
35Channel = Annotated[int, pydantic.Field(ge=0, le=255)]34Channel: TypeAlias = Annotated[int, pydantic.Field(ge=0, le=255)]
36Rgba = tuple[Channel, Channel, Channel, Channel]35Rgba: TypeAlias = tuple[Channel, Channel, Channel, Channel]
36
37TileGeoshiftMode: TypeAlias = Literal["auto", "subtract", "none"]
38LabelMode: TypeAlias = Literal["none", "feature", "child", "both"]
39MissingMetadata: TypeAlias = Literal["error", "skip"]
40CollageLabelMode: TypeAlias = Literal["none", "feature", "lane"]
3741
3842
39class XmlTopdownOverlayConfigError(config_loader.ConfigError):43class XmlTopdownOverlayConfigError(config_loader.ConfigError):
40 """Raised when XML top-down overlay config contains unsupported values."""44 """Raised when xml topdown overlay config contains unsupported keys or values."""
4145
4246
43class RasterSetConfig(config_loader.ConfigModel):47class XmlTopdownOverlayRasterSetConfig(config_loader.ConfigModel):
44 """One named raster-set: input tiles, overlay output, and image glob."""48 """One named raster-set: input tiles, overlay output, and image glob."""
4549
46 input_subdir: str50 input_subdir: str
47 output_subdir: str51 output_subdir: str
Importance #23: src/iolabs_point_cloud_visualization_overlays/_config.py @@ -50,42 +54,42 @@
5054
51class XmlTopdownOverlayConfig(config_loader.ConfigModel):55class XmlTopdownOverlayConfig(config_loader.ConfigModel):
52 """Full overlay config; field names match the packaged JSON keys."""56 """Full overlay config; field names match the packaged JSON keys."""
5357
54 raster_sets: dict[str, RasterSetConfig] = {58 raster_sets: dict[str, XmlTopdownOverlayRasterSetConfig] = {
55 "step4": RasterSetConfig(59 "step4": XmlTopdownOverlayRasterSetConfig(
56 input_subdir="topdown_tiles",60 input_subdir="topdown_tiles",
57 output_subdir="topdown_tiles_run7_overlay",61 output_subdir="topdown_tiles_run7_overlay",
58 ),62 ),
59 "step6b": RasterSetConfig(63 "step6b": XmlTopdownOverlayRasterSetConfig(
60 input_subdir="topdown_tiles_run6_clusters",64 input_subdir="topdown_tiles_run6_clusters",
61 output_subdir="topdown_tiles_run6_clusters_run7_overlay",65 output_subdir="topdown_tiles_run6_clusters_run7_overlay",
62 ),66 ),
63 }67 }
64 default_raster_sets: list[str] = ["step4"]68 default_raster_sets: list[str] = ["step4"]
65 tile_geoshift_mode: Literal["auto", "subtract", "none"] = "auto"69 tile_geoshift_mode: TileGeoshiftMode = "auto"
66 include_feature_types: list[str] = [70 include_feature_types: list[str] = [
67 "Axis of the Edge",71 "Axis of the Edge",
68 "Center Lines",72 "Center Lines",
69 "Central Axis",73 "Central Axis",
70 ]74 ]
71 include_alternative_axes: bool = False75 include_alternative_axes: bool = False
72 label_mode: Literal["none", "feature", "child", "both"] = "child"76 label_mode: LabelMode = "child"
73 line_width_px: int = pydantic.Field(default=4, ge=1)77 line_width_px: int = pydantic.Field(default=4, ge=1)
74 axis_width_px: int = pydantic.Field(default=5, ge=1)78 axis_width_px: int = pydantic.Field(default=5, ge=1)
75 use_measured_width: bool = True79 use_measured_width: bool = True
76 flagged_color: Rgba = (255, 60, 60, 255)80 flagged_color: Rgba = (255, 60, 60, 255)
77 label_show_width: bool = False81 label_show_width: bool = False
78 label_font_size_px: int = pydantic.Field(default=18, ge=1)82 label_font_size_px: int = pydantic.Field(default=18, ge=1)
79 label_outline_width_px: int = pydantic.Field(default=2, ge=0)83 label_outline_width_px: int = pydantic.Field(default=2, ge=0)
80 missing_metadata: Literal["error", "skip"] = "error"84 missing_metadata: MissingMetadata = "error"
81 spline_samples_per_segment: int = pydantic.Field(default=20, ge=1)85 spline_samples_per_segment: int = pydantic.Field(default=20, ge=1)
82 collage_max_dimension_px: int = pydantic.Field(default=8192, ge=1)86 collage_max_dimension_px: int = pydantic.Field(default=8192, ge=1)
83 collage_pixels_per_meter: float | None = pydantic.Field(default=None, gt=0.0)87 collage_pixels_per_meter: float | None = pydantic.Field(default=None, gt=0.0)
84 collage_parts: Literal["auto"] | Annotated[int, pydantic.Field(ge=1)] = "auto"88 collage_parts: Literal["auto"] | Annotated[int, pydantic.Field(ge=1)] = "auto"
85 collage_max_parts: int = pydantic.Field(default=5, ge=1)89 collage_max_parts: int = pydantic.Field(default=5, ge=1)
86 collage_background_rgba: Rgba = (0, 0, 0, 255)90 collage_background_rgba: Rgba = (0, 0, 0, 255)
87 collage_label_mode: Literal["none", "feature", "lane"] = "feature"91 collage_label_mode: CollageLabelMode = "feature"
88 collage_label_font_size_px: int = pydantic.Field(default=28, ge=1)92 collage_label_font_size_px: int = pydantic.Field(default=28, ge=1)
89 collage_lane_label_spacing_m: float = pydantic.Field(default=250.0, gt=0.0)93 collage_lane_label_spacing_m: float = pydantic.Field(default=250.0, gt=0.0)
90 collage_tile_label_font_size_px: int = pydantic.Field(default=32, ge=1)94 collage_tile_label_font_size_px: int = pydantic.Field(default=32, ge=1)
91 collage_tile_label_rgba: Rgba = (200, 200, 200, 255)95 collage_tile_label_rgba: Rgba = (200, 200, 200, 255)
Importance #24: src/iolabs_point_cloud_visualization_overlays/_config.py @@ -158,28 +162,17 @@
158 )162 )
159 return self163 return self
160164
161165
162def _load_model(166def _load_json_overrides(config_path: str | Path | None) -> dict[str, Any]:
163 overrides: dict[str, Any] | None = None,
164) -> XmlTopdownOverlayConfig:
165 """Merge *overrides* onto the packaged defaults and validate the result."""
166 return config_loader.load_config(
167 XmlTopdownOverlayConfig,
168 package=__package__ or _PACKAGE_NAME,
169 filename=_DEFAULT_CONFIG_NAME,
170 overrides=overrides,
171 context=_CONTEXT,
172 error_cls=XmlTopdownOverlayConfigError,
173 )
174
175
176def _read_overrides(config_path: str | Path | None) -> dict[str, Any]:
177 """Read a JSON override file, or return an empty mapping when there is none.167 """Read a JSON override file, or return an empty mapping when there is none.
178168
169 Local stand-in for the shared ``config_loader.load_json_overrides`` helper;
170 drop it once the shared layer exports one.
171
179 Raises:172 Raises:
180 XmlTopdownOverlayConfigError: The file is not valid JSON, or is not a173 XmlTopdownOverlayConfigError: The file is not valid JSON, or does not
181 JSON object.174 hold a JSON object.
182 """175 """
183 if config_path is None:176 if config_path is None:
184 return {}177 return {}
185 path = Path(config_path)178 path = Path(config_path)
Importance #25: src/iolabs_point_cloud_visualization_overlays/_config.py @@ -187,16 +180,85 @@
187 with path.open(encoding="utf-8") as handle:180 with path.open(encoding="utf-8") as handle:
188 raw = json.load(handle)181 raw = json.load(handle)
189 except json.JSONDecodeError as exc:182 except json.JSONDecodeError as exc:
190 raise XmlTopdownOverlayConfigError(183 raise XmlTopdownOverlayConfigError(
191 f"Invalid JSON in overlay config file {path}: {exc}"184 f"Invalid JSON in config file {path}: {exc}"
192 ) from exc185 ) from exc
193 if not isinstance(raw, dict):186 if not isinstance(raw, dict):
194 raise XmlTopdownOverlayConfigError("Overlay config must be a JSON object")187 raise XmlTopdownOverlayConfigError(
195 logger.debug("Loaded %s overrides from %s", _CONTEXT, path)188 f"Config file {path} must hold a JSON object, got {type(raw).__name__}"
189 )
196 return raw190 return raw
197191
198192
193def _load_model(
194 *,
195 overrides: Mapping[str, Any] | None = None,
196 config_path: str | Path | None = None,
197) -> XmlTopdownOverlayConfig:
198 """Deep-merge the config file and *overrides* onto the packaged defaults."""
199 merged: dict[str, Any] = _load_json_overrides(config_path)
200 if config_path is not None:
201 logger.info("Config file applied: %s", config_path)
202 if overrides:
203 merged = config_loader.deep_merge_dicts(merged, dict(overrides))
204 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
205 return config_loader.load_config(
206 XmlTopdownOverlayConfig,
207 package=_PACKAGE_NAME,
208 filename=_DEFAULT_FILENAME,
209 overrides=merged,
210 context=_CONTEXT,
211 error_cls=XmlTopdownOverlayConfigError,
212 )
213
214
215def normalize_xml_topdown_overlay_config(
216 raw_config: Mapping[str, Any],
217) -> dict[str, Any]:
218 """Validate *raw_config* and fill in the model defaults.
219
220 Args:
221 raw_config: A full or partial overlay config mapping.
222
223 Returns:
224 The validated configuration as a plain dict.
225
226 Raises:
227 XmlTopdownOverlayConfigError: Unknown key or bad value.
228 """
229 return config_loader.validate_config(
230 XmlTopdownOverlayConfig,
231 raw_config,
232 context=_CONTEXT,
233 error_cls=XmlTopdownOverlayConfigError,
234 ).model_dump()
235
236
237def build_xml_topdown_overlay_config(
238 *,
239 overrides: Mapping[str, Any] | None = None,
240 config_path: str | Path | None = None,
241) -> dict[str, Any]:
242 """Build the overlay config from defaults, a config file and *overrides*.
243
244 Unlike the replace semantics of the shared loader, *config_path* is
245 deep-merged **onto** the packaged defaults; *overrides* (e.g. the result of
246 ``config_loader.parse_set_overrides``) is merged on top of that.
247
248 Args:
249 overrides: Mapping deep-merged onto the defaults, or ``None``.
250 config_path: Path to a JSON object of partial overrides, or ``None``.
251
252 Returns:
253 The merged, validated configuration as a plain dict.
254
255 Raises:
256 XmlTopdownOverlayConfigError: Unknown key, bad value, or non-object JSON.
257 """
258 return _load_model(overrides=overrides, config_path=config_path).model_dump()
259
260
199def load_xml_topdown_overlay_config(261def load_xml_topdown_overlay_config(
200 config_path: str | Path | None = None,262 config_path: str | Path | None = None,
201) -> dict[str, Any]:263) -> dict[str, Any]:
202 """Load packaged defaults, optionally deep-merged with a JSON override file.264 """Load packaged defaults, optionally deep-merged with a JSON override file.
Importance #26: src/iolabs_point_cloud_visualization_overlays/_config.py @@ -210,5 +272,5 @@
210272
211 Raises:273 Raises:
212 XmlTopdownOverlayConfigError: Unknown key, bad value, or non-object JSON.274 XmlTopdownOverlayConfigError: Unknown key, bad value, or non-object JSON.
213 """275 """
214 return _load_model(overrides=_read_overrides(config_path)).model_dump()276 return build_xml_topdown_overlay_config(config_path=config_path)
Importance #27: src/iolabs_point_cloud_visualization_overlays/cli.py @@ -1,14 +1,15 @@
1from __future__ import annotations1from __future__ import annotations
22
3import argparse3import argparse
4import logging4import logging
5import typing
5from collections.abc import Sequence6from collections.abc import Sequence
6from pathlib import Path7from pathlib import Path
78
8from iolabs.common.cli import add_log_level_argument, configure_logging9from iolabs.common.cli import add_log_level_argument, configure_logging
910
10from ._config import load_xml_topdown_overlay_config11from . import _config
11from .renderer import overlay_xml_on_raster_tiles12from .renderer import overlay_xml_on_raster_tiles
1213
13logger = logging.getLogger(__name__)14logger = logging.getLogger(__name__)
1415
Importance #28: src/iolabs_point_cloud_visualization_overlays/cli.py @@ -24,19 +25,19 @@
24 parser.add_argument("--to-segment", type=int, default=None)25 parser.add_argument("--to-segment", type=int, default=None)
25 parser.add_argument("--image-glob", default=None)26 parser.add_argument("--image-glob", default=None)
26 parser.add_argument(27 parser.add_argument(
27 "--label-mode",28 "--label-mode",
28 choices=["none", "feature", "child", "both"],29 choices=list(typing.get_args(_config.LabelMode)),
29 default=None,30 default=None,
30 )31 )
31 parser.add_argument(32 parser.add_argument(
32 "--tile-geoshift-mode",33 "--tile-geoshift-mode",
33 choices=["auto", "subtract", "none"],34 choices=list(typing.get_args(_config.TileGeoshiftMode)),
34 default=None,35 default=None,
35 )36 )
36 parser.add_argument(37 parser.add_argument(
37 "--missing-metadata",38 "--missing-metadata",
38 choices=["error", "skip"],39 choices=list(typing.get_args(_config.MissingMetadata)),
39 default=None,40 default=None,
40 )41 )
41 parser.add_argument("--include-alternative-axes", action="store_true")42 parser.add_argument("--include-alternative-axes", action="store_true")
42 add_log_level_argument(parser)43 add_log_level_argument(parser)
Importance #29: src/iolabs_point_cloud_visualization_overlays/cli.py @@ -45,9 +46,9 @@
4546
46def main(argv: Sequence[str] | None = None) -> int:47def main(argv: Sequence[str] | None = None) -> int:
47 args = build_arg_parser().parse_args(argv)48 args = build_arg_parser().parse_args(argv)
48 configure_logging(args.log_level)49 configure_logging(args.log_level)
49 config = load_xml_topdown_overlay_config()50 config = _config.load_xml_topdown_overlay_config()
50 if args.image_glob:51 if args.image_glob:
51 config["image_glob"] = args.image_glob52 config["image_glob"] = args.image_glob
52 if args.label_mode:53 if args.label_mode:
53 config["label_mode"] = args.label_mode54 config["label_mode"] = args.label_mode
Importance #30: src/iolabs_point_cloud_visualization_overlays/collage_cli.py @@ -1,14 +1,15 @@
1from __future__ import annotations1from __future__ import annotations
22
3import argparse3import argparse
4import logging4import logging
5import typing
5from collections.abc import Sequence6from collections.abc import Sequence
6from pathlib import Path7from pathlib import Path
78
8from iolabs.common.cli import add_log_level_argument, configure_logging9from iolabs.common.cli import add_log_level_argument, configure_logging
910
10from ._config import load_xml_topdown_overlay_config11from . import _config
11from .collage import overlay_xml_on_topdown_collage12from .collage import overlay_xml_on_topdown_collage
1213
13logger = logging.getLogger(__name__)14logger = logging.getLogger(__name__)
1415
Importance #31: src/iolabs_point_cloud_visualization_overlays/collage_cli.py @@ -27,9 +28,9 @@
27 parser.add_argument("--to-segment", type=int, default=None)28 parser.add_argument("--to-segment", type=int, default=None)
28 parser.add_argument("--image-glob", default=None)29 parser.add_argument("--image-glob", default=None)
29 parser.add_argument(30 parser.add_argument(
30 "--label-mode",31 "--label-mode",
31 choices=["none", "feature", "lane"],32 choices=list(typing.get_args(_config.CollageLabelMode)),
32 default=None,33 default=None,
33 help=(34 help=(
34 "Lane label mode: one label per lane feature, one per LaneID, "35 "Lane label mode: one label per lane feature, one per LaneID, "
35 "or none."36 "or none."
Importance #32: src/iolabs_point_cloud_visualization_overlays/collage_cli.py @@ -79,15 +80,15 @@
79 )80 )
80 parser.add_argument("--output-filename", default=None)81 parser.add_argument("--output-filename", default=None)
81 parser.add_argument(82 parser.add_argument(
82 "--tile-geoshift-mode",83 "--tile-geoshift-mode",
83 choices=["auto", "subtract", "none"],84 choices=list(typing.get_args(_config.TileGeoshiftMode)),
84 default=None,85 default=None,
85 help="How to handle raster metadata geoshift when placing tiles.",86 help="How to handle raster metadata geoshift when placing tiles.",
86 )87 )
87 parser.add_argument(88 parser.add_argument(
88 "--missing-metadata",89 "--missing-metadata",
89 choices=["error", "skip"],90 choices=list(typing.get_args(_config.MissingMetadata)),
90 default=None,91 default=None,
91 )92 )
92 parser.add_argument("--include-alternative-axes", action="store_true")93 parser.add_argument("--include-alternative-axes", action="store_true")
93 add_log_level_argument(parser)94 add_log_level_argument(parser)
Importance #33: src/iolabs_point_cloud_visualization_overlays/collage_cli.py @@ -96,9 +97,9 @@
9697
97def main(argv: Sequence[str] | None = None) -> int:98def main(argv: Sequence[str] | None = None) -> int:
98 args = build_arg_parser().parse_args(argv)99 args = build_arg_parser().parse_args(argv)
99 configure_logging(args.log_level)100 configure_logging(args.log_level)
100 config = load_xml_topdown_overlay_config()101 config = _config.load_xml_topdown_overlay_config()
101 if args.image_glob:102 if args.image_glob:
102 config["image_glob"] = args.image_glob103 config["image_glob"] = args.image_glob
103 if args.label_mode:104 if args.label_mode:
104 config["collage_label_mode"] = args.label_mode105 config["collage_label_mode"] = args.label_mode
Importance #34: tests/test_config.py @@ -7,18 +7,20 @@
77
8import pytest8import pytest
9from iolabs.common import config_loader9from iolabs.common import config_loader
1010
11import iolabs_point_cloud_visualization_overlays as overlays
11from iolabs_point_cloud_visualization_overlays import _config12from iolabs_point_cloud_visualization_overlays import _config
1213
1314
14def _write_overrides(tmp_path: Path, payload: dict[str, object]) -> Path:15def _write_overrides(tmp_path: Path, payload: dict[str, object]) -> Path:
16 tmp_path.mkdir(parents=True, exist_ok=True)
15 path = tmp_path / "overlay.json"17 path = tmp_path / "overlay.json"
16 path.write_text(json.dumps(payload), encoding="utf-8")18 path.write_text(json.dumps(payload), encoding="utf-8")
17 return path19 return path
1820
1921
20def test_packaged_defaults_load_as_plain_dict() -> None:22def test_load_xml_topdown_overlay_config_returns_packaged_defaults() -> None:
21 config = _config.load_xml_topdown_overlay_config()23 config = _config.load_xml_topdown_overlay_config()
22 assert isinstance(config, dict)24 assert isinstance(config, dict)
23 assert config["tile_geoshift_mode"] == "auto"25 assert config["tile_geoshift_mode"] == "auto"
24 assert config["label_mode"] == "child"26 assert config["label_mode"] == "child"
Importance #35: tests/test_config.py @@ -61,9 +63,9 @@
61 }63 }
62 )64 )
6365
6466
65def test_config_path_deep_merges_onto_packaged_defaults(tmp_path: Path) -> None:67def test_overrides_deep_merge_onto_defaults(tmp_path: Path) -> None:
66 path = _write_overrides(68 path = _write_overrides(
67 tmp_path,69 tmp_path,
68 {"line_width_px": 9, "raster_sets": {"step4": {"image_glob": "*.png"}}},70 {"line_width_px": 9, "raster_sets": {"step4": {"image_glob": "*.png"}}},
69 )71 )
Importance #36: tests/test_config.py @@ -113,9 +115,9 @@
113 with pytest.raises(_config.XmlTopdownOverlayConfigError):115 with pytest.raises(_config.XmlTopdownOverlayConfigError):
114 _config.load_xml_topdown_overlay_config(path)116 _config.load_xml_topdown_overlay_config(path)
115117
116118
117def test_set_style_strings_coerce_like_the_fleet_matrix(tmp_path: Path) -> None:119def test_set_override_coercion_and_rejection(tmp_path: Path) -> None:
118 path = _write_overrides(120 path = _write_overrides(
119 tmp_path,121 tmp_path,
120 {122 {
121 "include_alternative_axes": "true",123 "include_alternative_axes": "true",
Importance #37: tests/test_config.py @@ -128,8 +130,11 @@
128 assert config["include_alternative_axes"] is True130 assert config["include_alternative_axes"] is True
129 assert config["line_width_px"] == 8131 assert config["line_width_px"] == 8
130 assert config["collage_lane_label_spacing_m"] == 100.0132 assert config["collage_lane_label_spacing_m"] == 100.0
131 assert config["collage_parts"] == 2133 assert config["collage_parts"] == 2
134 rejected = _write_overrides(tmp_path / "bad", {"include_alternative_axes": "flase"})
135 with pytest.raises(_config.XmlTopdownOverlayConfigError, match="flase"):
136 _config.load_xml_topdown_overlay_config(rejected)
132137
133138
134def test_non_object_overlay_file_is_rejected(tmp_path: Path) -> None:139def test_non_object_overlay_file_is_rejected(tmp_path: Path) -> None:
135 path = tmp_path / "overlay.json"140 path = tmp_path / "overlay.json"
Importance #38: tests/test_config.py @@ -137,13 +142,17 @@
137 with pytest.raises(_config.XmlTopdownOverlayConfigError, match="JSON object"):142 with pytest.raises(_config.XmlTopdownOverlayConfigError, match="JSON object"):
138 _config.load_xml_topdown_overlay_config(path)143 _config.load_xml_topdown_overlay_config(path)
139144
140145
141def test_model_defaults_match_the_packaged_json() -> None:146def test_model_defaults_match_packaged_json() -> None:
142 assert (147 packaged = config_loader.load_packaged_json(
143 _config.XmlTopdownOverlayConfig().model_dump()148 "iolabs_point_cloud_visualization_overlays",
144 == _config.load_xml_topdown_overlay_config()149 "xml_topdown_overlay.default.json",
145 )150 )
151 dumped = _config.XmlTopdownOverlayConfig().model_dump()
152 assert dumped == _config.normalize_xml_topdown_overlay_config(packaged)
153 assert dumped == _config.load_xml_topdown_overlay_config()
154 assert sorted(packaged) == sorted(dumped)
146155
147156
148def test_extra_raster_set_is_accepted(tmp_path: Path) -> None:157def test_extra_raster_set_is_accepted(tmp_path: Path) -> None:
149 path = _write_overrides(158 path = _write_overrides(
Importance #39: tests/test_config.py @@ -243,4 +252,52 @@
243def test_color_map_accepts_set_style_strings(tmp_path: Path) -> None:252def test_color_map_accepts_set_style_strings(tmp_path: Path) -> None:
244 path = _write_overrides(tmp_path, {"colors": {"default": ["10", "20", "30", "40"]}})253 path = _write_overrides(tmp_path, {"colors": {"default": ["10", "20", "30", "40"]}})
245 config = _config.load_xml_topdown_overlay_config(path)254 config = _config.load_xml_topdown_overlay_config(path)
246 assert config["colors"]["default"] == (10, 20, 30, 40)255 assert config["colors"]["default"] == (10, 20, 30, 40)
256
257
258def test_error_class_is_config_error() -> None:
259 assert issubclass(_config.XmlTopdownOverlayConfigError, config_loader.ConfigError)
260 assert issubclass(_config.XmlTopdownOverlayConfigError, ValueError)
261
262
263def test_build_config_merges_overrides_over_the_config_file(tmp_path: Path) -> None:
264 path = _write_overrides(tmp_path, {"line_width_px": 9, "axis_width_px": 7})
265 config = _config.build_xml_topdown_overlay_config(
266 overrides={"line_width_px": 11},
267 config_path=path,
268 )
269 assert config["line_width_px"] == 11
270 assert config["axis_width_px"] == 7
271 assert config["tile_geoshift_mode"] == "auto"
272
273
274def test_build_config_accepts_set_overrides() -> None:
275 overrides = config_loader.parse_set_overrides(
276 ["line_width_px=1e1", "use_measured_width=off"],
277 error_cls=_config.XmlTopdownOverlayConfigError,
278 )
279 config = _config.build_xml_topdown_overlay_config(overrides=overrides)
280 assert config["line_width_px"] == 10
281 assert config["use_measured_width"] is False
282
283
284def test_normalize_fills_defaults_and_rejects_unknown_keys() -> None:
285 config = _config.normalize_xml_topdown_overlay_config({"line_width_px": 6})
286 assert config["line_width_px"] == 6
287 assert config["label_mode"] == "child"
288 with pytest.raises(_config.XmlTopdownOverlayConfigError, match="nope"):
289 _config.normalize_xml_topdown_overlay_config({"nope": 1})
290
291
292def test_entry_points_are_re_exported_from_the_package() -> None:
293 assert (
294 overlays.load_xml_topdown_overlay_config
295 is _config.load_xml_topdown_overlay_config
296 )
297 assert (
298 overlays.build_xml_topdown_overlay_config
299 is _config.build_xml_topdown_overlay_config
300 )
301 assert overlays.XmlTopdownOverlayConfigError is (
302 _config.XmlTopdownOverlayConfigError
303 )