143 lines
5.9 KiB
Python
143 lines
5.9 KiB
Python
"""Checksum-bound geographic validation scope for production model inference."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from hashlib import sha256
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
from typing import Any
|
|
|
|
from shapely.geometry import shape
|
|
from shapely.geometry.base import BaseGeometry
|
|
|
|
from app.core.errors import AppError
|
|
|
|
|
|
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
|
|
|
|
class ModelValidationScopeService:
|
|
"""Load an immutable, model-bound AOI and prove that an input is covered."""
|
|
|
|
SCHEMA_VERSION = "geointel.model-validation-scope/v1"
|
|
|
|
@classmethod
|
|
def assert_area_covered(
|
|
cls,
|
|
*,
|
|
area_geometry: BaseGeometry,
|
|
manifest_path: str | None,
|
|
expected_manifest_sha256: str | None,
|
|
model_id: str,
|
|
model_path: str | None,
|
|
) -> dict[str, str]:
|
|
path = Path(manifest_path).expanduser() if manifest_path else None
|
|
expected_checksum = (expected_manifest_sha256 or "").strip().lower()
|
|
if path is None or not expected_checksum:
|
|
cls._raise(
|
|
"DETECTION_VALIDATION_SCOPE_NOT_CONFIGURED",
|
|
"Configured YOLO inference requires a checksum-bound geographic validation-scope manifest.",
|
|
)
|
|
if not _SHA256.fullmatch(expected_checksum):
|
|
cls._raise(
|
|
"DETECTION_VALIDATION_SCOPE_INVALID",
|
|
"The configured validation-scope checksum must be a lowercase SHA-256 digest.",
|
|
manifest_path=str(path),
|
|
)
|
|
try:
|
|
raw_manifest = path.read_bytes()
|
|
payload = json.loads(raw_manifest.decode("utf-8"))
|
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
cls._raise(
|
|
"DETECTION_VALIDATION_SCOPE_INVALID",
|
|
"The configured validation-scope manifest is missing or unreadable.",
|
|
manifest_path=str(path),
|
|
error_type=type(exc).__name__,
|
|
)
|
|
observed_manifest_sha256 = sha256(raw_manifest).hexdigest()
|
|
if observed_manifest_sha256 != expected_checksum:
|
|
cls._raise(
|
|
"DETECTION_VALIDATION_SCOPE_CHECKSUM_MISMATCH",
|
|
"The validation-scope manifest does not match its configured checksum.",
|
|
manifest_path=str(path),
|
|
expected=expected_checksum,
|
|
observed=observed_manifest_sha256,
|
|
)
|
|
if not isinstance(payload, dict) or payload.get("schema_version") != cls.SCHEMA_VERSION:
|
|
cls._raise(
|
|
"DETECTION_VALIDATION_SCOPE_INVALID",
|
|
"The validation-scope manifest has an unsupported schema.",
|
|
manifest_path=str(path),
|
|
)
|
|
if payload.get("model_id") != model_id:
|
|
cls._raise(
|
|
"DETECTION_VALIDATION_SCOPE_MODEL_MISMATCH",
|
|
"The validation scope is not bound to the selected model identity.",
|
|
expected_model_id=model_id,
|
|
observed_model_id=payload.get("model_id"),
|
|
)
|
|
configured_model_path = Path(model_path).expanduser() if model_path else None
|
|
if configured_model_path is None or not configured_model_path.is_file():
|
|
cls._raise(
|
|
"DETECTION_VALIDATION_SCOPE_MODEL_UNAVAILABLE",
|
|
"The model bytes bound by the validation scope are unavailable.",
|
|
)
|
|
declared_model_sha256 = str(payload.get("model_sha256") or "").strip().lower()
|
|
observed_model_sha256 = cls._file_sha256(configured_model_path)
|
|
if not _SHA256.fullmatch(declared_model_sha256) or declared_model_sha256 != observed_model_sha256:
|
|
cls._raise(
|
|
"DETECTION_VALIDATION_SCOPE_MODEL_MISMATCH",
|
|
"The validation scope is not bound to the exact selected model bytes.",
|
|
expected=declared_model_sha256 or None,
|
|
observed=observed_model_sha256,
|
|
)
|
|
if payload.get("crs") != "EPSG:4326":
|
|
cls._raise(
|
|
"DETECTION_VALIDATION_SCOPE_INVALID",
|
|
"The validation-scope geometry must explicitly use EPSG:4326.",
|
|
observed_crs=payload.get("crs"),
|
|
)
|
|
try:
|
|
scope_geometry = shape(payload.get("geometry"))
|
|
except Exception as exc:
|
|
cls._raise(
|
|
"DETECTION_VALIDATION_SCOPE_INVALID",
|
|
"The validation-scope geometry is not valid GeoJSON.",
|
|
error_type=type(exc).__name__,
|
|
)
|
|
if (
|
|
scope_geometry.is_empty
|
|
or not scope_geometry.is_valid
|
|
or scope_geometry.geom_type not in {"Polygon", "MultiPolygon"}
|
|
):
|
|
cls._raise(
|
|
"DETECTION_VALIDATION_SCOPE_INVALID",
|
|
"The validation scope must be a non-empty valid Polygon or MultiPolygon.",
|
|
geometry_type=scope_geometry.geom_type,
|
|
)
|
|
if area_geometry.is_empty or not area_geometry.is_valid or not scope_geometry.covers(area_geometry):
|
|
cls._raise(
|
|
"DETECTION_VALIDATION_SCOPE_UNAVAILABLE",
|
|
"Configured YOLO inference is not validated for the complete Dataset area.",
|
|
scope_key=payload.get("scope_key"),
|
|
)
|
|
return {
|
|
"scope_key": str(payload.get("scope_key") or "unspecified"),
|
|
"manifest_path": str(path.resolve()),
|
|
"manifest_sha256": observed_manifest_sha256,
|
|
"model_sha256": observed_model_sha256,
|
|
}
|
|
|
|
@staticmethod
|
|
def _file_sha256(path: Path) -> str:
|
|
digest = sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
@staticmethod
|
|
def _raise(code: str, message: str, **details: Any) -> None:
|
|
raise AppError(code=code, message=message, details=details, status_code=422)
|