Back to report index

Step 5 linebitmapinference c209bfa: AI3D-379 Pydantic config models via iolabs-common ConfigModel

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(-)
Importance #1: src/iolabs_image_analyzer_line_bitmap_inference/_config.py @@ -1,143 +1,108 @@
1"""Inference runtime config: JSON defaults + overrides, unknown keys rejected.1"""Inference runtime config: JSON defaults + overrides, unknown keys rejected.
22
3Mirrors the ``_config.py`` convention of the sibling ``iolabs.pointcloud.*``3Mirrors ``line_bitmap_inference.default.json`` with a pydantic
4packages (frozenset allow-lists, deep-merge overrides, fail-fast on typos). This4``config_loader.ConfigModel`` tree. Unknown keys fail fast; ``--set``-style
5is the inference *runtime* config (tiling, device, output toggles, vectorization5string overrides coerce through the fleet accepted-input matrix. This is the
6inference *runtime* config (tiling, device, output toggles, vectorization
6params). The *model* spec (architecture/encoder/classes) is a separate artifact7params). The *model* spec (architecture/encoder/classes) is a separate artifact
7read from the training YAML or a deployment bundle (see ``model_spec.py``).8read from the training YAML or a deployment bundle (see ``model_spec.py``).
8"""
9from __future__ import annotations
109
11import json10To add a config key, add the field to the model and the JSON default; nothing
11else.
12"""
13import logging
12import os14import os
13from importlib import resources
14from pathlib import Path15from pathlib import Path
15from typing import Any16from typing import Any
1617
17ALLOWED_INFERENCE_CONFIG_KEYS = frozenset(18from iolabs.common import config_loader
18 {19
19 "tile_size",20logger = 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",26class 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
3334class LineBitmapInferenceConfig(config_loader.ConfigModel):
34ALLOWED_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"
4142 tta: bool = False
4243 blend: str = "hann"
43class 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
51class LineBitmapInferenceConfigError(config_loader.ConfigError):
44 """Raised when the inference config contains unsupported keys."""52 """Raised when the inference config contains unsupported keys."""
4553
4654
47def _default_config_path() -> Path:55def _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
55def _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
64def _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
76def _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
8762
8863
89def normalize_line_bitmap_inference_config(raw_config: dict[str, Any]) -> dict[str, Any]:64def 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,
9469 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 config73 return config
11874
11975
120def load_line_bitmap_inference_config(76def 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()
12988
13089
131def build_line_bitmap_inference_config(90def 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()
140105
141106
142# ---------------------------------------------------------------------------107# ---------------------------------------------------------------------------
143# Model artifact registry108# Model artifact registry
Importance #2: tests/test_config.py @@ -46,4 +46,22 @@
46 assert config["tile_size"] == 76846 assert config["tile_size"] == 768
47 assert config["overlap"] == 128 # untouched default47 assert config["overlap"] == 128 # untouched default
48 assert config["vectorization"]["dash_max_gap_px"] == 10.048 assert config["vectorization"]["dash_max_gap_px"] == 10.0
49 assert config["vectorization"]["min_component_pixels"] == 16 # untouched default49 assert config["vectorization"]["min_component_pixels"] == 16 # untouched default
50
51
52def 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
65def 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})
Importance #3: pyproject.toml @@ -1,7 +1,7 @@
1[project]1[project]
2name = "iolabs-image-analyzer-line-bitmap-inference"2name = "iolabs-image-analyzer-line-bitmap-inference"
3version = "0.2.0"3version = "0.2.1"
4description = "Road-marking segmentation inference for LiDAR intensity rasters (solid/dashed lane lines)"4description = "Road-marking segmentation inference for LiDAR intensity rasters (solid/dashed lane lines)"
5requires-python = ">=3.11,<3.13"5requires-python = ">=3.11,<3.13"
6dependencies = [6dependencies = [
7 "numpy>=1.26",7 "numpy>=1.26",
Importance #4: pyproject.toml @@ -11,10 +11,11 @@
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]
1819
19[project.optional-dependencies]20[project.optional-dependencies]
20dev = [21dev = [
Importance #5: README.md @@ -97,9 +97,11 @@
97### Key flags / config97### Key flags / config
9898
99Runtime config is layered: built-in defaults โ†’ `--config JSON` โ†’ individual CLI99Runtime config is layered: built-in defaults โ†’ `--config JSON` โ†’ individual CLI
100flags (highest precedence). Defaults live in100flags (highest precedence). Defaults live in
101`line_bitmap_inference.default.json`:101`line_bitmap_inference.default.json`, mirrored by the pydantic `ConfigModel`
102tree in `_config.py`. To add a config key, add the field to the model and the
103JSON default โ€” nothing else (unknown keys are rejected automatically).
102104
103- `--tile-size` / `--overlap` โ€” sliding-window size and overlap (back-stepped105- `--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`.
Importance #6: pyproject.toml @@ -1,7 +1,7 @@
1[project]1[project]
2name = "iolabs-image-analyzer-line-bitmap-inference"2name = "iolabs-image-analyzer-line-bitmap-inference"
3version = "0.2.0"3version = "0.2.1"
4description = "Road-marking segmentation inference for LiDAR intensity rasters (solid/dashed lane lines)"4description = "Road-marking segmentation inference for LiDAR intensity rasters (solid/dashed lane lines)"
5requires-python = ">=3.11,<3.13"5requires-python = ">=3.11,<3.13"
6dependencies = [6dependencies = [
7 "numpy>=1.26",7 "numpy>=1.26",
Importance #7: pyproject.toml @@ -11,10 +11,11 @@
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]
1819
19[project.optional-dependencies]20[project.optional-dependencies]
20dev = [21dev = [
Importance #8: src/iolabs_image_analyzer_line_bitmap_inference/_config.py @@ -1,143 +1,108 @@
1"""Inference runtime config: JSON defaults + overrides, unknown keys rejected.1"""Inference runtime config: JSON defaults + overrides, unknown keys rejected.
22
3Mirrors the ``_config.py`` convention of the sibling ``iolabs.pointcloud.*``3Mirrors ``line_bitmap_inference.default.json`` with a pydantic
4packages (frozenset allow-lists, deep-merge overrides, fail-fast on typos). This4``config_loader.ConfigModel`` tree. Unknown keys fail fast; ``--set``-style
5is the inference *runtime* config (tiling, device, output toggles, vectorization5string overrides coerce through the fleet accepted-input matrix. This is the
6inference *runtime* config (tiling, device, output toggles, vectorization
6params). The *model* spec (architecture/encoder/classes) is a separate artifact7params). The *model* spec (architecture/encoder/classes) is a separate artifact
7read from the training YAML or a deployment bundle (see ``model_spec.py``).8read from the training YAML or a deployment bundle (see ``model_spec.py``).
8"""
9from __future__ import annotations
109
11import json10To add a config key, add the field to the model and the JSON default; nothing
11else.
12"""
13import logging
12import os14import os
13from importlib import resources
14from pathlib import Path15from pathlib import Path
15from typing import Any16from typing import Any
1617
17ALLOWED_INFERENCE_CONFIG_KEYS = frozenset(18from iolabs.common import config_loader
18 {19
19 "tile_size",20logger = 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",26class 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
3334class LineBitmapInferenceConfig(config_loader.ConfigModel):
34ALLOWED_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"
4142 tta: bool = False
4243 blend: str = "hann"
43class 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
51class LineBitmapInferenceConfigError(config_loader.ConfigError):
44 """Raised when the inference config contains unsupported keys."""52 """Raised when the inference config contains unsupported keys."""
4553
4654
47def _default_config_path() -> Path:55def _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
55def _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
64def _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
76def _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
8762
8863
89def normalize_line_bitmap_inference_config(raw_config: dict[str, Any]) -> dict[str, Any]:64def 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,
9469 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 config73 return config
11874
11975
120def load_line_bitmap_inference_config(76def 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()
12988
13089
131def build_line_bitmap_inference_config(90def 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()
140105
141106
142# ---------------------------------------------------------------------------107# ---------------------------------------------------------------------------
143# Model artifact registry108# Model artifact registry
Importance #9: tests/test_config.py @@ -46,4 +46,22 @@
46 assert config["tile_size"] == 76846 assert config["tile_size"] == 768
47 assert config["overlap"] == 128 # untouched default47 assert config["overlap"] == 128 # untouched default
48 assert config["vectorization"]["dash_max_gap_px"] == 10.048 assert config["vectorization"]["dash_max_gap_px"] == 10.0
49 assert config["vectorization"]["min_component_pixels"] == 16 # untouched default49 assert config["vectorization"]["min_component_pixels"] == 16 # untouched default
50
51
52def 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
65def 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})