Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:36:56+02:00
Commit #31 · 32 snippets
README.md | 23 +++- .../__init__.py | 12 +- .../_config.py | 150 +++++++-------------- .../model_fetch.py | 14 +- .../model_registry.py | 67 +++++++++ tests/test_config.py | 84 +++++++----- 6 files changed, 195 insertions(+), 155 deletions(-)
| 21 | logger = logging.getLogger(__name__) | 22 | logger = logging.getLogger(__name__) |
| 22 | 23 | ||
| 23 | _PACKAGE_NAME = "iolabs_image_analyzer_line_bitmap_inference" | 24 | _PACKAGE_NAME = "iolabs_image_analyzer_line_bitmap_inference" |
| 24 | _DEFAULT_FILENAME = "line_bitmap_inference.default.json" | 25 | _DEFAULT_FILENAME = "line_bitmap_inference.default.json" |
| 26 | _CONTEXT = "line bitmap inference config" | ||
| 25 | 27 | ||
| 26 | 28 | ||
| 27 | class VectorizationConfig(config_loader.ConfigModel): | 29 | class LineBitmapInferenceVectorizationConfig(config_loader.ConfigModel): |
| 28 | """Polyline extraction parameters applied to the predicted mask.""" | 30 | """Polyline extraction parameters applied to the predicted mask.""" |
| 29 | 31 | ||
| 30 | min_component_pixels: int = 16 | 32 | min_component_pixels: int = 16 |
| 31 | simplify_tolerance_px: float = 2.0 | 33 | simplify_tolerance_px: float = 2.0 |
| 55 | return {} if value is None else value | 59 | return {} if value is None else value |
| 56 | 60 | ||
| 57 | 61 | ||
| 58 | class LineBitmapInferenceConfigError(config_loader.ConfigError): | 62 | class LineBitmapInferenceConfigError(config_loader.ConfigError): |
| 59 | """Raised when the inference config contains unsupported keys or values.""" | 63 | """Raised when line bitmap inference config contains unsupported keys or values.""" |
| 60 | 64 | ||
| 61 | 65 | ||
| 62 | def _config_path_for_load(config_path: str | Path | None) -> str | Path | None: | 66 | def _load_model( |
| 63 | """Return *config_path*, or the sibling JSON when this module is loaded loose.""" | 67 | *, |
| 68 | overrides: Mapping[str, Any] | None = None, | ||
| 69 | config_path: str | Path | None = None, | ||
| 70 | ) -> LineBitmapInferenceConfig: | ||
| 71 | """Load packaged (or *config_path*) defaults, merge *overrides*, validate.""" | ||
| 72 | if overrides: | ||
| 73 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | ||
| 64 | if config_path is not None: | 74 | if config_path is not None: |
| 65 | return config_path | 75 | logger.info("Config file applied: %s", config_path) |
| 66 | if __package__ in {None, ""}: | 76 | return config_loader.load_config( |
| 67 | return Path(__file__).resolve().with_name(_DEFAULT_FILENAME) | 77 | LineBitmapInferenceConfig, |
| 68 | return None | 78 | package=_PACKAGE_NAME, |
| 79 | filename=_DEFAULT_FILENAME, | ||
| 80 | overrides=overrides, | ||
| 81 | config_path=config_path, | ||
| 82 | context=_CONTEXT, | ||
| 83 | error_cls=LineBitmapInferenceConfigError, | ||
| 84 | ) | ||
| 69 | 85 | ||
| 70 | 86 | ||
| 71 | def normalize_line_bitmap_inference_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 87 | def normalize_line_bitmap_inference_config( |
| 88 | raw_config: Mapping[str, Any], | ||
| 89 | ) -> dict[str, Any]: | ||
| 72 | """Validate *raw_config* against the model, filling defaults; return a dict.""" | 90 | """Validate *raw_config* against the model, filling defaults; return a dict.""" |
| 73 | config = config_loader.validate_config( | 91 | return config_loader.validate_config( |
| 74 | LineBitmapInferenceConfig, | 92 | LineBitmapInferenceConfig, |
| 75 | raw_config, | 93 | raw_config, |
| 76 | context="inference config", | 94 | context=_CONTEXT, |
| 77 | error_cls=LineBitmapInferenceConfigError, | 95 | error_cls=LineBitmapInferenceConfigError, |
| 78 | ).model_dump() | 96 | ).model_dump() |
| 79 | logger.debug("Normalized inference config") | ||
| 80 | return config | ||
| 81 | 97 | ||
| 82 | 98 | ||
| 83 | def load_line_bitmap_inference_config( | 99 | def load_line_bitmap_inference_config( |
| 84 | config_path: str | Path | None = None, | 100 | config_path: str | Path | None = None, |
| 85 | ) -> dict[str, Any]: | 101 | ) -> dict[str, Any]: |
| 86 | """Load packaged (or *config_path*) defaults and validate them.""" | 102 | """Load the packaged defaults, or *config_path* in their place, and validate.""" |
| 87 | return config_loader.load_config( | 103 | return _load_model(config_path=config_path).model_dump() |
| 88 | LineBitmapInferenceConfig, | ||
| 89 | package=_PACKAGE_NAME, | ||
| 90 | filename=_DEFAULT_FILENAME, | ||
| 91 | config_path=_config_path_for_load(config_path), | ||
| 92 | context="inference config", | ||
| 93 | error_cls=LineBitmapInferenceConfigError, | ||
| 94 | ).model_dump() | ||
| 95 | 104 | ||
| 96 | 105 | ||
| 97 | def build_line_bitmap_inference_config( | 106 | def build_line_bitmap_inference_config( |
| 98 | *, | 107 | *, |
| 99 | overrides: dict[str, Any] | None = None, | 108 | overrides: Mapping[str, Any] | None = None, |
| 100 | config_path: str | Path | None = None, | 109 | config_path: str | Path | None = None, |
| 101 | ) -> dict[str, Any]: | 110 | ) -> dict[str, Any]: |
| 102 | """Load defaults, deep-merge *overrides*, and validate.""" | 111 | """Load defaults (or *config_path* in their place), deep-merge *overrides*.""" |
| 103 | return config_loader.load_config( | 112 | return _load_model(overrides=overrides, config_path=config_path).model_dump() |
| 104 | LineBitmapInferenceConfig, | ||
| 105 | package=_PACKAGE_NAME, | ||
| 106 | filename=_DEFAULT_FILENAME, | ||
| 107 | overrides=overrides, | ||
| 108 | config_path=_config_path_for_load(config_path), | ||
| 109 | context="inference config", | ||
| 110 | error_cls=LineBitmapInferenceConfigError, | ||
| 111 | ).model_dump() | ||
| 112 | |||
| 113 | |||
| 114 | # --------------------------------------------------------------------------- | ||
| 115 | # Model artifact registry | ||
| 116 | # --------------------------------------------------------------------------- | ||
| 117 | # The runtime config above is orthogonal to the *model* itself. A trained model | ||
| 118 | # is frozen into a portable bundle (model.ckpt + model.json) by ``export-model`` | ||
| 119 | # and hosted as a single ``.zip`` at a stable URL (an Azure Blob public/SAS | ||
| 120 | # URL). ``model_fetch.resolve_model`` downloads + caches it on demand, verified | ||
| 121 | # by sha256. URL / sha256 / cache are overridable per-environment without a code | ||
| 122 | # change: | ||
| 123 | # IOLABS_LINE_BITMAP_MODEL_URL | ||
| 124 | # IOLABS_LINE_BITMAP_MODEL_SHA256 | ||
| 125 | # IOLABS_LINE_BITMAP_MODEL_CACHE | ||
| 126 | |||
| 127 | DEFAULT_MODEL_NAME = "line_bitmap_v1" | ||
| 128 | |||
| 129 | # NOTE: placeholder url/sha256. Upload the exported bundle .zip to Azure Blob, | ||
| 130 | # then fill in the real values here (or set the env vars above). Until then, | ||
| 131 | # auto-resolution raises a clear error and callers must pass an explicit | ||
| 132 | # ``--bundle`` / ``--checkpoint``. | ||
| 133 | MODEL_REGISTRY: dict[str, dict[str, str]] = { | ||
| 134 | DEFAULT_MODEL_NAME: { | ||
| 135 | "url": "https://REPLACE_ME.blob.core.windows.net/models/line-bitmap/line_bitmap_v1.zip", | ||
| 136 | "sha256": "", | ||
| 137 | "format": "bundle-zip", | ||
| 138 | }, | ||
| 139 | } | ||
| 140 | |||
| 141 | _DEFAULT_MODEL_CACHE = Path.home() / ".cache" / "iolabs" / "line-bitmap-inference" | ||
| 142 | |||
| 143 | |||
| 144 | def model_cache_dir() -> Path: | ||
| 145 | """Local cache root for downloaded model bundles (env-overridable).""" | ||
| 146 | override = os.environ.get("IOLABS_LINE_BITMAP_MODEL_CACHE") | ||
| 147 | return Path(override).expanduser() if override else _DEFAULT_MODEL_CACHE | ||
| 148 | |||
| 149 | |||
| 150 | def model_registry_entry(model_name: str | None = None) -> dict[str, str]: | ||
| 151 | """Return the registry entry for ``model_name`` (the default model if None). | ||
| 152 | |||
| 153 | ``IOLABS_LINE_BITMAP_MODEL_URL`` / ``IOLABS_LINE_BITMAP_MODEL_SHA256`` (when | ||
| 154 | set) override the registered url / sha256, so a deployment can point at a | ||
| 155 | model artifact without editing code. | ||
| 156 | """ | ||
| 157 | name = model_name or DEFAULT_MODEL_NAME | ||
| 158 | try: | ||
| 159 | entry = dict(MODEL_REGISTRY[name]) | ||
| 160 | except KeyError: | ||
| 161 | known = ", ".join(sorted(MODEL_REGISTRY)) or "<none>" | ||
| 162 | raise LineBitmapInferenceConfigError( | ||
| 163 | f"Unknown model name {name!r}. Known models: {known}" | ||
| 164 | ) from None | ||
| 165 | |||
| 166 | url_override = os.environ.get("IOLABS_LINE_BITMAP_MODEL_URL") | ||
| 167 | sha_override = os.environ.get("IOLABS_LINE_BITMAP_MODEL_SHA256") | ||
| 168 | if url_override: | ||
| 169 | entry["url"] = url_override | ||
| 170 | if sha_override: | ||
| 171 | entry["sha256"] = sha_override | ||
| 172 | return entry |
| 1 | """Inference runtime config: JSON defaults + overrides, unknown keys rejected. | 1 | """Runtime inference config: tiling, device, output toggles, vectorization. |
| 2 | 2 | ||
| 3 | Mirrors ``line_bitmap_inference.default.json`` with a pydantic | 3 | The schema is `LineBitmapInferenceConfig` (a `config_loader.ConfigModel`), |
| 4 | ``config_loader.ConfigModel`` tree. Unknown keys fail fast; ``--set``-style | 4 | mirroring `line_bitmap_inference.default.json` key for key. |
| 5 | string overrides coerce through the fleet accepted-input matrix. This is the | ||
| 6 | inference *runtime* config (tiling, device, output toggles, vectorization | ||
| 7 | params). The *model* spec (architecture/encoder/classes) is a separate artifact | ||
| 8 | read from the training YAML or a deployment bundle (see ``model_spec.py``). | ||
| 9 | 5 | ||
| 10 | To add a config key, add the field to the model and the JSON default; nothing | 6 | Adding a config key means adding the field to the model and the same key to |
| 11 | else. | 7 | `line_bitmap_inference.default.json` — nothing else. Unknown keys are rejected. |
| 8 | |||
| 9 | The entry points return a plain ``dict``. The *model* spec | ||
| 10 | (architecture/encoder/classes) is a separate artifact read from the training | ||
| 11 | YAML or a deployment bundle (see ``model_spec.py``); the model artifact | ||
| 12 | registry lives in ``model_registry.py``. | ||
| 12 | """ | 13 | """ |
| 13 | import logging | 14 | import logging |
| 14 | import os | 15 | from collections.abc import Mapping |
| 15 | from pathlib import Path | 16 | from pathlib import Path |
| 16 | from typing import Any | 17 | from typing import Any |
| 17 | 18 | ||
| 18 | import pydantic | 19 | import pydantic |
| 45 | write_vectors: bool = True | 47 | write_vectors: bool = True |
| 46 | write_probabilities: bool = False | 48 | write_probabilities: bool = False |
| 47 | write_overlay: bool = False | 49 | write_overlay: bool = False |
| 48 | probabilities_dtype: str = "float16" | 50 | probabilities_dtype: str = "float16" |
| 49 | vectorization: VectorizationConfig = VectorizationConfig() | 51 | vectorization: LineBitmapInferenceVectorizationConfig = ( |
| 52 | LineBitmapInferenceVectorizationConfig() | ||
| 53 | ) | ||
| 50 | 54 | ||
| 51 | @pydantic.field_validator("vectorization", mode="before") | 55 | @pydantic.field_validator("vectorization", mode="before") |
| 52 | @classmethod | 56 | @classmethod |
| 53 | def _none_section_is_default(cls, value: Any) -> Any: | 57 | def _none_section_is_default(cls, value: Any) -> Any: |
| 13 | __version__ = _pkg_version("iolabs-image-analyzer-line-bitmap-inference") | 13 | __version__ = _pkg_version("iolabs-image-analyzer-line-bitmap-inference") |
| 14 | except PackageNotFoundError: # running from a source checkout that isn't installed | 14 | except PackageNotFoundError: # running from a source checkout that isn't installed |
| 15 | __version__ = "0.0.0+local" | 15 | __version__ = "0.0.0+local" |
| 16 | 16 | ||
| 17 | from ._config import build_line_bitmap_inference_config | 17 | from ._config import ( |
| 18 | LineBitmapInferenceConfig, | ||
| 19 | LineBitmapInferenceConfigError, | ||
| 20 | build_line_bitmap_inference_config, | ||
| 21 | load_line_bitmap_inference_config, | ||
| 22 | normalize_line_bitmap_inference_config, | ||
| 23 | ) | ||
| 18 | from .class_names import CLASS_NAMES | 24 | from .class_names import CLASS_NAMES |
| 19 | from .model_fetch import resolve_model | 25 | from .model_fetch import resolve_model |
| 20 | from .model_spec import ( | 26 | from .model_spec import ( |
| 21 | ModelSpec, | 27 | ModelSpec, |
| 30 | 36 | ||
| 31 | __all__ = [ | 37 | __all__ = [ |
| 32 | "__version__", | 38 | "__version__", |
| 33 | "CLASS_NAMES", | 39 | "CLASS_NAMES", |
| 40 | "LineBitmapInferenceConfig", | ||
| 41 | "LineBitmapInferenceConfigError", | ||
| 34 | "ModelSpec", | 42 | "ModelSpec", |
| 35 | "FlipTTA", | 43 | "FlipTTA", |
| 36 | "build_line_bitmap_inference_config", | 44 | "build_line_bitmap_inference_config", |
| 45 | "load_line_bitmap_inference_config", | ||
| 37 | "load_model", | 46 | "load_model", |
| 38 | "load_model_spec_from_bundle", | 47 | "load_model_spec_from_bundle", |
| 39 | "load_model_spec_from_yaml", | 48 | "load_model_spec_from_yaml", |
| 49 | "normalize_line_bitmap_inference_config", | ||
| 40 | "predict_tile", | 50 | "predict_tile", |
| 41 | "resolve_model", | 51 | "resolve_model", |
| 42 | "run_directory", | 52 | "run_directory", |
| 43 | "run_tile", | 53 | "run_tile", |
| 4 | by ``export-model``). :func:`resolve_model` returns one of: | 4 | by ``export-model``). :func:`resolve_model` returns one of: |
| 5 | 5 | ||
| 6 | 1. an explicit local path — a bundle dir (or a ``.ckpt``) that exists is returned | 6 | 1. an explicit local path — a bundle dir (or a ``.ckpt``) that exists is returned |
| 7 | unchanged, so an explicit ``--bundle`` / ``--checkpoint`` always wins; | 7 | unchanged, so an explicit ``--bundle`` / ``--checkpoint`` always wins; |
| 8 | 2. a named entry in :data:`._config.MODEL_REGISTRY` — a zipped bundle hosted at a | 8 | 2. a named entry in :data:`.model_registry.MODEL_REGISTRY` — a zipped bundle hosted at a |
| 9 | stable URL (e.g. an Azure Blob public/SAS URL), downloaded once into a | 9 | stable URL (e.g. an Azure Blob public/SAS URL), downloaded once into a |
| 10 | content-addressed cache, verified by sha256, and reused thereafter. | 10 | content-addressed cache, verified by sha256, and reused thereafter. |
| 11 | 11 | ||
| 12 | The download is plain stdlib ``urllib`` + ``hashlib`` — a public/SAS blob URL is | 12 | The download is plain stdlib ``urllib`` + ``hashlib`` — a public/SAS blob URL is |
| 24 | import zipfile | 24 | import zipfile |
| 25 | from pathlib import Path | 25 | from pathlib import Path |
| 26 | from urllib.request import urlopen | 26 | from urllib.request import urlopen |
| 27 | 27 | ||
| 28 | from . import _config | 28 | from . import model_registry |
| 29 | 29 | ||
| 30 | __all__ = ["resolve_model", "ModelResolutionError"] | 30 | __all__ = ["resolve_model", "ModelResolutionError"] |
| 31 | 31 | ||
| 32 | logger = logging.getLogger(__name__) | 32 | logger = logging.getLogger(__name__) |
| 55 | if path.exists(): | 55 | if path.exists(): |
| 56 | return path | 56 | return path |
| 57 | raise ModelResolutionError(f"model path does not exist: {path}") | 57 | raise ModelResolutionError(f"model path does not exist: {path}") |
| 58 | 58 | ||
| 59 | entry = _config.model_registry_entry(model_name) | 59 | entry = model_registry.model_registry_entry(model_name) |
| 60 | url = entry.get("url", "") | 60 | url = entry.get("url", "") |
| 61 | sha256 = (entry.get("sha256") or "").lower() | 61 | sha256 = (entry.get("sha256") or "").lower() |
| 62 | name = model_name or _config.DEFAULT_MODEL_NAME | 62 | name = model_name or model_registry.DEFAULT_MODEL_NAME |
| 63 | 63 | ||
| 64 | _require_configured(name, url, sha256) | 64 | _require_configured(name, url, sha256) |
| 65 | 65 | ||
| 66 | dest = _config.model_cache_dir() / name / sha256[:12] | 66 | dest = model_registry.model_cache_dir() / name / sha256[:12] |
| 67 | if _is_valid_bundle(dest): | 67 | if _is_valid_bundle(dest): |
| 68 | logger.debug("model %s already cached at %s", name, dest) | 68 | logger.debug("model %s already cached at %s", name, dest) |
| 69 | return dest | 69 | return dest |
| 70 | 70 |
| 80 | if missing: | 80 | if missing: |
| 81 | raise ModelResolutionError( | 81 | raise ModelResolutionError( |
| 82 | f"model {name!r} is not configured ({', '.join(missing)} missing). " | 82 | f"model {name!r} is not configured ({', '.join(missing)} missing). " |
| 83 | "Upload the exported bundle .zip and set its url + sha256 in " | 83 | "Upload the exported bundle .zip and set its url + sha256 in " |
| 84 | "_config.MODEL_REGISTRY, or export IOLABS_LINE_BITMAP_MODEL_URL / " | 84 | "model_registry.MODEL_REGISTRY, or export IOLABS_LINE_BITMAP_MODEL_URL / " |
| 85 | "IOLABS_LINE_BITMAP_MODEL_SHA256 — or pass an explicit " | 85 | "IOLABS_LINE_BITMAP_MODEL_SHA256 — or pass an explicit " |
| 86 | "--bundle / --checkpoint." | 86 | "--bundle / --checkpoint." |
| 87 | ) | 87 | ) |
| 88 | 88 |
| 99 | return checkpoint.is_file() | 99 | return checkpoint.is_file() |
| 100 | 100 | ||
| 101 | 101 | ||
| 102 | def _fetch_bundle(*, name: str, url: str, sha256: str, dest: Path) -> Path: | 102 | def _fetch_bundle(*, name: str, url: str, sha256: str, dest: Path) -> Path: |
| 103 | cache_root = _config.model_cache_dir() | 103 | cache_root = model_registry.model_cache_dir() |
| 104 | cache_root.mkdir(parents=True, exist_ok=True) | 104 | cache_root.mkdir(parents=True, exist_ok=True) |
| 105 | logger.info("downloading model %s from %s", name, url) | 105 | logger.info("downloading model %s from %s", name, url) |
| 106 | 106 | ||
| 107 | work = Path(tempfile.mkdtemp(prefix=".fetch-", dir=cache_root)) | 107 | work = Path(tempfile.mkdtemp(prefix=".fetch-", dir=cache_root)) |
| 1 | """Registry of trained model artifacts and their local download cache. | ||
| 2 | |||
| 3 | The runtime config (``_config.py``) is orthogonal to the *model* itself. A | ||
| 4 | trained model is frozen into a portable bundle (model.ckpt + model.json) by | ||
| 5 | ``export-model`` and hosted as a single ``.zip`` at a stable URL (an Azure Blob | ||
| 6 | public/SAS URL). ``model_fetch.resolve_model`` downloads + caches it on demand, | ||
| 7 | verified by sha256. URL / sha256 / cache are overridable per-environment without | ||
| 8 | a code change:: | ||
| 9 | |||
| 10 | IOLABS_LINE_BITMAP_MODEL_URL | ||
| 11 | IOLABS_LINE_BITMAP_MODEL_SHA256 | ||
| 12 | IOLABS_LINE_BITMAP_MODEL_CACHE | ||
| 13 | """ | ||
| 14 | import logging | ||
| 15 | import os | ||
| 16 | from pathlib import Path | ||
| 17 | |||
| 18 | from ._config import LineBitmapInferenceConfigError | ||
| 19 | |||
| 20 | logger = logging.getLogger(__name__) | ||
| 21 | |||
| 22 | DEFAULT_MODEL_NAME = "line_bitmap_v1" | ||
| 23 | |||
| 24 | # NOTE: placeholder url/sha256. Upload the exported bundle .zip to Azure Blob, | ||
| 25 | # then fill in the real values here (or set the env vars above). Until then, | ||
| 26 | # auto-resolution raises a clear error and callers must pass an explicit | ||
| 27 | # ``--bundle`` / ``--checkpoint``. | ||
| 28 | MODEL_REGISTRY: dict[str, dict[str, str]] = { | ||
| 29 | DEFAULT_MODEL_NAME: { | ||
| 30 | "url": "https://REPLACE_ME.blob.core.windows.net/models/line-bitmap/line_bitmap_v1.zip", | ||
| 31 | "sha256": "", | ||
| 32 | "format": "bundle-zip", | ||
| 33 | }, | ||
| 34 | } | ||
| 35 | |||
| 36 | _DEFAULT_MODEL_CACHE = Path.home() / ".cache" / "iolabs" / "line-bitmap-inference" | ||
| 37 | |||
| 38 | |||
| 39 | def model_cache_dir() -> Path: | ||
| 40 | """Local cache root for downloaded model bundles (env-overridable).""" | ||
| 41 | override = os.environ.get("IOLABS_LINE_BITMAP_MODEL_CACHE") | ||
| 42 | return Path(override).expanduser() if override else _DEFAULT_MODEL_CACHE | ||
| 43 | |||
| 44 | |||
| 45 | def model_registry_entry(model_name: str | None = None) -> dict[str, str]: | ||
| 46 | """Return the registry entry for ``model_name`` (the default model if None). | ||
| 47 | |||
| 48 | ``IOLABS_LINE_BITMAP_MODEL_URL`` / ``IOLABS_LINE_BITMAP_MODEL_SHA256`` (when | ||
| 49 | set) override the registered url / sha256, so a deployment can point at a | ||
| 50 | model artifact without editing code. | ||
| 51 | """ | ||
| 52 | name = model_name or DEFAULT_MODEL_NAME | ||
| 53 | try: | ||
| 54 | entry = dict(MODEL_REGISTRY[name]) | ||
| 55 | except KeyError: | ||
| 56 | known = ", ".join(sorted(MODEL_REGISTRY)) or "<none>" | ||
| 57 | raise LineBitmapInferenceConfigError( | ||
| 58 | f"Unknown model name {name!r}. Known models: {known}" | ||
| 59 | ) from None | ||
| 60 | |||
| 61 | url_override = os.environ.get("IOLABS_LINE_BITMAP_MODEL_URL") | ||
| 62 | sha_override = os.environ.get("IOLABS_LINE_BITMAP_MODEL_SHA256") | ||
| 63 | if url_override: | ||
| 64 | entry["url"] = url_override | ||
| 65 | if sha_override: | ||
| 66 | entry["sha256"] = sha_override | ||
| 67 | return entry | ||
| 0 |
| 1 | """Config loading/validation — loaded in isolation (no heavy package imports).""" | 1 | """Config loading/validation for the line bitmap inference runtime config.""" |
| 2 | import importlib.util | 2 | import json |
| 3 | from pathlib import Path | 3 | from importlib import resources |
| 4 | 4 | ||
| 5 | import pytest | 5 | import pytest |
| 6 | from iolabs.common import config_loader | ||
| 7 | from iolabs_image_analyzer_line_bitmap_inference import _config | ||
| 8 | |||
| 9 | |||
| 10 | def _packaged_defaults() -> dict: | ||
| 11 | text = ( | ||
| 12 | resources.files("iolabs_image_analyzer_line_bitmap_inference") | ||
| 13 | .joinpath("line_bitmap_inference.default.json") | ||
| 14 | .read_text(encoding="utf-8") | ||
| 15 | ) | ||
| 16 | return json.loads(text) | ||
| 17 | |||
| 18 | |||
| 19 | def test_model_defaults_match_packaged_json() -> None: | ||
| 20 | assert _config.LineBitmapInferenceConfig().model_dump() == _packaged_defaults() | ||
| 6 | 21 | ||
| 7 | MODULE_PATH = ( | 22 | |
| 8 | Path(__file__).resolve().parents[1] | 23 | def test_load_line_bitmap_inference_config_returns_packaged_defaults() -> None: |
| 9 | / "src" | 24 | assert _config.load_line_bitmap_inference_config() == _packaged_defaults() |
| 10 | / "iolabs_image_analyzer_line_bitmap_inference" | 25 | |
| 11 | / "_config.py" | 26 | |
| 12 | ) | 27 | def test_error_class_is_config_error() -> None: |
| 13 | 28 | assert issubclass(_config.LineBitmapInferenceConfigError, config_loader.ConfigError) | |
| 14 | SPEC = importlib.util.spec_from_file_location("lbi_config", MODULE_PATH) | 29 | assert issubclass(_config.LineBitmapInferenceConfigError, ValueError) |
| 15 | assert SPEC is not None and SPEC.loader is not None | ||
| 16 | MODULE = importlib.util.module_from_spec(SPEC) | ||
| 17 | SPEC.loader.exec_module(MODULE) | ||
| 18 | |||
| 19 | |||
| 20 | def test_load_defaults() -> None: | ||
| 21 | config = MODULE.load_line_bitmap_inference_config() | ||
| 22 | assert config["tile_size"] == 512 | ||
| 23 | assert config["overlap"] == 128 | ||
| 24 | assert config["tta"] is False | ||
| 25 | assert config["write_vectors"] is True | ||
| 26 | assert config["blend"] == "hann" | ||
| 27 | assert config["vectorization"]["min_component_pixels"] == 16 | ||
| 28 | 30 | ||
| 29 | 31 | ||
| 30 | def test_unknown_top_level_key_is_rejected() -> None: | 32 | def test_unknown_top_level_key_is_rejected() -> None: |
| 31 | with pytest.raises(MODULE.LineBitmapInferenceConfigError, match="random_seed"): | 33 | with pytest.raises(_config.LineBitmapInferenceConfigError, match="random_seed"): |
| 32 | MODULE.build_line_bitmap_inference_config(overrides={"random_seed": 1}) | 34 | _config.build_line_bitmap_inference_config(overrides={"random_seed": 1}) |
| 33 | 35 | ||
| 34 | 36 | ||
| 35 | def test_unknown_vectorization_key_is_rejected() -> None: | 37 | def test_unknown_nested_key_is_rejected() -> None: |
| 36 | with pytest.raises(MODULE.LineBitmapInferenceConfigError, match="bogus"): | 38 | with pytest.raises(_config.LineBitmapInferenceConfigError, match="bogus"): |
| 37 | MODULE.build_line_bitmap_inference_config( | 39 | _config.build_line_bitmap_inference_config( |
| 38 | overrides={"vectorization": {"bogus": 1}} | 40 | overrides={"vectorization": {"bogus": 1}} |
| 39 | ) | 41 | ) |
| 40 | 42 | ||
| 41 | 43 | ||
| 42 | def test_overrides_deep_merge_keeps_other_defaults() -> None: | 44 | def test_overrides_deep_merge_onto_defaults() -> None: |
| 43 | config = MODULE.build_line_bitmap_inference_config( | 45 | config = _config.build_line_bitmap_inference_config( |
| 44 | overrides={"tile_size": 768, "vectorization": {"dash_max_gap_px": 10.0}} | 46 | overrides={"tile_size": 768, "vectorization": {"dash_max_gap_px": 10.0}} |
| 45 | ) | 47 | ) |
| 46 | assert config["tile_size"] == 768 | 48 | assert config["tile_size"] == 768 |
| 47 | assert config["overlap"] == 128 # untouched default | 49 | assert config["overlap"] == 128 # untouched default |
| 48 | assert config["vectorization"]["dash_max_gap_px"] == 10.0 | 50 | assert config["vectorization"]["dash_max_gap_px"] == 10.0 |
| 49 | assert config["vectorization"]["min_component_pixels"] == 16 # untouched default | 51 | assert config["vectorization"]["min_component_pixels"] == 16 # untouched default |
| 50 | 52 | ||
| 51 | 53 | ||
| 52 | def test_string_overrides_are_coerced() -> None: | 54 | def test_set_override_coercion_and_rejection() -> None: |
| 53 | config = MODULE.build_line_bitmap_inference_config( | 55 | config = _config.build_line_bitmap_inference_config( |
| 54 | overrides={ | 56 | overrides={ |
| 55 | "tile_size": "1e3", | 57 | "tile_size": "1e3", |
| 56 | "tta": "on", | 58 | "tta": "on", |
| 57 | "vectorization": {"dash_max_gap_px": "5"}, | 59 | "vectorization": {"dash_max_gap_px": "5"}, |
| 59 | ) | 61 | ) |
| 60 | assert config["tile_size"] == 1000 | 62 | assert config["tile_size"] == 1000 |
| 61 | assert config["tta"] is True | 63 | assert config["tta"] is True |
| 62 | assert config["vectorization"]["dash_max_gap_px"] == 5.0 | 64 | assert config["vectorization"]["dash_max_gap_px"] == 5.0 |
| 65 | with pytest.raises(_config.LineBitmapInferenceConfigError, match="tta"): | ||
| 66 | _config.build_line_bitmap_inference_config(overrides={"tta": "flase"}) | ||
| 67 | |||
| 68 | |||
| 69 | def test_normalize_fills_defaults() -> None: | ||
| 70 | config = _config.normalize_line_bitmap_inference_config({"tile_size": 256}) | ||
| 71 | assert config["tile_size"] == 256 | ||
| 72 | assert config == {**_packaged_defaults(), "tile_size": 256} | ||
| 63 | 73 | ||
| 64 | 74 | ||
| 65 | def test_bool_is_rejected_for_int_field() -> None: | 75 | def test_bool_is_rejected_for_int_field() -> None: |
| 66 | with pytest.raises(MODULE.LineBitmapInferenceConfigError, match="tile_size"): | 76 | with pytest.raises(_config.LineBitmapInferenceConfigError, match="tile_size"): |
| 67 | MODULE.build_line_bitmap_inference_config(overrides={"tile_size": True}) | 77 | _config.build_line_bitmap_inference_config(overrides={"tile_size": True}) |
| 68 | 78 | ||
| 69 | 79 | ||
| 70 | def test_null_vectorization_section_falls_back_to_defaults() -> None: | 80 | def test_null_vectorization_section_falls_back_to_defaults() -> None: |
| 71 | config = MODULE.build_line_bitmap_inference_config(overrides={"vectorization": None}) | 81 | config = _config.build_line_bitmap_inference_config( |
| 82 | overrides={"vectorization": None} | ||
| 83 | ) | ||
| 72 | assert config["vectorization"]["min_component_pixels"] == 16 | 84 | assert config["vectorization"]["min_component_pixels"] == 16 |
| 73 | assert config["vectorization"]["dash_max_gap_px"] == 40.0 | 85 | assert config["vectorization"]["dash_max_gap_px"] == 40.0 |
| 58 | 58 | ||
| 59 | ### Model registry / fetch-on-demand | 59 | ### Model registry / fetch-on-demand |
| 60 | 60 | ||
| 61 | When no explicit model is passed, the CLI (and `resolve_model()`) resolve the | 61 | When no explicit model is passed, the CLI (and `resolve_model()`) resolve the |
| 62 | default model from `_config.MODEL_REGISTRY`: a zipped bundle hosted at a stable | 62 | default model from `model_registry.MODEL_REGISTRY`: a zipped bundle hosted at a stable |
| 63 | URL (e.g. an Azure Blob public/SAS URL) is downloaded once into a | 63 | URL (e.g. an Azure Blob public/SAS URL) is downloaded once into a |
| 64 | content-addressed cache (`~/.cache/iolabs/line-bitmap-inference/`), verified by | 64 | content-addressed cache (`~/.cache/iolabs/line-bitmap-inference/`), verified by |
| 65 | sha256, and reused thereafter. The fetch is plain stdlib `urllib` — no | 65 | sha256, and reused thereafter. The fetch is plain stdlib `urllib` — no |
| 66 | `azure-sdk`/`dvc` dependency; a blob URL is just a GET. Override per environment | 66 | `azure-sdk`/`dvc` dependency; a blob URL is just a GET. Override per environment |
| 96 | 96 | ||
| 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). See [Configuration](#configuration) for the schema. |
| 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). | ||
| 104 | 101 | ||
| 105 | - `--tile-size` / `--overlap` — sliding-window size and overlap (back-stepped | 102 | - `--tile-size` / `--overlap` — sliding-window size and overlap (back-stepped |
| 106 | origins, Hann-blended). | 103 | origins, Hann-blended). |
| 107 | - `--batch-size`, `--device` (`auto|cpu|cuda`), `--precision`. | 104 | - `--batch-size`, `--device` (`auto|cpu|cuda`), `--precision`. |
| 126 | 123 | ||
| 127 | `scripts/pipeline/run_infer.py` is a path-insert wrapper around the same CLI for | 124 | `scripts/pipeline/run_infer.py` is a path-insert wrapper around the same CLI for |
| 128 | running without installing the package. | 125 | running without installing the package. |
| 129 | 126 | ||
| 127 | ## Configuration | ||
| 128 | |||
| 129 | Defaults live in | ||
| 130 | `src/iolabs_image_analyzer_line_bitmap_inference/line_bitmap_inference.default.json`. | ||
| 131 | The schema is `LineBitmapInferenceConfig` in `_config.py` (a | ||
| 132 | `config_loader.ConfigModel`); nested JSON sections are nested models and unknown | ||
| 133 | keys are rejected. **To add a config key: add the field (with its type, default | ||
| 134 | and any `Field` range) to the model and the same key with the same default to | ||
| 135 | the JSON — nothing else.** `load_line_bitmap_inference_config`, | ||
| 136 | `build_line_bitmap_inference_config` and | ||
| 137 | `normalize_line_bitmap_inference_config` return a plain `dict`. Runtime | ||
| 138 | overrides come from repeatable `--set KEY=VALUE`, never repo-local JSON. | ||
| 139 | |||
| 130 | ## Logging | 140 | ## Logging |
| 131 | 141 | ||
| 132 | Wired to `iolabs-logstash`. The CLI calls `configure_job_logging` + | 142 | Wired to `iolabs-logstash`. The CLI calls `configure_job_logging` + |
| 133 | `install_context_filter` at entry; library modules log structured `props` | 143 | `install_context_filter` at entry; library modules log structured `props` |
| 143 | tta.py # shared FlipTTA (SSOT) | 153 | tta.py # shared FlipTTA (SSOT) |
| 144 | vectorize.py overlay.py # mask -> vectors / QA | 154 | vectorize.py overlay.py # mask -> vectors / QA |
| 145 | model_spec.py bundle.py # model load + export | 155 | model_spec.py bundle.py # model load + export |
| 146 | io.py runner.py cli.py # outputs, batch, CLI | 156 | io.py runner.py cli.py # outputs, batch, CLI |
| 147 | _config.py _log_props.py line_bitmap_inference.default.json # config + logging | 157 | _config.py model_registry.py line_bitmap_inference.default.json # config + models |
| 158 | _log_props.py # logging | ||
| 148 | ``` | 159 | ``` |
| 149 | 160 | ||
| 150 | ## Scope | 161 | ## Scope |
| 151 | 162 |
| 13 | __version__ = _pkg_version("iolabs-image-analyzer-line-bitmap-inference") | 13 | __version__ = _pkg_version("iolabs-image-analyzer-line-bitmap-inference") |
| 14 | except PackageNotFoundError: # running from a source checkout that isn't installed | 14 | except PackageNotFoundError: # running from a source checkout that isn't installed |
| 15 | __version__ = "0.0.0+local" | 15 | __version__ = "0.0.0+local" |
| 16 | 16 | ||
| 17 | from ._config import build_line_bitmap_inference_config | 17 | from ._config import ( |
| 18 | LineBitmapInferenceConfig, | ||
| 19 | LineBitmapInferenceConfigError, | ||
| 20 | build_line_bitmap_inference_config, | ||
| 21 | load_line_bitmap_inference_config, | ||
| 22 | normalize_line_bitmap_inference_config, | ||
| 23 | ) | ||
| 18 | from .class_names import CLASS_NAMES | 24 | from .class_names import CLASS_NAMES |
| 19 | from .model_fetch import resolve_model | 25 | from .model_fetch import resolve_model |
| 20 | from .model_spec import ( | 26 | from .model_spec import ( |
| 21 | ModelSpec, | 27 | ModelSpec, |
| 30 | 36 | ||
| 31 | __all__ = [ | 37 | __all__ = [ |
| 32 | "__version__", | 38 | "__version__", |
| 33 | "CLASS_NAMES", | 39 | "CLASS_NAMES", |
| 40 | "LineBitmapInferenceConfig", | ||
| 41 | "LineBitmapInferenceConfigError", | ||
| 34 | "ModelSpec", | 42 | "ModelSpec", |
| 35 | "FlipTTA", | 43 | "FlipTTA", |
| 36 | "build_line_bitmap_inference_config", | 44 | "build_line_bitmap_inference_config", |
| 45 | "load_line_bitmap_inference_config", | ||
| 37 | "load_model", | 46 | "load_model", |
| 38 | "load_model_spec_from_bundle", | 47 | "load_model_spec_from_bundle", |
| 39 | "load_model_spec_from_yaml", | 48 | "load_model_spec_from_yaml", |
| 49 | "normalize_line_bitmap_inference_config", | ||
| 40 | "predict_tile", | 50 | "predict_tile", |
| 41 | "resolve_model", | 51 | "resolve_model", |
| 42 | "run_directory", | 52 | "run_directory", |
| 43 | "run_tile", | 53 | "run_tile", |
| 1 | """Inference runtime config: JSON defaults + overrides, unknown keys rejected. | 1 | """Runtime inference config: tiling, device, output toggles, vectorization. |
| 2 | 2 | ||
| 3 | Mirrors ``line_bitmap_inference.default.json`` with a pydantic | 3 | The schema is `LineBitmapInferenceConfig` (a `config_loader.ConfigModel`), |
| 4 | ``config_loader.ConfigModel`` tree. Unknown keys fail fast; ``--set``-style | 4 | mirroring `line_bitmap_inference.default.json` key for key. |
| 5 | string overrides coerce through the fleet accepted-input matrix. This is the | ||
| 6 | inference *runtime* config (tiling, device, output toggles, vectorization | ||
| 7 | params). The *model* spec (architecture/encoder/classes) is a separate artifact | ||
| 8 | read from the training YAML or a deployment bundle (see ``model_spec.py``). | ||
| 9 | 5 | ||
| 10 | To add a config key, add the field to the model and the JSON default; nothing | 6 | Adding a config key means adding the field to the model and the same key to |
| 11 | else. | 7 | `line_bitmap_inference.default.json` — nothing else. Unknown keys are rejected. |
| 8 | |||
| 9 | The entry points return a plain ``dict``. The *model* spec | ||
| 10 | (architecture/encoder/classes) is a separate artifact read from the training | ||
| 11 | YAML or a deployment bundle (see ``model_spec.py``); the model artifact | ||
| 12 | registry lives in ``model_registry.py``. | ||
| 12 | """ | 13 | """ |
| 13 | import logging | 14 | import logging |
| 14 | import os | 15 | from collections.abc import Mapping |
| 15 | from pathlib import Path | 16 | from pathlib import Path |
| 16 | from typing import Any | 17 | from typing import Any |
| 17 | 18 | ||
| 18 | import pydantic | 19 | import pydantic |
| 21 | logger = logging.getLogger(__name__) | 22 | logger = logging.getLogger(__name__) |
| 22 | 23 | ||
| 23 | _PACKAGE_NAME = "iolabs_image_analyzer_line_bitmap_inference" | 24 | _PACKAGE_NAME = "iolabs_image_analyzer_line_bitmap_inference" |
| 24 | _DEFAULT_FILENAME = "line_bitmap_inference.default.json" | 25 | _DEFAULT_FILENAME = "line_bitmap_inference.default.json" |
| 26 | _CONTEXT = "line bitmap inference config" | ||
| 25 | 27 | ||
| 26 | 28 | ||
| 27 | class VectorizationConfig(config_loader.ConfigModel): | 29 | class LineBitmapInferenceVectorizationConfig(config_loader.ConfigModel): |
| 28 | """Polyline extraction parameters applied to the predicted mask.""" | 30 | """Polyline extraction parameters applied to the predicted mask.""" |
| 29 | 31 | ||
| 30 | min_component_pixels: int = 16 | 32 | min_component_pixels: int = 16 |
| 31 | simplify_tolerance_px: float = 2.0 | 33 | simplify_tolerance_px: float = 2.0 |
| 45 | write_vectors: bool = True | 47 | write_vectors: bool = True |
| 46 | write_probabilities: bool = False | 48 | write_probabilities: bool = False |
| 47 | write_overlay: bool = False | 49 | write_overlay: bool = False |
| 48 | probabilities_dtype: str = "float16" | 50 | probabilities_dtype: str = "float16" |
| 49 | vectorization: VectorizationConfig = VectorizationConfig() | 51 | vectorization: LineBitmapInferenceVectorizationConfig = ( |
| 52 | LineBitmapInferenceVectorizationConfig() | ||
| 53 | ) | ||
| 50 | 54 | ||
| 51 | @pydantic.field_validator("vectorization", mode="before") | 55 | @pydantic.field_validator("vectorization", mode="before") |
| 52 | @classmethod | 56 | @classmethod |
| 53 | def _none_section_is_default(cls, value: Any) -> Any: | 57 | def _none_section_is_default(cls, value: Any) -> Any: |
| 55 | return {} if value is None else value | 59 | return {} if value is None else value |
| 56 | 60 | ||
| 57 | 61 | ||
| 58 | class LineBitmapInferenceConfigError(config_loader.ConfigError): | 62 | class LineBitmapInferenceConfigError(config_loader.ConfigError): |
| 59 | """Raised when the inference config contains unsupported keys or values.""" | 63 | """Raised when line bitmap inference config contains unsupported keys or values.""" |
| 60 | 64 | ||
| 61 | 65 | ||
| 62 | def _config_path_for_load(config_path: str | Path | None) -> str | Path | None: | 66 | def _load_model( |
| 63 | """Return *config_path*, or the sibling JSON when this module is loaded loose.""" | 67 | *, |
| 68 | overrides: Mapping[str, Any] | None = None, | ||
| 69 | config_path: str | Path | None = None, | ||
| 70 | ) -> LineBitmapInferenceConfig: | ||
| 71 | """Load packaged (or *config_path*) defaults, merge *overrides*, validate.""" | ||
| 72 | if overrides: | ||
| 73 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | ||
| 64 | if config_path is not None: | 74 | if config_path is not None: |
| 65 | return config_path | 75 | logger.info("Config file applied: %s", config_path) |
| 66 | if __package__ in {None, ""}: | 76 | return config_loader.load_config( |
| 67 | return Path(__file__).resolve().with_name(_DEFAULT_FILENAME) | 77 | LineBitmapInferenceConfig, |
| 68 | return None | 78 | package=_PACKAGE_NAME, |
| 79 | filename=_DEFAULT_FILENAME, | ||
| 80 | overrides=overrides, | ||
| 81 | config_path=config_path, | ||
| 82 | context=_CONTEXT, | ||
| 83 | error_cls=LineBitmapInferenceConfigError, | ||
| 84 | ) | ||
| 69 | 85 | ||
| 70 | 86 | ||
| 71 | def normalize_line_bitmap_inference_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 87 | def normalize_line_bitmap_inference_config( |
| 88 | raw_config: Mapping[str, Any], | ||
| 89 | ) -> dict[str, Any]: | ||
| 72 | """Validate *raw_config* against the model, filling defaults; return a dict.""" | 90 | """Validate *raw_config* against the model, filling defaults; return a dict.""" |
| 73 | config = config_loader.validate_config( | 91 | return config_loader.validate_config( |
| 74 | LineBitmapInferenceConfig, | 92 | LineBitmapInferenceConfig, |
| 75 | raw_config, | 93 | raw_config, |
| 76 | context="inference config", | 94 | context=_CONTEXT, |
| 77 | error_cls=LineBitmapInferenceConfigError, | 95 | error_cls=LineBitmapInferenceConfigError, |
| 78 | ).model_dump() | 96 | ).model_dump() |
| 79 | logger.debug("Normalized inference config") | ||
| 80 | return config | ||
| 81 | 97 | ||
| 82 | 98 | ||
| 83 | def load_line_bitmap_inference_config( | 99 | def load_line_bitmap_inference_config( |
| 84 | config_path: str | Path | None = None, | 100 | config_path: str | Path | None = None, |
| 85 | ) -> dict[str, Any]: | 101 | ) -> dict[str, Any]: |
| 86 | """Load packaged (or *config_path*) defaults and validate them.""" | 102 | """Load the packaged defaults, or *config_path* in their place, and validate.""" |
| 87 | return config_loader.load_config( | 103 | return _load_model(config_path=config_path).model_dump() |
| 88 | LineBitmapInferenceConfig, | ||
| 89 | package=_PACKAGE_NAME, | ||
| 90 | filename=_DEFAULT_FILENAME, | ||
| 91 | config_path=_config_path_for_load(config_path), | ||
| 92 | context="inference config", | ||
| 93 | error_cls=LineBitmapInferenceConfigError, | ||
| 94 | ).model_dump() | ||
| 95 | 104 | ||
| 96 | 105 | ||
| 97 | def build_line_bitmap_inference_config( | 106 | def build_line_bitmap_inference_config( |
| 98 | *, | 107 | *, |
| 99 | overrides: dict[str, Any] | None = None, | 108 | overrides: Mapping[str, Any] | None = None, |
| 100 | config_path: str | Path | None = None, | 109 | config_path: str | Path | None = None, |
| 101 | ) -> dict[str, Any]: | 110 | ) -> dict[str, Any]: |
| 102 | """Load defaults, deep-merge *overrides*, and validate.""" | 111 | """Load defaults (or *config_path* in their place), deep-merge *overrides*.""" |
| 103 | return config_loader.load_config( | 112 | return _load_model(overrides=overrides, config_path=config_path).model_dump() |
| 104 | LineBitmapInferenceConfig, | ||
| 105 | package=_PACKAGE_NAME, | ||
| 106 | filename=_DEFAULT_FILENAME, | ||
| 107 | overrides=overrides, | ||
| 108 | config_path=_config_path_for_load(config_path), | ||
| 109 | context="inference config", | ||
| 110 | error_cls=LineBitmapInferenceConfigError, | ||
| 111 | ).model_dump() | ||
| 112 | |||
| 113 | |||
| 114 | # --------------------------------------------------------------------------- | ||
| 115 | # Model artifact registry | ||
| 116 | # --------------------------------------------------------------------------- | ||
| 117 | # The runtime config above is orthogonal to the *model* itself. A trained model | ||
| 118 | # is frozen into a portable bundle (model.ckpt + model.json) by ``export-model`` | ||
| 119 | # and hosted as a single ``.zip`` at a stable URL (an Azure Blob public/SAS | ||
| 120 | # URL). ``model_fetch.resolve_model`` downloads + caches it on demand, verified | ||
| 121 | # by sha256. URL / sha256 / cache are overridable per-environment without a code | ||
| 122 | # change: | ||
| 123 | # IOLABS_LINE_BITMAP_MODEL_URL | ||
| 124 | # IOLABS_LINE_BITMAP_MODEL_SHA256 | ||
| 125 | # IOLABS_LINE_BITMAP_MODEL_CACHE | ||
| 126 | |||
| 127 | DEFAULT_MODEL_NAME = "line_bitmap_v1" | ||
| 128 | |||
| 129 | # NOTE: placeholder url/sha256. Upload the exported bundle .zip to Azure Blob, | ||
| 130 | # then fill in the real values here (or set the env vars above). Until then, | ||
| 131 | # auto-resolution raises a clear error and callers must pass an explicit | ||
| 132 | # ``--bundle`` / ``--checkpoint``. | ||
| 133 | MODEL_REGISTRY: dict[str, dict[str, str]] = { | ||
| 134 | DEFAULT_MODEL_NAME: { | ||
| 135 | "url": "https://REPLACE_ME.blob.core.windows.net/models/line-bitmap/line_bitmap_v1.zip", | ||
| 136 | "sha256": "", | ||
| 137 | "format": "bundle-zip", | ||
| 138 | }, | ||
| 139 | } | ||
| 140 | |||
| 141 | _DEFAULT_MODEL_CACHE = Path.home() / ".cache" / "iolabs" / "line-bitmap-inference" | ||
| 142 | |||
| 143 | |||
| 144 | def model_cache_dir() -> Path: | ||
| 145 | """Local cache root for downloaded model bundles (env-overridable).""" | ||
| 146 | override = os.environ.get("IOLABS_LINE_BITMAP_MODEL_CACHE") | ||
| 147 | return Path(override).expanduser() if override else _DEFAULT_MODEL_CACHE | ||
| 148 | |||
| 149 | |||
| 150 | def model_registry_entry(model_name: str | None = None) -> dict[str, str]: | ||
| 151 | """Return the registry entry for ``model_name`` (the default model if None). | ||
| 152 | |||
| 153 | ``IOLABS_LINE_BITMAP_MODEL_URL`` / ``IOLABS_LINE_BITMAP_MODEL_SHA256`` (when | ||
| 154 | set) override the registered url / sha256, so a deployment can point at a | ||
| 155 | model artifact without editing code. | ||
| 156 | """ | ||
| 157 | name = model_name or DEFAULT_MODEL_NAME | ||
| 158 | try: | ||
| 159 | entry = dict(MODEL_REGISTRY[name]) | ||
| 160 | except KeyError: | ||
| 161 | known = ", ".join(sorted(MODEL_REGISTRY)) or "<none>" | ||
| 162 | raise LineBitmapInferenceConfigError( | ||
| 163 | f"Unknown model name {name!r}. Known models: {known}" | ||
| 164 | ) from None | ||
| 165 | |||
| 166 | url_override = os.environ.get("IOLABS_LINE_BITMAP_MODEL_URL") | ||
| 167 | sha_override = os.environ.get("IOLABS_LINE_BITMAP_MODEL_SHA256") | ||
| 168 | if url_override: | ||
| 169 | entry["url"] = url_override | ||
| 170 | if sha_override: | ||
| 171 | entry["sha256"] = sha_override | ||
| 172 | return entry |
| 4 | by ``export-model``). :func:`resolve_model` returns one of: | 4 | by ``export-model``). :func:`resolve_model` returns one of: |
| 5 | 5 | ||
| 6 | 1. an explicit local path — a bundle dir (or a ``.ckpt``) that exists is returned | 6 | 1. an explicit local path — a bundle dir (or a ``.ckpt``) that exists is returned |
| 7 | unchanged, so an explicit ``--bundle`` / ``--checkpoint`` always wins; | 7 | unchanged, so an explicit ``--bundle`` / ``--checkpoint`` always wins; |
| 8 | 2. a named entry in :data:`._config.MODEL_REGISTRY` — a zipped bundle hosted at a | 8 | 2. a named entry in :data:`.model_registry.MODEL_REGISTRY` — a zipped bundle hosted at a |
| 9 | stable URL (e.g. an Azure Blob public/SAS URL), downloaded once into a | 9 | stable URL (e.g. an Azure Blob public/SAS URL), downloaded once into a |
| 10 | content-addressed cache, verified by sha256, and reused thereafter. | 10 | content-addressed cache, verified by sha256, and reused thereafter. |
| 11 | 11 | ||
| 12 | The download is plain stdlib ``urllib`` + ``hashlib`` — a public/SAS blob URL is | 12 | The download is plain stdlib ``urllib`` + ``hashlib`` — a public/SAS blob URL is |
| 24 | import zipfile | 24 | import zipfile |
| 25 | from pathlib import Path | 25 | from pathlib import Path |
| 26 | from urllib.request import urlopen | 26 | from urllib.request import urlopen |
| 27 | 27 | ||
| 28 | from . import _config | 28 | from . import model_registry |
| 29 | 29 | ||
| 30 | __all__ = ["resolve_model", "ModelResolutionError"] | 30 | __all__ = ["resolve_model", "ModelResolutionError"] |
| 31 | 31 | ||
| 32 | logger = logging.getLogger(__name__) | 32 | logger = logging.getLogger(__name__) |
| 55 | if path.exists(): | 55 | if path.exists(): |
| 56 | return path | 56 | return path |
| 57 | raise ModelResolutionError(f"model path does not exist: {path}") | 57 | raise ModelResolutionError(f"model path does not exist: {path}") |
| 58 | 58 | ||
| 59 | entry = _config.model_registry_entry(model_name) | 59 | entry = model_registry.model_registry_entry(model_name) |
| 60 | url = entry.get("url", "") | 60 | url = entry.get("url", "") |
| 61 | sha256 = (entry.get("sha256") or "").lower() | 61 | sha256 = (entry.get("sha256") or "").lower() |
| 62 | name = model_name or _config.DEFAULT_MODEL_NAME | 62 | name = model_name or model_registry.DEFAULT_MODEL_NAME |
| 63 | 63 | ||
| 64 | _require_configured(name, url, sha256) | 64 | _require_configured(name, url, sha256) |
| 65 | 65 | ||
| 66 | dest = _config.model_cache_dir() / name / sha256[:12] | 66 | dest = model_registry.model_cache_dir() / name / sha256[:12] |
| 67 | if _is_valid_bundle(dest): | 67 | if _is_valid_bundle(dest): |
| 68 | logger.debug("model %s already cached at %s", name, dest) | 68 | logger.debug("model %s already cached at %s", name, dest) |
| 69 | return dest | 69 | return dest |
| 70 | 70 |
| 80 | if missing: | 80 | if missing: |
| 81 | raise ModelResolutionError( | 81 | raise ModelResolutionError( |
| 82 | f"model {name!r} is not configured ({', '.join(missing)} missing). " | 82 | f"model {name!r} is not configured ({', '.join(missing)} missing). " |
| 83 | "Upload the exported bundle .zip and set its url + sha256 in " | 83 | "Upload the exported bundle .zip and set its url + sha256 in " |
| 84 | "_config.MODEL_REGISTRY, or export IOLABS_LINE_BITMAP_MODEL_URL / " | 84 | "model_registry.MODEL_REGISTRY, or export IOLABS_LINE_BITMAP_MODEL_URL / " |
| 85 | "IOLABS_LINE_BITMAP_MODEL_SHA256 — or pass an explicit " | 85 | "IOLABS_LINE_BITMAP_MODEL_SHA256 — or pass an explicit " |
| 86 | "--bundle / --checkpoint." | 86 | "--bundle / --checkpoint." |
| 87 | ) | 87 | ) |
| 88 | 88 |
| 99 | return checkpoint.is_file() | 99 | return checkpoint.is_file() |
| 100 | 100 | ||
| 101 | 101 | ||
| 102 | def _fetch_bundle(*, name: str, url: str, sha256: str, dest: Path) -> Path: | 102 | def _fetch_bundle(*, name: str, url: str, sha256: str, dest: Path) -> Path: |
| 103 | cache_root = _config.model_cache_dir() | 103 | cache_root = model_registry.model_cache_dir() |
| 104 | cache_root.mkdir(parents=True, exist_ok=True) | 104 | cache_root.mkdir(parents=True, exist_ok=True) |
| 105 | logger.info("downloading model %s from %s", name, url) | 105 | logger.info("downloading model %s from %s", name, url) |
| 106 | 106 | ||
| 107 | work = Path(tempfile.mkdtemp(prefix=".fetch-", dir=cache_root)) | 107 | work = Path(tempfile.mkdtemp(prefix=".fetch-", dir=cache_root)) |
| 1 | """Registry of trained model artifacts and their local download cache. | ||
| 2 | |||
| 3 | The runtime config (``_config.py``) is orthogonal to the *model* itself. A | ||
| 4 | trained model is frozen into a portable bundle (model.ckpt + model.json) by | ||
| 5 | ``export-model`` and hosted as a single ``.zip`` at a stable URL (an Azure Blob | ||
| 6 | public/SAS URL). ``model_fetch.resolve_model`` downloads + caches it on demand, | ||
| 7 | verified by sha256. URL / sha256 / cache are overridable per-environment without | ||
| 8 | a code change:: | ||
| 9 | |||
| 10 | IOLABS_LINE_BITMAP_MODEL_URL | ||
| 11 | IOLABS_LINE_BITMAP_MODEL_SHA256 | ||
| 12 | IOLABS_LINE_BITMAP_MODEL_CACHE | ||
| 13 | """ | ||
| 14 | import logging | ||
| 15 | import os | ||
| 16 | from pathlib import Path | ||
| 17 | |||
| 18 | from ._config import LineBitmapInferenceConfigError | ||
| 19 | |||
| 20 | logger = logging.getLogger(__name__) | ||
| 21 | |||
| 22 | DEFAULT_MODEL_NAME = "line_bitmap_v1" | ||
| 23 | |||
| 24 | # NOTE: placeholder url/sha256. Upload the exported bundle .zip to Azure Blob, | ||
| 25 | # then fill in the real values here (or set the env vars above). Until then, | ||
| 26 | # auto-resolution raises a clear error and callers must pass an explicit | ||
| 27 | # ``--bundle`` / ``--checkpoint``. | ||
| 28 | MODEL_REGISTRY: dict[str, dict[str, str]] = { | ||
| 29 | DEFAULT_MODEL_NAME: { | ||
| 30 | "url": "https://REPLACE_ME.blob.core.windows.net/models/line-bitmap/line_bitmap_v1.zip", | ||
| 31 | "sha256": "", | ||
| 32 | "format": "bundle-zip", | ||
| 33 | }, | ||
| 34 | } | ||
| 35 | |||
| 36 | _DEFAULT_MODEL_CACHE = Path.home() / ".cache" / "iolabs" / "line-bitmap-inference" | ||
| 37 | |||
| 38 | |||
| 39 | def model_cache_dir() -> Path: | ||
| 40 | """Local cache root for downloaded model bundles (env-overridable).""" | ||
| 41 | override = os.environ.get("IOLABS_LINE_BITMAP_MODEL_CACHE") | ||
| 42 | return Path(override).expanduser() if override else _DEFAULT_MODEL_CACHE | ||
| 43 | |||
| 44 | |||
| 45 | def model_registry_entry(model_name: str | None = None) -> dict[str, str]: | ||
| 46 | """Return the registry entry for ``model_name`` (the default model if None). | ||
| 47 | |||
| 48 | ``IOLABS_LINE_BITMAP_MODEL_URL`` / ``IOLABS_LINE_BITMAP_MODEL_SHA256`` (when | ||
| 49 | set) override the registered url / sha256, so a deployment can point at a | ||
| 50 | model artifact without editing code. | ||
| 51 | """ | ||
| 52 | name = model_name or DEFAULT_MODEL_NAME | ||
| 53 | try: | ||
| 54 | entry = dict(MODEL_REGISTRY[name]) | ||
| 55 | except KeyError: | ||
| 56 | known = ", ".join(sorted(MODEL_REGISTRY)) or "<none>" | ||
| 57 | raise LineBitmapInferenceConfigError( | ||
| 58 | f"Unknown model name {name!r}. Known models: {known}" | ||
| 59 | ) from None | ||
| 60 | |||
| 61 | url_override = os.environ.get("IOLABS_LINE_BITMAP_MODEL_URL") | ||
| 62 | sha_override = os.environ.get("IOLABS_LINE_BITMAP_MODEL_SHA256") | ||
| 63 | if url_override: | ||
| 64 | entry["url"] = url_override | ||
| 65 | if sha_override: | ||
| 66 | entry["sha256"] = sha_override | ||
| 67 | return entry | ||
| 0 |
| 1 | """Config loading/validation — loaded in isolation (no heavy package imports).""" | 1 | """Config loading/validation for the line bitmap inference runtime config.""" |
| 2 | import importlib.util | 2 | import json |
| 3 | from pathlib import Path | 3 | from importlib import resources |
| 4 | 4 | ||
| 5 | import pytest | 5 | import pytest |
| 6 | from iolabs.common import config_loader | ||
| 7 | from iolabs_image_analyzer_line_bitmap_inference import _config | ||
| 8 | |||
| 9 | |||
| 10 | def _packaged_defaults() -> dict: | ||
| 11 | text = ( | ||
| 12 | resources.files("iolabs_image_analyzer_line_bitmap_inference") | ||
| 13 | .joinpath("line_bitmap_inference.default.json") | ||
| 14 | .read_text(encoding="utf-8") | ||
| 15 | ) | ||
| 16 | return json.loads(text) | ||
| 17 | |||
| 18 | |||
| 19 | def test_model_defaults_match_packaged_json() -> None: | ||
| 20 | assert _config.LineBitmapInferenceConfig().model_dump() == _packaged_defaults() | ||
| 6 | 21 | ||
| 7 | MODULE_PATH = ( | 22 | |
| 8 | Path(__file__).resolve().parents[1] | 23 | def test_load_line_bitmap_inference_config_returns_packaged_defaults() -> None: |
| 9 | / "src" | 24 | assert _config.load_line_bitmap_inference_config() == _packaged_defaults() |
| 10 | / "iolabs_image_analyzer_line_bitmap_inference" | 25 | |
| 11 | / "_config.py" | 26 | |
| 12 | ) | 27 | def test_error_class_is_config_error() -> None: |
| 13 | 28 | assert issubclass(_config.LineBitmapInferenceConfigError, config_loader.ConfigError) | |
| 14 | SPEC = importlib.util.spec_from_file_location("lbi_config", MODULE_PATH) | 29 | assert issubclass(_config.LineBitmapInferenceConfigError, ValueError) |
| 15 | assert SPEC is not None and SPEC.loader is not None | ||
| 16 | MODULE = importlib.util.module_from_spec(SPEC) | ||
| 17 | SPEC.loader.exec_module(MODULE) | ||
| 18 | |||
| 19 | |||
| 20 | def test_load_defaults() -> None: | ||
| 21 | config = MODULE.load_line_bitmap_inference_config() | ||
| 22 | assert config["tile_size"] == 512 | ||
| 23 | assert config["overlap"] == 128 | ||
| 24 | assert config["tta"] is False | ||
| 25 | assert config["write_vectors"] is True | ||
| 26 | assert config["blend"] == "hann" | ||
| 27 | assert config["vectorization"]["min_component_pixels"] == 16 | ||
| 28 | 30 | ||
| 29 | 31 | ||
| 30 | def test_unknown_top_level_key_is_rejected() -> None: | 32 | def test_unknown_top_level_key_is_rejected() -> None: |
| 31 | with pytest.raises(MODULE.LineBitmapInferenceConfigError, match="random_seed"): | 33 | with pytest.raises(_config.LineBitmapInferenceConfigError, match="random_seed"): |
| 32 | MODULE.build_line_bitmap_inference_config(overrides={"random_seed": 1}) | 34 | _config.build_line_bitmap_inference_config(overrides={"random_seed": 1}) |
| 33 | 35 | ||
| 34 | 36 | ||
| 35 | def test_unknown_vectorization_key_is_rejected() -> None: | 37 | def test_unknown_nested_key_is_rejected() -> None: |
| 36 | with pytest.raises(MODULE.LineBitmapInferenceConfigError, match="bogus"): | 38 | with pytest.raises(_config.LineBitmapInferenceConfigError, match="bogus"): |
| 37 | MODULE.build_line_bitmap_inference_config( | 39 | _config.build_line_bitmap_inference_config( |
| 38 | overrides={"vectorization": {"bogus": 1}} | 40 | overrides={"vectorization": {"bogus": 1}} |
| 39 | ) | 41 | ) |
| 40 | 42 | ||
| 41 | 43 | ||
| 42 | def test_overrides_deep_merge_keeps_other_defaults() -> None: | 44 | def test_overrides_deep_merge_onto_defaults() -> None: |
| 43 | config = MODULE.build_line_bitmap_inference_config( | 45 | config = _config.build_line_bitmap_inference_config( |
| 44 | overrides={"tile_size": 768, "vectorization": {"dash_max_gap_px": 10.0}} | 46 | overrides={"tile_size": 768, "vectorization": {"dash_max_gap_px": 10.0}} |
| 45 | ) | 47 | ) |
| 46 | assert config["tile_size"] == 768 | 48 | assert config["tile_size"] == 768 |
| 47 | assert config["overlap"] == 128 # untouched default | 49 | assert config["overlap"] == 128 # untouched default |
| 48 | assert config["vectorization"]["dash_max_gap_px"] == 10.0 | 50 | assert config["vectorization"]["dash_max_gap_px"] == 10.0 |
| 49 | assert config["vectorization"]["min_component_pixels"] == 16 # untouched default | 51 | assert config["vectorization"]["min_component_pixels"] == 16 # untouched default |
| 50 | 52 | ||
| 51 | 53 | ||
| 52 | def test_string_overrides_are_coerced() -> None: | 54 | def test_set_override_coercion_and_rejection() -> None: |
| 53 | config = MODULE.build_line_bitmap_inference_config( | 55 | config = _config.build_line_bitmap_inference_config( |
| 54 | overrides={ | 56 | overrides={ |
| 55 | "tile_size": "1e3", | 57 | "tile_size": "1e3", |
| 56 | "tta": "on", | 58 | "tta": "on", |
| 57 | "vectorization": {"dash_max_gap_px": "5"}, | 59 | "vectorization": {"dash_max_gap_px": "5"}, |
| 59 | ) | 61 | ) |
| 60 | assert config["tile_size"] == 1000 | 62 | assert config["tile_size"] == 1000 |
| 61 | assert config["tta"] is True | 63 | assert config["tta"] is True |
| 62 | assert config["vectorization"]["dash_max_gap_px"] == 5.0 | 64 | assert config["vectorization"]["dash_max_gap_px"] == 5.0 |
| 65 | with pytest.raises(_config.LineBitmapInferenceConfigError, match="tta"): | ||
| 66 | _config.build_line_bitmap_inference_config(overrides={"tta": "flase"}) | ||
| 67 | |||
| 68 | |||
| 69 | def test_normalize_fills_defaults() -> None: | ||
| 70 | config = _config.normalize_line_bitmap_inference_config({"tile_size": 256}) | ||
| 71 | assert config["tile_size"] == 256 | ||
| 72 | assert config == {**_packaged_defaults(), "tile_size": 256} | ||
| 63 | 73 | ||
| 64 | 74 | ||
| 65 | def test_bool_is_rejected_for_int_field() -> None: | 75 | def test_bool_is_rejected_for_int_field() -> None: |
| 66 | with pytest.raises(MODULE.LineBitmapInferenceConfigError, match="tile_size"): | 76 | with pytest.raises(_config.LineBitmapInferenceConfigError, match="tile_size"): |
| 67 | MODULE.build_line_bitmap_inference_config(overrides={"tile_size": True}) | 77 | _config.build_line_bitmap_inference_config(overrides={"tile_size": True}) |
| 68 | 78 | ||
| 69 | 79 | ||
| 70 | def test_null_vectorization_section_falls_back_to_defaults() -> None: | 80 | def test_null_vectorization_section_falls_back_to_defaults() -> None: |
| 71 | config = MODULE.build_line_bitmap_inference_config(overrides={"vectorization": None}) | 81 | config = _config.build_line_bitmap_inference_config( |
| 82 | overrides={"vectorization": None} | ||
| 83 | ) | ||
| 72 | assert config["vectorization"]["min_component_pixels"] == 16 | 84 | assert config["vectorization"]["min_component_pixels"] == 16 |
| 73 | assert config["vectorization"]["dash_max_gap_px"] == 40.0 | 85 | assert config["vectorization"]["dash_max_gap_px"] == 40.0 |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.