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(-)
| 1 | """Load, merge and validate XML top-down overlay configuration. | ||
| 2 | |||
| 3 | The pydantic model tree below is the schema and mirrors | ||
| 4 | ``xml_topdown_overlay.default.json`` exactly: unknown keys fail, and string | ||
| 5 | choices are ``Literal``-checked here rather than at the point of use. Loading, | ||
| 6 | deep-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 | ||
| 9 | callers (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 | ||
| 12 | by name), but each entry is validated by :class:`RasterSetConfig`. | ||
| 13 | |||
| 14 | Adding 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 | |||
| 1 | from __future__ import annotations | 18 | from __future__ import annotations |
| 2 | 19 | ||
| 3 | import json | 20 | import json |
| 21 | import logging | ||
| 4 | from pathlib import Path | 22 | from pathlib import Path |
| 5 | from typing import Any | 23 | from typing import Annotated, Any, Literal |
| 24 | |||
| 25 | import pydantic | ||
| 26 | from iolabs.common import config_loader | ||
| 6 | 27 | ||
| 7 | from iolabs.common.config_loader import deep_merge_dicts, load_packaged_json | 28 | logger = logging.getLogger(__name__) |
| 8 | 29 | ||
| 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 | |||
| 34 | Channel = Annotated[int, pydantic.Field(ge=0, le=255)] | ||
| 35 | Rgba = tuple[Channel, Channel, Channel, Channel] | ||
| 11 | 36 | ||
| 12 | 37 | ||
| 13 | class XmlTopdownOverlayConfigError(ValueError): | 38 | class 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.""" |
| 15 | 40 | ||
| 16 | 41 | ||
| 17 | def _load_raw_default_config() -> dict[str, Any]: | 42 | class 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.""" |
| 19 | 44 | ||
| 45 | input_subdir: str | ||
| 46 | output_subdir: str | ||
| 47 | image_glob: str = "*segment_*_intensity.png" | ||
| 20 | 48 | ||
| 21 | def load_xml_topdown_overlay_config( | 49 | |
| 22 | config_path: str | Path | None = None, | 50 | class 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 | |||
| 115 | def _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 | |||
| 129 | def _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 defaults | 137 | 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 | |||
| 152 | def 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() |
| 1 | """Tests for the packaged XML top-down overlay config.""" | ||
| 2 | |||
| 3 | from __future__ import annotations | ||
| 4 | |||
| 5 | import json | ||
| 6 | from pathlib import Path | ||
| 7 | |||
| 8 | import pytest | ||
| 9 | from iolabs.common import config_loader | ||
| 10 | |||
| 11 | from iolabs_point_cloud_visualization_overlays import _config | ||
| 12 | |||
| 13 | |||
| 14 | def _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 | |||
| 20 | def 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 | |||
| 65 | def 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 | |||
| 79 | def 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 | |||
| 89 | def 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 | ) | ||
| 109 | def 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 | |||
| 117 | def 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 | |||
| 134 | def 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 | |||
| 141 | def 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 | |||
| 148 | def 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 | |||
| 162 | def 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 | ) | ||
| 181 | def 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 | |||
| 189 | def 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 | |||
| 198 | def 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 |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-point-cloud-visualization-overlays" | 2 | name = "iolabs-point-cloud-visualization-overlays" |
| 3 | version = "0.3.0" | 3 | version = "0.3.1" |
| 4 | description = "2D visualization overlays for point-cloud pipeline outputs" | 4 | description = "2D visualization overlays for point-cloud pipeline outputs" |
| 5 | requires-python = ">=3.11,<3.13" | 5 | requires-python = ">=3.11,<3.13" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 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 | ] |
| 14 | 15 |
| 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 | |||
| 59 | Defaults live in `src/iolabs_point_cloud_visualization_overlays/xml_topdown_overlay.default.json` | ||
| 60 | and are typed by the pydantic model tree in `_config.py` | ||
| 61 | (`iolabs.common.config_loader.ConfigModel`). Unknown keys and invalid values | ||
| 62 | fail loudly; a JSON override file is deep-merged onto the packaged defaults | ||
| 63 | and 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 | |||
| 68 | Adding a config key: add the field (with its type, default and any range | ||
| 69 | constraint) to the matching model in `_config.py`, and add the same key to | ||
| 70 | `xml_topdown_overlay.default.json`. Nothing else. |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-point-cloud-visualization-overlays" | 2 | name = "iolabs-point-cloud-visualization-overlays" |
| 3 | version = "0.3.0" | 3 | version = "0.3.1" |
| 4 | description = "2D visualization overlays for point-cloud pipeline outputs" | 4 | description = "2D visualization overlays for point-cloud pipeline outputs" |
| 5 | requires-python = ">=3.11,<3.13" | 5 | requires-python = ">=3.11,<3.13" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 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 | ] |
| 14 | 15 |
| 1 | """Load, merge and validate XML top-down overlay configuration. | ||
| 2 | |||
| 3 | The pydantic model tree below is the schema and mirrors | ||
| 4 | ``xml_topdown_overlay.default.json`` exactly: unknown keys fail, and string | ||
| 5 | choices are ``Literal``-checked here rather than at the point of use. Loading, | ||
| 6 | deep-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 | ||
| 9 | callers (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 | ||
| 12 | by name), but each entry is validated by :class:`RasterSetConfig`. | ||
| 13 | |||
| 14 | Adding 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 | |||
| 1 | from __future__ import annotations | 18 | from __future__ import annotations |
| 2 | 19 | ||
| 3 | import json | 20 | import json |
| 21 | import logging | ||
| 4 | from pathlib import Path | 22 | from pathlib import Path |
| 5 | from typing import Any | 23 | from typing import Annotated, Any, Literal |
| 24 | |||
| 25 | import pydantic | ||
| 26 | from iolabs.common import config_loader | ||
| 6 | 27 | ||
| 7 | from iolabs.common.config_loader import deep_merge_dicts, load_packaged_json | 28 | logger = logging.getLogger(__name__) |
| 8 | 29 | ||
| 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 | |||
| 34 | Channel = Annotated[int, pydantic.Field(ge=0, le=255)] | ||
| 35 | Rgba = tuple[Channel, Channel, Channel, Channel] | ||
| 11 | 36 | ||
| 12 | 37 | ||
| 13 | class XmlTopdownOverlayConfigError(ValueError): | 38 | class 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.""" |
| 15 | 40 | ||
| 16 | 41 | ||
| 17 | def _load_raw_default_config() -> dict[str, Any]: | 42 | class 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.""" |
| 19 | 44 | ||
| 45 | input_subdir: str | ||
| 46 | output_subdir: str | ||
| 47 | image_glob: str = "*segment_*_intensity.png" | ||
| 20 | 48 | ||
| 21 | def load_xml_topdown_overlay_config( | 49 | |
| 22 | config_path: str | Path | None = None, | 50 | class 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 | |||
| 115 | def _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 | |||
| 129 | def _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 defaults | 137 | 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 | |||
| 152 | def 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() |
| 1 | """Tests for the packaged XML top-down overlay config.""" | ||
| 2 | |||
| 3 | from __future__ import annotations | ||
| 4 | |||
| 5 | import json | ||
| 6 | from pathlib import Path | ||
| 7 | |||
| 8 | import pytest | ||
| 9 | from iolabs.common import config_loader | ||
| 10 | |||
| 11 | from iolabs_point_cloud_visualization_overlays import _config | ||
| 12 | |||
| 13 | |||
| 14 | def _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 | |||
| 20 | def 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 | |||
| 65 | def 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 | |||
| 79 | def 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 | |||
| 89 | def 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 | ) | ||
| 109 | def 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 | |||
| 117 | def 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 | |||
| 134 | def 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 | |||
| 141 | def 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 | |||
| 148 | def 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 | |||
| 162 | def 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 | ) | ||
| 181 | def 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 | |||
| 189 | def 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 | |||
| 198 | def 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 |
ConfigModel: nested section models mirror the packaged*.default.jsonkey for key; whitelist sets and hand-rolled coercion deleted; loader built onconfig_loader.load_config. Public entry-point names and return types unchanged so lanefinder wrappers keep working.pydantic>=2.7dependency.