Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:32:11+02:00
Commit #29 ยท 9 snippets
README.md | 4 +- pyproject.toml | 5 +- .../_config.py | 185 +++++++++------------ tests/test_config.py | 18 ++ 4 files changed, 99 insertions(+), 113 deletions(-)
| 1 | """Inference runtime config: JSON defaults + overrides, unknown keys rejected. | 1 | """Inference runtime config: JSON defaults + overrides, unknown keys rejected. |
| 2 | 2 | ||
| 3 | Mirrors the ``_config.py`` convention of the sibling ``iolabs.pointcloud.*`` | 3 | Mirrors ``line_bitmap_inference.default.json`` with a pydantic |
| 4 | packages (frozenset allow-lists, deep-merge overrides, fail-fast on typos). This | 4 | ``config_loader.ConfigModel`` tree. Unknown keys fail fast; ``--set``-style |
| 5 | is the inference *runtime* config (tiling, device, output toggles, vectorization | 5 | string overrides coerce through the fleet accepted-input matrix. This is the |
| 6 | inference *runtime* config (tiling, device, output toggles, vectorization | ||
| 6 | params). The *model* spec (architecture/encoder/classes) is a separate artifact | 7 | params). The *model* spec (architecture/encoder/classes) is a separate artifact |
| 7 | read from the training YAML or a deployment bundle (see ``model_spec.py``). | 8 | read from the training YAML or a deployment bundle (see ``model_spec.py``). |
| 8 | """ | ||
| 9 | from __future__ import annotations | ||
| 10 | 9 | ||
| 11 | import json | 10 | To add a config key, add the field to the model and the JSON default; nothing |
| 11 | else. | ||
| 12 | """ | ||
| 13 | import logging | ||
| 12 | import os | 14 | import os |
| 13 | from importlib import resources | ||
| 14 | from pathlib import Path | 15 | from pathlib import Path |
| 15 | from typing import Any | 16 | from typing import Any |
| 16 | 17 | ||
| 17 | ALLOWED_INFERENCE_CONFIG_KEYS = frozenset( | 18 | from iolabs.common import config_loader |
| 18 | { | 19 | |
| 19 | "tile_size", | 20 | logger = logging.getLogger(__name__) |
| 20 | "overlap", | 21 | |
| 21 | "batch_size", | 22 | _PACKAGE_NAME = "iolabs_image_analyzer_line_bitmap_inference" |
| 22 | "device", | 23 | _DEFAULT_FILENAME = "line_bitmap_inference.default.json" |
| 23 | "precision", | 24 | |
| 24 | "tta", | 25 | |
| 25 | "blend", | 26 | class VectorizationConfig(config_loader.ConfigModel): |
| 26 | "write_vectors", | 27 | """Polyline extraction parameters applied to the predicted mask.""" |
| 27 | "write_probabilities", | 28 | |
| 28 | "write_overlay", | 29 | min_component_pixels: int = 16 |
| 29 | "probabilities_dtype", | 30 | simplify_tolerance_px: float = 2.0 |
| 30 | "vectorization", | 31 | dash_max_gap_px: float = 40.0 |
| 31 | } | 32 | |
| 32 | ) | 33 | |
| 33 | 34 | class LineBitmapInferenceConfig(config_loader.ConfigModel): | |
| 34 | ALLOWED_VECTORIZATION_KEYS = frozenset( | 35 | """Runtime inference config mirroring ``line_bitmap_inference.default.json``.""" |
| 35 | { | 36 | |
| 36 | "min_component_pixels", | 37 | tile_size: int = 512 |
| 37 | "simplify_tolerance_px", | 38 | overlap: int = 128 |
| 38 | "dash_max_gap_px", | 39 | batch_size: int = 4 |
| 39 | } | 40 | device: str = "auto" |
| 40 | ) | 41 | precision: str = "auto" |
| 41 | 42 | tta: bool = False | |
| 42 | 43 | blend: str = "hann" | |
| 43 | class LineBitmapInferenceConfigError(ValueError): | 44 | write_vectors: bool = True |
| 45 | write_probabilities: bool = False | ||
| 46 | write_overlay: bool = False | ||
| 47 | probabilities_dtype: str = "float16" | ||
| 48 | vectorization: VectorizationConfig = VectorizationConfig() | ||
| 49 | |||
| 50 | |||
| 51 | class LineBitmapInferenceConfigError(config_loader.ConfigError): | ||
| 44 | """Raised when the inference config contains unsupported keys.""" | 52 | """Raised when the inference config contains unsupported keys.""" |
| 45 | 53 | ||
| 46 | 54 | ||
| 47 | def _default_config_path() -> Path: | 55 | def _config_path_for_load(config_path: str | Path | None) -> str | Path | None: |
| 56 | """Return *config_path*, or the sibling JSON when this module is loaded loose.""" | ||
| 57 | if config_path is not None: | ||
| 58 | return config_path | ||
| 48 | if __package__ in {None, ""}: | 59 | if __package__ in {None, ""}: |
| 49 | return Path(__file__).resolve().with_name("line_bitmap_inference.default.json") | 60 | return Path(__file__).resolve().with_name(_DEFAULT_FILENAME) |
| 50 | return Path( | 61 | return None |
| 51 | str(resources.files(__package__).joinpath("line_bitmap_inference.default.json")) | ||
| 52 | ) | ||
| 53 | |||
| 54 | |||
| 55 | def _deep_merge_dicts(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]: | ||
| 56 | for key, value in overrides.items(): | ||
| 57 | if isinstance(value, dict) and isinstance(base.get(key), dict): | ||
| 58 | base[key] = _deep_merge_dicts(dict(base[key]), value) | ||
| 59 | else: | ||
| 60 | base[key] = value | ||
| 61 | return base | ||
| 62 | |||
| 63 | |||
| 64 | def _validate_allowed_keys( | ||
| 65 | config: dict[str, Any], allowed_keys: frozenset[str], *, context: str | ||
| 66 | ) -> None: | ||
| 67 | unknown_keys = sorted(set(config) - allowed_keys) | ||
| 68 | if not unknown_keys: | ||
| 69 | return | ||
| 70 | allowed = ", ".join(sorted(allowed_keys)) | ||
| 71 | raise LineBitmapInferenceConfigError( | ||
| 72 | f"Unknown {context} key(s): {', '.join(unknown_keys)}. Allowed keys: {allowed}" | ||
| 73 | ) | ||
| 74 | |||
| 75 | |||
| 76 | def _normalize_section( | ||
| 77 | raw_section: Any, *, allowed_keys: frozenset[str], context: str | ||
| 78 | ) -> dict[str, Any]: | ||
| 79 | if raw_section is None: | ||
| 80 | section: dict[str, Any] = {} | ||
| 81 | elif isinstance(raw_section, dict): | ||
| 82 | section = dict(raw_section) | ||
| 83 | else: | ||
| 84 | raise LineBitmapInferenceConfigError(f"{context} must be a mapping") | ||
| 85 | _validate_allowed_keys(section, allowed_keys, context=context) | ||
| 86 | return section | ||
| 87 | 62 | ||
| 88 | 63 | ||
| 89 | def normalize_line_bitmap_inference_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 64 | def normalize_line_bitmap_inference_config(raw_config: dict[str, Any]) -> dict[str, Any]: |
| 90 | config = dict(raw_config) | 65 | """Validate *raw_config* against the model, filling defaults; return a dict.""" |
| 91 | _validate_allowed_keys( | 66 | config = config_loader.validate_config( |
| 92 | config, ALLOWED_INFERENCE_CONFIG_KEYS, context="inference config" | 67 | LineBitmapInferenceConfig, |
| 93 | ) | 68 | raw_config, |
| 94 | 69 | context="inference config", | |
| 95 | config.setdefault("tile_size", 512) | 70 | error_cls=LineBitmapInferenceConfigError, |
| 96 | config.setdefault("overlap", 128) | 71 | ).model_dump() |
| 97 | config.setdefault("batch_size", 4) | 72 | logger.debug("Normalized inference config") |
| 98 | config.setdefault("device", "auto") | ||
| 99 | config.setdefault("precision", "auto") | ||
| 100 | config.setdefault("tta", False) | ||
| 101 | config.setdefault("blend", "hann") | ||
| 102 | config.setdefault("write_vectors", True) | ||
| 103 | config.setdefault("write_probabilities", False) | ||
| 104 | config.setdefault("write_overlay", False) | ||
| 105 | config.setdefault("probabilities_dtype", "float16") | ||
| 106 | |||
| 107 | vectorization = _normalize_section( | ||
| 108 | config.get("vectorization"), | ||
| 109 | allowed_keys=ALLOWED_VECTORIZATION_KEYS, | ||
| 110 | context="inference vectorization", | ||
| 111 | ) | ||
| 112 | vectorization.setdefault("min_component_pixels", 16) | ||
| 113 | vectorization.setdefault("simplify_tolerance_px", 2.0) | ||
| 114 | vectorization.setdefault("dash_max_gap_px", 40.0) | ||
| 115 | config["vectorization"] = vectorization | ||
| 116 | |||
| 117 | return config | 73 | return config |
| 118 | 74 | ||
| 119 | 75 | ||
| 120 | def load_line_bitmap_inference_config( | 76 | def load_line_bitmap_inference_config( |
| 121 | config_path: str | Path | None = None, | 77 | config_path: str | Path | None = None, |
| 122 | ) -> dict[str, Any]: | 78 | ) -> dict[str, Any]: |
| 123 | resolved_path = ( | 79 | """Load packaged (or *config_path*) defaults and validate them.""" |
| 124 | Path(config_path) if config_path is not None else _default_config_path() | 80 | return config_loader.load_config( |
| 125 | ) | 81 | LineBitmapInferenceConfig, |
| 126 | with resolved_path.open("r", encoding="utf-8") as handle: | 82 | package=_PACKAGE_NAME, |
| 127 | raw_config: dict[str, Any] = json.load(handle) | 83 | filename=_DEFAULT_FILENAME, |
| 128 | return normalize_line_bitmap_inference_config(raw_config) | 84 | config_path=_config_path_for_load(config_path), |
| 85 | context="inference config", | ||
| 86 | error_cls=LineBitmapInferenceConfigError, | ||
| 87 | ).model_dump() | ||
| 129 | 88 | ||
| 130 | 89 | ||
| 131 | def build_line_bitmap_inference_config( | 90 | def build_line_bitmap_inference_config( |
| 132 | *, | 91 | *, |
| 133 | overrides: dict[str, Any] | None = None, | 92 | overrides: dict[str, Any] | None = None, |
| 134 | config_path: str | Path | None = None, | 93 | config_path: str | Path | None = None, |
| 135 | ) -> dict[str, Any]: | 94 | ) -> dict[str, Any]: |
| 136 | config = load_line_bitmap_inference_config(config_path) | 95 | """Load defaults, deep-merge *overrides*, and validate.""" |
| 137 | if overrides: | 96 | return config_loader.load_config( |
| 138 | config = _deep_merge_dicts(config, dict(overrides)) | 97 | LineBitmapInferenceConfig, |
| 139 | return normalize_line_bitmap_inference_config(config) | 98 | package=_PACKAGE_NAME, |
| 99 | filename=_DEFAULT_FILENAME, | ||
| 100 | overrides=overrides, | ||
| 101 | config_path=_config_path_for_load(config_path), | ||
| 102 | context="inference config", | ||
| 103 | error_cls=LineBitmapInferenceConfigError, | ||
| 104 | ).model_dump() | ||
| 140 | 105 | ||
| 141 | 106 | ||
| 142 | # --------------------------------------------------------------------------- | 107 | # --------------------------------------------------------------------------- |
| 143 | # Model artifact registry | 108 | # Model artifact registry |
| 46 | assert config["tile_size"] == 768 | 46 | assert config["tile_size"] == 768 |
| 47 | assert config["overlap"] == 128 # untouched default | 47 | assert config["overlap"] == 128 # untouched default |
| 48 | assert config["vectorization"]["dash_max_gap_px"] == 10.0 | 48 | assert config["vectorization"]["dash_max_gap_px"] == 10.0 |
| 49 | assert config["vectorization"]["min_component_pixels"] == 16 # untouched default | 49 | assert config["vectorization"]["min_component_pixels"] == 16 # untouched default |
| 50 | |||
| 51 | |||
| 52 | def test_string_overrides_are_coerced() -> None: | ||
| 53 | config = MODULE.build_line_bitmap_inference_config( | ||
| 54 | overrides={ | ||
| 55 | "tile_size": "1e3", | ||
| 56 | "tta": "on", | ||
| 57 | "vectorization": {"dash_max_gap_px": "5"}, | ||
| 58 | } | ||
| 59 | ) | ||
| 60 | assert config["tile_size"] == 1000 | ||
| 61 | assert config["tta"] is True | ||
| 62 | assert config["vectorization"]["dash_max_gap_px"] == 5.0 | ||
| 63 | |||
| 64 | |||
| 65 | def test_bool_is_rejected_for_int_field() -> None: | ||
| 66 | with pytest.raises(MODULE.LineBitmapInferenceConfigError, match="tile_size"): | ||
| 67 | MODULE.build_line_bitmap_inference_config(overrides={"tile_size": True}) |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-image-analyzer-line-bitmap-inference" | 2 | name = "iolabs-image-analyzer-line-bitmap-inference" |
| 3 | version = "0.2.0" | 3 | version = "0.2.1" |
| 4 | description = "Road-marking segmentation inference for LiDAR intensity rasters (solid/dashed lane lines)" | 4 | description = "Road-marking segmentation inference for LiDAR intensity rasters (solid/dashed lane lines)" |
| 5 | requires-python = ">=3.11,<3.13" | 5 | requires-python = ">=3.11,<3.13" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 7 | "numpy>=1.26", | 7 | "numpy>=1.26", |
| 11 | "Pillow>=10.0", | 11 | "Pillow>=10.0", |
| 12 | "torch>=2.2.0", | 12 | "torch>=2.2.0", |
| 13 | "segmentation-models-pytorch>=0.3", | 13 | "segmentation-models-pytorch>=0.3", |
| 14 | "pyyaml>=6.0", | 14 | "pyyaml>=6.0", |
| 15 | "pydantic>=2.7", | ||
| 15 | "iolabs-logstash>=0.5.1", | 16 | "iolabs-logstash>=0.5.1", |
| 16 | "iolabs-common", | 17 | "iolabs-common>=0.8.0", |
| 17 | ] | 18 | ] |
| 18 | 19 | ||
| 19 | [project.optional-dependencies] | 20 | [project.optional-dependencies] |
| 20 | dev = [ | 21 | dev = [ |
| 97 | ### Key flags / config | 97 | ### Key flags / config |
| 98 | 98 | ||
| 99 | Runtime config is layered: built-in defaults โ `--config JSON` โ individual CLI | 99 | Runtime config is layered: built-in defaults โ `--config JSON` โ individual CLI |
| 100 | flags (highest precedence). Defaults live in | 100 | flags (highest precedence). Defaults live in |
| 101 | `line_bitmap_inference.default.json`: | 101 | `line_bitmap_inference.default.json`, mirrored by the pydantic `ConfigModel` |
| 102 | tree in `_config.py`. To add a config key, add the field to the model and the | ||
| 103 | JSON default โ nothing else (unknown keys are rejected automatically). | ||
| 102 | 104 | ||
| 103 | - `--tile-size` / `--overlap` โ sliding-window size and overlap (back-stepped | 105 | - `--tile-size` / `--overlap` โ sliding-window size and overlap (back-stepped |
| 104 | origins, Hann-blended). | 106 | origins, Hann-blended). |
| 105 | - `--batch-size`, `--device` (`auto|cpu|cuda`), `--precision`. | 107 | - `--batch-size`, `--device` (`auto|cpu|cuda`), `--precision`. |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-image-analyzer-line-bitmap-inference" | 2 | name = "iolabs-image-analyzer-line-bitmap-inference" |
| 3 | version = "0.2.0" | 3 | version = "0.2.1" |
| 4 | description = "Road-marking segmentation inference for LiDAR intensity rasters (solid/dashed lane lines)" | 4 | description = "Road-marking segmentation inference for LiDAR intensity rasters (solid/dashed lane lines)" |
| 5 | requires-python = ">=3.11,<3.13" | 5 | requires-python = ">=3.11,<3.13" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 7 | "numpy>=1.26", | 7 | "numpy>=1.26", |
| 11 | "Pillow>=10.0", | 11 | "Pillow>=10.0", |
| 12 | "torch>=2.2.0", | 12 | "torch>=2.2.0", |
| 13 | "segmentation-models-pytorch>=0.3", | 13 | "segmentation-models-pytorch>=0.3", |
| 14 | "pyyaml>=6.0", | 14 | "pyyaml>=6.0", |
| 15 | "pydantic>=2.7", | ||
| 15 | "iolabs-logstash>=0.5.1", | 16 | "iolabs-logstash>=0.5.1", |
| 16 | "iolabs-common", | 17 | "iolabs-common>=0.8.0", |
| 17 | ] | 18 | ] |
| 18 | 19 | ||
| 19 | [project.optional-dependencies] | 20 | [project.optional-dependencies] |
| 20 | dev = [ | 21 | dev = [ |
| 1 | """Inference runtime config: JSON defaults + overrides, unknown keys rejected. | 1 | """Inference runtime config: JSON defaults + overrides, unknown keys rejected. |
| 2 | 2 | ||
| 3 | Mirrors the ``_config.py`` convention of the sibling ``iolabs.pointcloud.*`` | 3 | Mirrors ``line_bitmap_inference.default.json`` with a pydantic |
| 4 | packages (frozenset allow-lists, deep-merge overrides, fail-fast on typos). This | 4 | ``config_loader.ConfigModel`` tree. Unknown keys fail fast; ``--set``-style |
| 5 | is the inference *runtime* config (tiling, device, output toggles, vectorization | 5 | string overrides coerce through the fleet accepted-input matrix. This is the |
| 6 | inference *runtime* config (tiling, device, output toggles, vectorization | ||
| 6 | params). The *model* spec (architecture/encoder/classes) is a separate artifact | 7 | params). The *model* spec (architecture/encoder/classes) is a separate artifact |
| 7 | read from the training YAML or a deployment bundle (see ``model_spec.py``). | 8 | read from the training YAML or a deployment bundle (see ``model_spec.py``). |
| 8 | """ | ||
| 9 | from __future__ import annotations | ||
| 10 | 9 | ||
| 11 | import json | 10 | To add a config key, add the field to the model and the JSON default; nothing |
| 11 | else. | ||
| 12 | """ | ||
| 13 | import logging | ||
| 12 | import os | 14 | import os |
| 13 | from importlib import resources | ||
| 14 | from pathlib import Path | 15 | from pathlib import Path |
| 15 | from typing import Any | 16 | from typing import Any |
| 16 | 17 | ||
| 17 | ALLOWED_INFERENCE_CONFIG_KEYS = frozenset( | 18 | from iolabs.common import config_loader |
| 18 | { | 19 | |
| 19 | "tile_size", | 20 | logger = logging.getLogger(__name__) |
| 20 | "overlap", | 21 | |
| 21 | "batch_size", | 22 | _PACKAGE_NAME = "iolabs_image_analyzer_line_bitmap_inference" |
| 22 | "device", | 23 | _DEFAULT_FILENAME = "line_bitmap_inference.default.json" |
| 23 | "precision", | 24 | |
| 24 | "tta", | 25 | |
| 25 | "blend", | 26 | class VectorizationConfig(config_loader.ConfigModel): |
| 26 | "write_vectors", | 27 | """Polyline extraction parameters applied to the predicted mask.""" |
| 27 | "write_probabilities", | 28 | |
| 28 | "write_overlay", | 29 | min_component_pixels: int = 16 |
| 29 | "probabilities_dtype", | 30 | simplify_tolerance_px: float = 2.0 |
| 30 | "vectorization", | 31 | dash_max_gap_px: float = 40.0 |
| 31 | } | 32 | |
| 32 | ) | 33 | |
| 33 | 34 | class LineBitmapInferenceConfig(config_loader.ConfigModel): | |
| 34 | ALLOWED_VECTORIZATION_KEYS = frozenset( | 35 | """Runtime inference config mirroring ``line_bitmap_inference.default.json``.""" |
| 35 | { | 36 | |
| 36 | "min_component_pixels", | 37 | tile_size: int = 512 |
| 37 | "simplify_tolerance_px", | 38 | overlap: int = 128 |
| 38 | "dash_max_gap_px", | 39 | batch_size: int = 4 |
| 39 | } | 40 | device: str = "auto" |
| 40 | ) | 41 | precision: str = "auto" |
| 41 | 42 | tta: bool = False | |
| 42 | 43 | blend: str = "hann" | |
| 43 | class LineBitmapInferenceConfigError(ValueError): | 44 | write_vectors: bool = True |
| 45 | write_probabilities: bool = False | ||
| 46 | write_overlay: bool = False | ||
| 47 | probabilities_dtype: str = "float16" | ||
| 48 | vectorization: VectorizationConfig = VectorizationConfig() | ||
| 49 | |||
| 50 | |||
| 51 | class LineBitmapInferenceConfigError(config_loader.ConfigError): | ||
| 44 | """Raised when the inference config contains unsupported keys.""" | 52 | """Raised when the inference config contains unsupported keys.""" |
| 45 | 53 | ||
| 46 | 54 | ||
| 47 | def _default_config_path() -> Path: | 55 | def _config_path_for_load(config_path: str | Path | None) -> str | Path | None: |
| 56 | """Return *config_path*, or the sibling JSON when this module is loaded loose.""" | ||
| 57 | if config_path is not None: | ||
| 58 | return config_path | ||
| 48 | if __package__ in {None, ""}: | 59 | if __package__ in {None, ""}: |
| 49 | return Path(__file__).resolve().with_name("line_bitmap_inference.default.json") | 60 | return Path(__file__).resolve().with_name(_DEFAULT_FILENAME) |
| 50 | return Path( | 61 | return None |
| 51 | str(resources.files(__package__).joinpath("line_bitmap_inference.default.json")) | ||
| 52 | ) | ||
| 53 | |||
| 54 | |||
| 55 | def _deep_merge_dicts(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]: | ||
| 56 | for key, value in overrides.items(): | ||
| 57 | if isinstance(value, dict) and isinstance(base.get(key), dict): | ||
| 58 | base[key] = _deep_merge_dicts(dict(base[key]), value) | ||
| 59 | else: | ||
| 60 | base[key] = value | ||
| 61 | return base | ||
| 62 | |||
| 63 | |||
| 64 | def _validate_allowed_keys( | ||
| 65 | config: dict[str, Any], allowed_keys: frozenset[str], *, context: str | ||
| 66 | ) -> None: | ||
| 67 | unknown_keys = sorted(set(config) - allowed_keys) | ||
| 68 | if not unknown_keys: | ||
| 69 | return | ||
| 70 | allowed = ", ".join(sorted(allowed_keys)) | ||
| 71 | raise LineBitmapInferenceConfigError( | ||
| 72 | f"Unknown {context} key(s): {', '.join(unknown_keys)}. Allowed keys: {allowed}" | ||
| 73 | ) | ||
| 74 | |||
| 75 | |||
| 76 | def _normalize_section( | ||
| 77 | raw_section: Any, *, allowed_keys: frozenset[str], context: str | ||
| 78 | ) -> dict[str, Any]: | ||
| 79 | if raw_section is None: | ||
| 80 | section: dict[str, Any] = {} | ||
| 81 | elif isinstance(raw_section, dict): | ||
| 82 | section = dict(raw_section) | ||
| 83 | else: | ||
| 84 | raise LineBitmapInferenceConfigError(f"{context} must be a mapping") | ||
| 85 | _validate_allowed_keys(section, allowed_keys, context=context) | ||
| 86 | return section | ||
| 87 | 62 | ||
| 88 | 63 | ||
| 89 | def normalize_line_bitmap_inference_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 64 | def normalize_line_bitmap_inference_config(raw_config: dict[str, Any]) -> dict[str, Any]: |
| 90 | config = dict(raw_config) | 65 | """Validate *raw_config* against the model, filling defaults; return a dict.""" |
| 91 | _validate_allowed_keys( | 66 | config = config_loader.validate_config( |
| 92 | config, ALLOWED_INFERENCE_CONFIG_KEYS, context="inference config" | 67 | LineBitmapInferenceConfig, |
| 93 | ) | 68 | raw_config, |
| 94 | 69 | context="inference config", | |
| 95 | config.setdefault("tile_size", 512) | 70 | error_cls=LineBitmapInferenceConfigError, |
| 96 | config.setdefault("overlap", 128) | 71 | ).model_dump() |
| 97 | config.setdefault("batch_size", 4) | 72 | logger.debug("Normalized inference config") |
| 98 | config.setdefault("device", "auto") | ||
| 99 | config.setdefault("precision", "auto") | ||
| 100 | config.setdefault("tta", False) | ||
| 101 | config.setdefault("blend", "hann") | ||
| 102 | config.setdefault("write_vectors", True) | ||
| 103 | config.setdefault("write_probabilities", False) | ||
| 104 | config.setdefault("write_overlay", False) | ||
| 105 | config.setdefault("probabilities_dtype", "float16") | ||
| 106 | |||
| 107 | vectorization = _normalize_section( | ||
| 108 | config.get("vectorization"), | ||
| 109 | allowed_keys=ALLOWED_VECTORIZATION_KEYS, | ||
| 110 | context="inference vectorization", | ||
| 111 | ) | ||
| 112 | vectorization.setdefault("min_component_pixels", 16) | ||
| 113 | vectorization.setdefault("simplify_tolerance_px", 2.0) | ||
| 114 | vectorization.setdefault("dash_max_gap_px", 40.0) | ||
| 115 | config["vectorization"] = vectorization | ||
| 116 | |||
| 117 | return config | 73 | return config |
| 118 | 74 | ||
| 119 | 75 | ||
| 120 | def load_line_bitmap_inference_config( | 76 | def load_line_bitmap_inference_config( |
| 121 | config_path: str | Path | None = None, | 77 | config_path: str | Path | None = None, |
| 122 | ) -> dict[str, Any]: | 78 | ) -> dict[str, Any]: |
| 123 | resolved_path = ( | 79 | """Load packaged (or *config_path*) defaults and validate them.""" |
| 124 | Path(config_path) if config_path is not None else _default_config_path() | 80 | return config_loader.load_config( |
| 125 | ) | 81 | LineBitmapInferenceConfig, |
| 126 | with resolved_path.open("r", encoding="utf-8") as handle: | 82 | package=_PACKAGE_NAME, |
| 127 | raw_config: dict[str, Any] = json.load(handle) | 83 | filename=_DEFAULT_FILENAME, |
| 128 | return normalize_line_bitmap_inference_config(raw_config) | 84 | config_path=_config_path_for_load(config_path), |
| 85 | context="inference config", | ||
| 86 | error_cls=LineBitmapInferenceConfigError, | ||
| 87 | ).model_dump() | ||
| 129 | 88 | ||
| 130 | 89 | ||
| 131 | def build_line_bitmap_inference_config( | 90 | def build_line_bitmap_inference_config( |
| 132 | *, | 91 | *, |
| 133 | overrides: dict[str, Any] | None = None, | 92 | overrides: dict[str, Any] | None = None, |
| 134 | config_path: str | Path | None = None, | 93 | config_path: str | Path | None = None, |
| 135 | ) -> dict[str, Any]: | 94 | ) -> dict[str, Any]: |
| 136 | config = load_line_bitmap_inference_config(config_path) | 95 | """Load defaults, deep-merge *overrides*, and validate.""" |
| 137 | if overrides: | 96 | return config_loader.load_config( |
| 138 | config = _deep_merge_dicts(config, dict(overrides)) | 97 | LineBitmapInferenceConfig, |
| 139 | return normalize_line_bitmap_inference_config(config) | 98 | package=_PACKAGE_NAME, |
| 99 | filename=_DEFAULT_FILENAME, | ||
| 100 | overrides=overrides, | ||
| 101 | config_path=_config_path_for_load(config_path), | ||
| 102 | context="inference config", | ||
| 103 | error_cls=LineBitmapInferenceConfigError, | ||
| 104 | ).model_dump() | ||
| 140 | 105 | ||
| 141 | 106 | ||
| 142 | # --------------------------------------------------------------------------- | 107 | # --------------------------------------------------------------------------- |
| 143 | # Model artifact registry | 108 | # Model artifact registry |
| 46 | assert config["tile_size"] == 768 | 46 | assert config["tile_size"] == 768 |
| 47 | assert config["overlap"] == 128 # untouched default | 47 | assert config["overlap"] == 128 # untouched default |
| 48 | assert config["vectorization"]["dash_max_gap_px"] == 10.0 | 48 | assert config["vectorization"]["dash_max_gap_px"] == 10.0 |
| 49 | assert config["vectorization"]["min_component_pixels"] == 16 # untouched default | 49 | assert config["vectorization"]["min_component_pixels"] == 16 # untouched default |
| 50 | |||
| 51 | |||
| 52 | def test_string_overrides_are_coerced() -> None: | ||
| 53 | config = MODULE.build_line_bitmap_inference_config( | ||
| 54 | overrides={ | ||
| 55 | "tile_size": "1e3", | ||
| 56 | "tta": "on", | ||
| 57 | "vectorization": {"dash_max_gap_px": "5"}, | ||
| 58 | } | ||
| 59 | ) | ||
| 60 | assert config["tile_size"] == 1000 | ||
| 61 | assert config["tta"] is True | ||
| 62 | assert config["vectorization"]["dash_max_gap_px"] == 5.0 | ||
| 63 | |||
| 64 | |||
| 65 | def test_bool_is_rejected_for_int_field() -> None: | ||
| 66 | with pytest.raises(MODULE.LineBitmapInferenceConfigError, match="tile_size"): | ||
| 67 | MODULE.build_line_bitmap_inference_config(overrides={"tile_size": True}) |
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.