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(-)
| 1 | """Load, merge and validate XML top-down overlay configuration. | 1 | """Load, merge and validate the XML top-down overlay configuration. |
| 2 | 2 | ||
| 3 | The pydantic model tree below is the schema and mirrors | 3 | The schema is `XmlTopdownOverlayConfig` (a `config_loader.ConfigModel`), |
| 4 | ``xml_topdown_overlay.default.json`` exactly: unknown keys fail, and string | 4 | mirroring ``xml_topdown_overlay.default.json`` key for key: unknown keys fail, |
| 5 | choices are ``Literal``-checked here rather than at the point of use. Loading, | 5 | and string choices are ``Literal``-checked here rather than at the point of use. |
| 6 | deep-merging and validation are delegated to :mod:`iolabs.common.config_loader`. | ||
| 7 | 6 | ||
| 8 | :func:`load_xml_topdown_overlay_config` keeps returning a plain dict, because | 7 | Adding a config key means adding the field to the model and the same key to |
| 9 | callers (CLI, renderer, collage, LaneFinder) pass and mutate the mapping. | 8 | ``xml_topdown_overlay.default.json`` — nothing else. Unknown keys are rejected. |
| 10 | 9 | ||
| 11 | ``raster_sets`` stays an open name-to-entry map (the run_7b wrapper selects sets | 10 | The entry points return a plain ``dict``, because callers (CLI, renderer, |
| 12 | by name), but each entry is validated by :class:`RasterSetConfig`. | 11 | collage, LaneFinder ``run_7b``) copy and mutate the mapping; sequence fields |
| 13 | 12 | therefore stay ``list`` so the dump stays mutable. ``raster_sets`` stays an open | |
| 14 | Adding a config key means adding the field to the model here and the same key to | 13 | name-to-entry map (the run_7b wrapper selects sets by name), but each entry is |
| 15 | ``xml_topdown_overlay.default.json`` — nothing else. | 14 | validated by :class:`XmlTopdownOverlayRasterSetConfig`. |
| 16 | """ | 15 | """ |
| 17 | 16 | ||
| 18 | from __future__ import annotations | 17 | from __future__ import annotations |
| 19 | 18 | ||
| 20 | import json | 19 | import json |
| 21 | import logging | 20 | import logging |
| 22 | from collections.abc import Mapping | 21 | from collections.abc import Mapping |
| 23 | from pathlib import Path | 22 | from pathlib import Path |
| 24 | from typing import Annotated, Any, Literal | 23 | from typing import Annotated, Any, Literal, TypeAlias |
| 25 | 24 | ||
| 26 | import pydantic | 25 | import pydantic |
| 27 | from iolabs.common import config_loader | 26 | from iolabs.common import config_loader |
| 28 | 27 | ||
| 29 | logger = logging.getLogger(__name__) | 28 | logger = logging.getLogger(__name__) |
| 30 | 29 | ||
| 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" |
| 34 | 33 | ||
| 35 | Channel = Annotated[int, pydantic.Field(ge=0, le=255)] | 34 | Channel: TypeAlias = Annotated[int, pydantic.Field(ge=0, le=255)] |
| 36 | Rgba = tuple[Channel, Channel, Channel, Channel] | 35 | Rgba: TypeAlias = tuple[Channel, Channel, Channel, Channel] |
| 36 | |||
| 37 | TileGeoshiftMode: TypeAlias = Literal["auto", "subtract", "none"] | ||
| 38 | LabelMode: TypeAlias = Literal["none", "feature", "child", "both"] | ||
| 39 | MissingMetadata: TypeAlias = Literal["error", "skip"] | ||
| 40 | CollageLabelMode: TypeAlias = Literal["none", "feature", "lane"] | ||
| 37 | 41 | ||
| 38 | 42 | ||
| 39 | class XmlTopdownOverlayConfigError(config_loader.ConfigError): | 43 | class 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.""" |
| 41 | 45 | ||
| 42 | 46 | ||
| 43 | class RasterSetConfig(config_loader.ConfigModel): | 47 | class 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.""" |
| 45 | 49 | ||
| 46 | input_subdir: str | 50 | input_subdir: str |
| 47 | output_subdir: str | 51 | output_subdir: str |
| 50 | 54 | ||
| 51 | class XmlTopdownOverlayConfig(config_loader.ConfigModel): | 55 | class 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.""" |
| 53 | 57 | ||
| 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 = False | 75 | 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 = True | 79 | 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 = False | 81 | 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) |
| 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 exc | 185 | ) 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 raw | 190 | return raw |
| 197 | 191 | ||
| 198 | 192 | ||
| 193 | def _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 | |||
| 215 | def 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 | |||
| 237 | def 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 | |||
| 199 | def load_xml_topdown_overlay_config( | 261 | def 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. |
| 158 | ) | 162 | ) |
| 159 | return self | 163 | return self |
| 160 | 164 | ||
| 161 | 165 | ||
| 162 | def _load_model( | 166 | def _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 | |||
| 176 | def _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. |
| 178 | 168 | ||
| 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 a | 173 | 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) |
| 210 | 272 | ||
| 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) |
| 1 | """Visualization overlay helpers for point-cloud pipeline outputs.""" | 1 | """Visualization overlay helpers for point-cloud pipeline outputs.""" |
| 2 | 2 | ||
| 3 | from ._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 | ) | ||
| 3 | from .collage import CollageSummary, overlay_xml_on_topdown_collage | 11 | from .collage import CollageSummary, overlay_xml_on_topdown_collage |
| 4 | from .renderer import OverlaySummary, overlay_xml_on_raster_tiles | 12 | from .renderer import OverlaySummary, overlay_xml_on_raster_tiles |
| 5 | 13 | ||
| 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 | ] |
| 1 | from __future__ import annotations | 1 | from __future__ import annotations |
| 2 | 2 | ||
| 3 | import argparse | 3 | import argparse |
| 4 | import logging | 4 | import logging |
| 5 | import typing | ||
| 5 | from collections.abc import Sequence | 6 | from collections.abc import Sequence |
| 6 | from pathlib import Path | 7 | from pathlib import Path |
| 7 | 8 | ||
| 8 | from iolabs.common.cli import add_log_level_argument, configure_logging | 9 | from iolabs.common.cli import add_log_level_argument, configure_logging |
| 9 | 10 | ||
| 10 | from ._config import load_xml_topdown_overlay_config | 11 | from . import _config |
| 11 | from .renderer import overlay_xml_on_raster_tiles | 12 | from .renderer import overlay_xml_on_raster_tiles |
| 12 | 13 | ||
| 13 | logger = logging.getLogger(__name__) | 14 | logger = logging.getLogger(__name__) |
| 14 | 15 |
| 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) |
| 45 | 46 | ||
| 46 | def main(argv: Sequence[str] | None = None) -> int: | 47 | def 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_glob | 52 | config["image_glob"] = args.image_glob |
| 52 | if args.label_mode: | 53 | if args.label_mode: |
| 53 | config["label_mode"] = args.label_mode | 54 | config["label_mode"] = args.label_mode |
| 1 | from __future__ import annotations | 1 | from __future__ import annotations |
| 2 | 2 | ||
| 3 | import argparse | 3 | import argparse |
| 4 | import logging | 4 | import logging |
| 5 | import typing | ||
| 5 | from collections.abc import Sequence | 6 | from collections.abc import Sequence |
| 6 | from pathlib import Path | 7 | from pathlib import Path |
| 7 | 8 | ||
| 8 | from iolabs.common.cli import add_log_level_argument, configure_logging | 9 | from iolabs.common.cli import add_log_level_argument, configure_logging |
| 9 | 10 | ||
| 10 | from ._config import load_xml_topdown_overlay_config | 11 | from . import _config |
| 11 | from .collage import overlay_xml_on_topdown_collage | 12 | from .collage import overlay_xml_on_topdown_collage |
| 12 | 13 | ||
| 13 | logger = logging.getLogger(__name__) | 14 | logger = logging.getLogger(__name__) |
| 14 | 15 |
| 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." |
| 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) |
| 96 | 97 | ||
| 97 | def main(argv: Sequence[str] | None = None) -> int: | 98 | def 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_glob | 103 | config["image_glob"] = args.image_glob |
| 103 | if args.label_mode: | 104 | if args.label_mode: |
| 104 | config["collage_label_mode"] = args.label_mode | 105 | config["collage_label_mode"] = args.label_mode |
| 7 | 7 | ||
| 8 | import pytest | 8 | import pytest |
| 9 | from iolabs.common import config_loader | 9 | from iolabs.common import config_loader |
| 10 | 10 | ||
| 11 | import iolabs_point_cloud_visualization_overlays as overlays | ||
| 11 | from iolabs_point_cloud_visualization_overlays import _config | 12 | from iolabs_point_cloud_visualization_overlays import _config |
| 12 | 13 | ||
| 13 | 14 | ||
| 14 | def _write_overrides(tmp_path: Path, payload: dict[str, object]) -> Path: | 15 | def _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 path | 19 | return path |
| 18 | 20 | ||
| 19 | 21 | ||
| 20 | def test_packaged_defaults_load_as_plain_dict() -> None: | 22 | def 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" |
| 61 | } | 63 | } |
| 62 | ) | 64 | ) |
| 63 | 65 | ||
| 64 | 66 | ||
| 65 | def test_config_path_deep_merges_onto_packaged_defaults(tmp_path: Path) -> None: | 67 | def 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 | ) |
| 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) |
| 115 | 117 | ||
| 116 | 118 | ||
| 117 | def test_set_style_strings_coerce_like_the_fleet_matrix(tmp_path: Path) -> None: | 119 | def 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", |
| 128 | assert config["include_alternative_axes"] is True | 130 | assert config["include_alternative_axes"] is True |
| 129 | assert config["line_width_px"] == 8 | 131 | assert config["line_width_px"] == 8 |
| 130 | assert config["collage_lane_label_spacing_m"] == 100.0 | 132 | assert config["collage_lane_label_spacing_m"] == 100.0 |
| 131 | assert config["collage_parts"] == 2 | 133 | 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) | ||
| 132 | 137 | ||
| 133 | 138 | ||
| 134 | def test_non_object_overlay_file_is_rejected(tmp_path: Path) -> None: | 139 | def test_non_object_overlay_file_is_rejected(tmp_path: Path) -> None: |
| 135 | path = tmp_path / "overlay.json" | 140 | path = tmp_path / "overlay.json" |
| 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) |
| 139 | 144 | ||
| 140 | 145 | ||
| 141 | def test_model_defaults_match_the_packaged_json() -> None: | 146 | def 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) | ||
| 146 | 155 | ||
| 147 | 156 | ||
| 148 | def test_extra_raster_set_is_accepted(tmp_path: Path) -> None: | 157 | def test_extra_raster_set_is_accepted(tmp_path: Path) -> None: |
| 149 | path = _write_overrides( | 158 | path = _write_overrides( |
| 243 | def test_color_map_accepts_set_style_strings(tmp_path: Path) -> None: | 252 | def 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 | |||
| 258 | def test_error_class_is_config_error() -> None: | ||
| 259 | assert issubclass(_config.XmlTopdownOverlayConfigError, config_loader.ConfigError) | ||
| 260 | assert issubclass(_config.XmlTopdownOverlayConfigError, ValueError) | ||
| 261 | |||
| 262 | |||
| 263 | def 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 | |||
| 274 | def 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 | |||
| 284 | def 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 | |||
| 292 | def 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 | ) |
| 55 | ``` | 55 | ``` |
| 56 | 56 | ||
| 57 | ## Configuration | 57 | ## Configuration |
| 58 | 58 | ||
| 59 | Defaults live in `src/iolabs_point_cloud_visualization_overlays/xml_topdown_overlay.default.json` | 59 | Defaults live in |
| 60 | and 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 values | 61 | The schema is `XmlTopdownOverlayConfig` in |
| 62 | fail loudly; a JSON override file is deep-merged onto the packaged defaults | 62 | `iolabs_point_cloud_visualization_overlays._config` (a |
| 63 | and re-validated. | 63 | `config_loader.ConfigModel`); nested JSON sections are nested models and |
| 64 | unknown keys are rejected. **To add a config key: add the field (with its type, | ||
| 65 | default and any `Field` range) to the model and the same key with the same | ||
| 66 | default 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 | ||
| 69 | plain `dict`, because callers copy and mutate it. Runtime overrides come from | ||
| 70 | repeatable `--set KEY=VALUE`, never repo-local JSON. | ||
| 71 | |||
| 72 | `build_xml_topdown_overlay_config(overrides=..., config_path=...)` deep-merges | ||
| 73 | the config file **onto** the packaged defaults (it does not replace them), then | ||
| 74 | merges `overrides` on top; `load_xml_topdown_overlay_config(config_path)` is the | ||
| 75 | same call without overrides. | ||
| 64 | 76 | ||
| 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 | |||
| 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 | """Visualization overlay helpers for point-cloud pipeline outputs.""" | 1 | """Visualization overlay helpers for point-cloud pipeline outputs.""" |
| 2 | 2 | ||
| 3 | from ._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 | ) | ||
| 3 | from .collage import CollageSummary, overlay_xml_on_topdown_collage | 11 | from .collage import CollageSummary, overlay_xml_on_topdown_collage |
| 4 | from .renderer import OverlaySummary, overlay_xml_on_raster_tiles | 12 | from .renderer import OverlaySummary, overlay_xml_on_raster_tiles |
| 5 | 13 | ||
| 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 | ] |
| 1 | """Load, merge and validate XML top-down overlay configuration. | 1 | """Load, merge and validate the XML top-down overlay configuration. |
| 2 | 2 | ||
| 3 | The pydantic model tree below is the schema and mirrors | 3 | The schema is `XmlTopdownOverlayConfig` (a `config_loader.ConfigModel`), |
| 4 | ``xml_topdown_overlay.default.json`` exactly: unknown keys fail, and string | 4 | mirroring ``xml_topdown_overlay.default.json`` key for key: unknown keys fail, |
| 5 | choices are ``Literal``-checked here rather than at the point of use. Loading, | 5 | and string choices are ``Literal``-checked here rather than at the point of use. |
| 6 | deep-merging and validation are delegated to :mod:`iolabs.common.config_loader`. | ||
| 7 | 6 | ||
| 8 | :func:`load_xml_topdown_overlay_config` keeps returning a plain dict, because | 7 | Adding a config key means adding the field to the model and the same key to |
| 9 | callers (CLI, renderer, collage, LaneFinder) pass and mutate the mapping. | 8 | ``xml_topdown_overlay.default.json`` — nothing else. Unknown keys are rejected. |
| 10 | 9 | ||
| 11 | ``raster_sets`` stays an open name-to-entry map (the run_7b wrapper selects sets | 10 | The entry points return a plain ``dict``, because callers (CLI, renderer, |
| 12 | by name), but each entry is validated by :class:`RasterSetConfig`. | 11 | collage, LaneFinder ``run_7b``) copy and mutate the mapping; sequence fields |
| 13 | 12 | therefore stay ``list`` so the dump stays mutable. ``raster_sets`` stays an open | |
| 14 | Adding a config key means adding the field to the model here and the same key to | 13 | name-to-entry map (the run_7b wrapper selects sets by name), but each entry is |
| 15 | ``xml_topdown_overlay.default.json`` — nothing else. | 14 | validated by :class:`XmlTopdownOverlayRasterSetConfig`. |
| 16 | """ | 15 | """ |
| 17 | 16 | ||
| 18 | from __future__ import annotations | 17 | from __future__ import annotations |
| 19 | 18 | ||
| 20 | import json | 19 | import json |
| 21 | import logging | 20 | import logging |
| 22 | from collections.abc import Mapping | 21 | from collections.abc import Mapping |
| 23 | from pathlib import Path | 22 | from pathlib import Path |
| 24 | from typing import Annotated, Any, Literal | 23 | from typing import Annotated, Any, Literal, TypeAlias |
| 25 | 24 | ||
| 26 | import pydantic | 25 | import pydantic |
| 27 | from iolabs.common import config_loader | 26 | from iolabs.common import config_loader |
| 28 | 27 | ||
| 29 | logger = logging.getLogger(__name__) | 28 | logger = logging.getLogger(__name__) |
| 30 | 29 | ||
| 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" |
| 34 | 33 | ||
| 35 | Channel = Annotated[int, pydantic.Field(ge=0, le=255)] | 34 | Channel: TypeAlias = Annotated[int, pydantic.Field(ge=0, le=255)] |
| 36 | Rgba = tuple[Channel, Channel, Channel, Channel] | 35 | Rgba: TypeAlias = tuple[Channel, Channel, Channel, Channel] |
| 36 | |||
| 37 | TileGeoshiftMode: TypeAlias = Literal["auto", "subtract", "none"] | ||
| 38 | LabelMode: TypeAlias = Literal["none", "feature", "child", "both"] | ||
| 39 | MissingMetadata: TypeAlias = Literal["error", "skip"] | ||
| 40 | CollageLabelMode: TypeAlias = Literal["none", "feature", "lane"] | ||
| 37 | 41 | ||
| 38 | 42 | ||
| 39 | class XmlTopdownOverlayConfigError(config_loader.ConfigError): | 43 | class 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.""" |
| 41 | 45 | ||
| 42 | 46 | ||
| 43 | class RasterSetConfig(config_loader.ConfigModel): | 47 | class 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.""" |
| 45 | 49 | ||
| 46 | input_subdir: str | 50 | input_subdir: str |
| 47 | output_subdir: str | 51 | output_subdir: str |
| 50 | 54 | ||
| 51 | class XmlTopdownOverlayConfig(config_loader.ConfigModel): | 55 | class 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.""" |
| 53 | 57 | ||
| 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 = False | 75 | 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 = True | 79 | 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 = False | 81 | 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) |
| 158 | ) | 162 | ) |
| 159 | return self | 163 | return self |
| 160 | 164 | ||
| 161 | 165 | ||
| 162 | def _load_model( | 166 | def _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 | |||
| 176 | def _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. |
| 178 | 168 | ||
| 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 a | 173 | 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) |
| 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 exc | 185 | ) 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 raw | 190 | return raw |
| 197 | 191 | ||
| 198 | 192 | ||
| 193 | def _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 | |||
| 215 | def 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 | |||
| 237 | def 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 | |||
| 199 | def load_xml_topdown_overlay_config( | 261 | def 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. |
| 210 | 272 | ||
| 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) |
| 1 | from __future__ import annotations | 1 | from __future__ import annotations |
| 2 | 2 | ||
| 3 | import argparse | 3 | import argparse |
| 4 | import logging | 4 | import logging |
| 5 | import typing | ||
| 5 | from collections.abc import Sequence | 6 | from collections.abc import Sequence |
| 6 | from pathlib import Path | 7 | from pathlib import Path |
| 7 | 8 | ||
| 8 | from iolabs.common.cli import add_log_level_argument, configure_logging | 9 | from iolabs.common.cli import add_log_level_argument, configure_logging |
| 9 | 10 | ||
| 10 | from ._config import load_xml_topdown_overlay_config | 11 | from . import _config |
| 11 | from .renderer import overlay_xml_on_raster_tiles | 12 | from .renderer import overlay_xml_on_raster_tiles |
| 12 | 13 | ||
| 13 | logger = logging.getLogger(__name__) | 14 | logger = logging.getLogger(__name__) |
| 14 | 15 |
| 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) |
| 45 | 46 | ||
| 46 | def main(argv: Sequence[str] | None = None) -> int: | 47 | def 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_glob | 52 | config["image_glob"] = args.image_glob |
| 52 | if args.label_mode: | 53 | if args.label_mode: |
| 53 | config["label_mode"] = args.label_mode | 54 | config["label_mode"] = args.label_mode |
| 1 | from __future__ import annotations | 1 | from __future__ import annotations |
| 2 | 2 | ||
| 3 | import argparse | 3 | import argparse |
| 4 | import logging | 4 | import logging |
| 5 | import typing | ||
| 5 | from collections.abc import Sequence | 6 | from collections.abc import Sequence |
| 6 | from pathlib import Path | 7 | from pathlib import Path |
| 7 | 8 | ||
| 8 | from iolabs.common.cli import add_log_level_argument, configure_logging | 9 | from iolabs.common.cli import add_log_level_argument, configure_logging |
| 9 | 10 | ||
| 10 | from ._config import load_xml_topdown_overlay_config | 11 | from . import _config |
| 11 | from .collage import overlay_xml_on_topdown_collage | 12 | from .collage import overlay_xml_on_topdown_collage |
| 12 | 13 | ||
| 13 | logger = logging.getLogger(__name__) | 14 | logger = logging.getLogger(__name__) |
| 14 | 15 |
| 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." |
| 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) |
| 96 | 97 | ||
| 97 | def main(argv: Sequence[str] | None = None) -> int: | 98 | def 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_glob | 103 | config["image_glob"] = args.image_glob |
| 103 | if args.label_mode: | 104 | if args.label_mode: |
| 104 | config["collage_label_mode"] = args.label_mode | 105 | config["collage_label_mode"] = args.label_mode |
| 7 | 7 | ||
| 8 | import pytest | 8 | import pytest |
| 9 | from iolabs.common import config_loader | 9 | from iolabs.common import config_loader |
| 10 | 10 | ||
| 11 | import iolabs_point_cloud_visualization_overlays as overlays | ||
| 11 | from iolabs_point_cloud_visualization_overlays import _config | 12 | from iolabs_point_cloud_visualization_overlays import _config |
| 12 | 13 | ||
| 13 | 14 | ||
| 14 | def _write_overrides(tmp_path: Path, payload: dict[str, object]) -> Path: | 15 | def _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 path | 19 | return path |
| 18 | 20 | ||
| 19 | 21 | ||
| 20 | def test_packaged_defaults_load_as_plain_dict() -> None: | 22 | def 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" |
| 61 | } | 63 | } |
| 62 | ) | 64 | ) |
| 63 | 65 | ||
| 64 | 66 | ||
| 65 | def test_config_path_deep_merges_onto_packaged_defaults(tmp_path: Path) -> None: | 67 | def 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 | ) |
| 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) |
| 115 | 117 | ||
| 116 | 118 | ||
| 117 | def test_set_style_strings_coerce_like_the_fleet_matrix(tmp_path: Path) -> None: | 119 | def 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", |
| 128 | assert config["include_alternative_axes"] is True | 130 | assert config["include_alternative_axes"] is True |
| 129 | assert config["line_width_px"] == 8 | 131 | assert config["line_width_px"] == 8 |
| 130 | assert config["collage_lane_label_spacing_m"] == 100.0 | 132 | assert config["collage_lane_label_spacing_m"] == 100.0 |
| 131 | assert config["collage_parts"] == 2 | 133 | 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) | ||
| 132 | 137 | ||
| 133 | 138 | ||
| 134 | def test_non_object_overlay_file_is_rejected(tmp_path: Path) -> None: | 139 | def test_non_object_overlay_file_is_rejected(tmp_path: Path) -> None: |
| 135 | path = tmp_path / "overlay.json" | 140 | path = tmp_path / "overlay.json" |
| 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) |
| 139 | 144 | ||
| 140 | 145 | ||
| 141 | def test_model_defaults_match_the_packaged_json() -> None: | 146 | def 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) | ||
| 146 | 155 | ||
| 147 | 156 | ||
| 148 | def test_extra_raster_set_is_accepted(tmp_path: Path) -> None: | 157 | def test_extra_raster_set_is_accepted(tmp_path: Path) -> None: |
| 149 | path = _write_overrides( | 158 | path = _write_overrides( |
| 243 | def test_color_map_accepts_set_style_strings(tmp_path: Path) -> None: | 252 | def 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 | |||
| 258 | def test_error_class_is_config_error() -> None: | ||
| 259 | assert issubclass(_config.XmlTopdownOverlayConfigError, config_loader.ConfigError) | ||
| 260 | assert issubclass(_config.XmlTopdownOverlayConfigError, ValueError) | ||
| 261 | |||
| 262 | |||
| 263 | def 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 | |||
| 274 | def 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 | |||
| 284 | def 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 | |||
| 292 | def 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 | ) |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.