fix(ai): bind model scope to immutable geometry
This commit is contained in:
@@ -118,6 +118,11 @@ YOLO_MODEL_DISPLAY_NAME=Configured YOLO detector
|
|||||||
YOLO_MODEL_VERSION=
|
YOLO_MODEL_VERSION=
|
||||||
YOLO_MODEL_CLASSES=building
|
YOLO_MODEL_CLASSES=building
|
||||||
YOLO_ENFORCE_VALIDATION_SCOPE=false
|
YOLO_ENFORCE_VALIDATION_SCOPE=false
|
||||||
|
# Required when scope enforcement is enabled. The manifest is bound to exact
|
||||||
|
# model bytes and contains the allowed EPSG:4326 validation geometry.
|
||||||
|
YOLO_VALIDATION_SCOPE_MANIFEST_PATH=
|
||||||
|
YOLO_VALIDATION_SCOPE_MANIFEST_SHA256=
|
||||||
|
# Deprecated display metadata; never used as an inference authorization gate.
|
||||||
YOLO_VALIDATED_AREA_NAMES=Mol,Kempen
|
YOLO_VALIDATED_AREA_NAMES=Mol,Kempen
|
||||||
YOLO_CONFIG_DIR=./storage/ultralytics
|
YOLO_CONFIG_DIR=./storage/ultralytics
|
||||||
YOLO_DEVICE=cpu
|
YOLO_DEVICE=cpu
|
||||||
|
|||||||
@@ -359,6 +359,17 @@ class Settings(BaseSettings):
|
|||||||
yolo_model_version: str | None = Field(default=None, validation_alias="YOLO_MODEL_VERSION")
|
yolo_model_version: str | None = Field(default=None, validation_alias="YOLO_MODEL_VERSION")
|
||||||
yolo_model_classes: str = Field(default="building", validation_alias="YOLO_MODEL_CLASSES")
|
yolo_model_classes: str = Field(default="building", validation_alias="YOLO_MODEL_CLASSES")
|
||||||
yolo_enforce_validation_scope: bool = Field(default=False, validation_alias="YOLO_ENFORCE_VALIDATION_SCOPE")
|
yolo_enforce_validation_scope: bool = Field(default=False, validation_alias="YOLO_ENFORCE_VALIDATION_SCOPE")
|
||||||
|
yolo_validation_scope_manifest_path: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_PATH",
|
||||||
|
)
|
||||||
|
yolo_validation_scope_manifest_sha256: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_SHA256",
|
||||||
|
)
|
||||||
|
# Deprecated compatibility field. Mutable Area names are never an
|
||||||
|
# inference authorization boundary; deployments must use the immutable
|
||||||
|
# checksum-bound scope manifest above.
|
||||||
yolo_validated_area_names: str = Field(default="Mol,Kempen", validation_alias="YOLO_VALIDATED_AREA_NAMES")
|
yolo_validated_area_names: str = Field(default="Mol,Kempen", validation_alias="YOLO_VALIDATED_AREA_NAMES")
|
||||||
yolo_device: str = Field(default="cpu", validation_alias="YOLO_DEVICE")
|
yolo_device: str = Field(default="cpu", validation_alias="YOLO_DEVICE")
|
||||||
yolo_require_cuda: bool = Field(default=False, validation_alias="YOLO_REQUIRE_CUDA")
|
yolo_require_cuda: bool = Field(default=False, validation_alias="YOLO_REQUIRE_CUDA")
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from app.services.detection_qa_service import DetectionQaService
|
|||||||
from app.services.dataset_consumption_gate_service import DatasetConsumptionGate
|
from app.services.dataset_consumption_gate_service import DatasetConsumptionGate
|
||||||
from app.services.model_asset_catalog_service import ModelAssetCatalogService
|
from app.services.model_asset_catalog_service import ModelAssetCatalogService
|
||||||
from app.services.model_registry_service import ModelRegistryService
|
from app.services.model_registry_service import ModelRegistryService
|
||||||
|
from app.services.model_validation_scope_service import ModelValidationScopeService
|
||||||
from app.services.qa_service import QaService
|
from app.services.qa_service import QaService
|
||||||
from app.services.quality_service import QualityService
|
from app.services.quality_service import QualityService
|
||||||
from app.services.runtime_model_provenance_service import RuntimeModelProvenance, RuntimeModelProvenanceService
|
from app.services.runtime_model_provenance_service import RuntimeModelProvenance, RuntimeModelProvenanceService
|
||||||
@@ -235,16 +236,30 @@ class DetectionService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate_model_area_scope(db, dataset: Dataset, settings: Settings) -> None:
|
def _validate_model_area_scope(db, dataset: Dataset, settings: Settings) -> None:
|
||||||
allowed_names = [value.strip().casefold() for value in settings.yolo_validated_area_names.split(",") if value.strip()]
|
|
||||||
area = db.get(Area, dataset.area_id) if dataset.area_id else None
|
area = db.get(Area, dataset.area_id) if dataset.area_id else None
|
||||||
area_name = area.name.strip() if area is not None else ""
|
if area is None or area.geometry is None:
|
||||||
if not area_name or not any(token in area_name.casefold() for token in allowed_names):
|
|
||||||
raise AppError(
|
raise AppError(
|
||||||
code="DETECTION_VALIDATION_SCOPE_UNAVAILABLE",
|
code="DETECTION_VALIDATION_SCOPE_UNAVAILABLE",
|
||||||
message="Configured YOLO inference is not validated for this Dataset area.",
|
message="Configured YOLO inference requires a persisted Dataset area geometry.",
|
||||||
details={"dataset_id": str(dataset.id), "dataset_area": area_name or None, "validated_area_names": allowed_names},
|
details={"dataset_id": str(dataset.id)},
|
||||||
status_code=422,
|
status_code=422,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
area_geometry = to_shape(area.geometry)
|
||||||
|
except Exception as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="DETECTION_VALIDATION_SCOPE_UNAVAILABLE",
|
||||||
|
message="The persisted Dataset area geometry cannot be validated for model inference.",
|
||||||
|
details={"dataset_id": str(dataset.id), "error_type": type(exc).__name__},
|
||||||
|
status_code=422,
|
||||||
|
) from exc
|
||||||
|
ModelValidationScopeService.assert_area_covered(
|
||||||
|
area_geometry=area_geometry,
|
||||||
|
manifest_path=settings.yolo_validation_scope_manifest_path,
|
||||||
|
expected_manifest_sha256=settings.yolo_validation_scope_manifest_sha256,
|
||||||
|
model_id=settings.yolo_model_id,
|
||||||
|
model_path=settings.yolo_model_path,
|
||||||
|
)
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fail_run_after_exception(db, analysis_run: AnalysisRun, job: Job, exc: Exception, fallback_code: str) -> None:
|
def _fail_run_after_exception(db, analysis_run: AnalysisRun, job: Job, exc: Exception, fallback_code: str) -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""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)
|
||||||
@@ -25,9 +25,9 @@ def test_detection_lab_explains_explicit_model_asset_and_threshold_selection() -
|
|||||||
|
|
||||||
assert "Lokaal modelbestand" in lab
|
assert "Lokaal modelbestand" in lab
|
||||||
assert "GeoIntel kiest automatisch het actieve lokale model" in lab
|
assert "GeoIntel kiest automatisch het actieve lokale model" in lab
|
||||||
assert "Gevalideerde YOLO-profielen" in lab
|
assert "Historische YOLO-controleprofielen" in lab
|
||||||
assert "DETECTION_OPERATOR_PROFILES" in lab
|
assert "DETECTION_OPERATOR_PROFILES" in lab
|
||||||
assert "kandidaat, extra controle vereist" in lab
|
assert "historisch, geen releasebewijs" in lab
|
||||||
assert "will_download_models" in lab
|
assert "will_download_models" in lab
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
|||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
def test_detection_operator_profiles_define_explicit_yolo_candidates_and_promoted_profile() -> None:
|
def test_detection_operator_profiles_define_explicit_historical_yolo_controls_without_promotion_claim() -> None:
|
||||||
profiles = ROOT / "frontend" / "src" / "components" / "detection" / "detectionProfiles.ts"
|
profiles = ROOT / "frontend" / "src" / "components" / "detection" / "detectionProfiles.ts"
|
||||||
source = profiles.read_text(encoding="utf-8")
|
source = profiles.read_text(encoding="utf-8")
|
||||||
|
|
||||||
@@ -17,16 +17,17 @@ def test_detection_operator_profiles_define_explicit_yolo_candidates_and_promote
|
|||||||
assert "conservative-review" in source
|
assert "conservative-review" in source
|
||||||
assert "confidenceThreshold: 0.15" in source
|
assert "confidenceThreshold: 0.15" in source
|
||||||
assert "confidenceThreshold: 0.35" in source
|
assert "confidenceThreshold: 0.35" in source
|
||||||
assert "defaultApproved: true" in source
|
assert "defaultApproved" not in source
|
||||||
assert "promotionRecommendation: 'promote_candidate'" in source
|
assert "promotionRecommendation" not in source
|
||||||
|
assert "independentTestProven: false" in source
|
||||||
assert "positiveSampleCount: 7" in source
|
assert "positiveSampleCount: 7" in source
|
||||||
assert "precision: 0.6140895327792112" in source
|
assert "precision: 0.6140895327792112" in source
|
||||||
assert "recall: 0.6062221049337548" in source
|
assert "recall: 0.6062221049337548" in source
|
||||||
assert "f1: 0.6068607646002744" in source
|
assert "f1: 0.6068607646002744" in source
|
||||||
assert "f1: 0.5432865390636915" in source
|
assert "f1: 0.5432865390636915" in source
|
||||||
assert "maxBackgroundDetections: 0" in source
|
assert "maxBackgroundDetections: 0" in source
|
||||||
assert "lege-achtergrondtest is geslaagd" in source
|
assert "Slechts drie pure-achtergrondbeelden" in source
|
||||||
assert "Postel blijft met 47,5% F1" in source
|
assert "ruimtelijke onafhankelijkheid niet bewezen" in source
|
||||||
assert "controlekandidaat en niet als grondwaarheid" in source
|
assert "controlekandidaat en niet als grondwaarheid" in source
|
||||||
|
|
||||||
|
|
||||||
@@ -39,11 +40,11 @@ def test_detection_lab_surfaces_profiles_as_deliberate_operator_actions() -> Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "DETECTION_OPERATOR_PROFILES" in lab
|
assert "DETECTION_OPERATOR_PROFILES" in lab
|
||||||
assert "Gevalideerde YOLO-profielen" in lab
|
assert "Historische YOLO-controleprofielen" in lab
|
||||||
assert "profile.displayName" in lab
|
assert "profile.displayName" in lab
|
||||||
assert "profile.confidenceThreshold" in lab
|
assert "profile.confidenceThreshold" in lab
|
||||||
assert "kandidaat, extra controle vereist" in lab
|
assert "historisch, geen releasebewijs" in lab
|
||||||
assert "standaardprofiel" in lab
|
assert "historische F1" in lab
|
||||||
assert "Profiel gebruiken" in lab
|
assert "Profiel gebruiken" in lab
|
||||||
assert "onApplyOperatorProfile(profile)" in lab
|
assert "onApplyOperatorProfile(profile)" in lab
|
||||||
assert "Recommended starting threshold: 0.25" not in lab
|
assert "Recommended starting threshold: 0.25" not in lab
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ def test_visible_ai_and_quality_labels_are_end_user_facing() -> None:
|
|||||||
providers = read("frontend/src/components/providers/ProviderPanel.tsx")
|
providers = read("frontend/src/components/providers/ProviderPanel.tsx")
|
||||||
|
|
||||||
assert "Aanbevolen controleprofiel kleine gebouwen" in profiles
|
assert "Aanbevolen controleprofiel kleine gebouwen" in profiles
|
||||||
assert "Postel blijft met 47,5% F1" in profiles
|
assert "Slechts drie pure-achtergrondbeelden" in profiles
|
||||||
assert "controlekandidaat en niet als grondwaarheid" in profiles
|
assert "controlekandidaat en niet als grondwaarheid" in profiles
|
||||||
assert "qualityStatusLabel" in quality
|
assert "qualityStatusLabel" in quality
|
||||||
assert "nog niet uitgevoerd" in quality
|
assert "nog niet uitgevoerd" in quality
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from types import SimpleNamespace
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from geoalchemy2.shape import from_shape
|
||||||
|
from shapely.geometry import box, mapping
|
||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.core.errors import AppError
|
from app.core.errors import AppError
|
||||||
@@ -15,6 +17,7 @@ from app.models import AnalysisRun, Area, Dataset, Detection, Job, Project, Sour
|
|||||||
from app.services.detection_georeferencing import pixel_bbox_to_epsg4326_polygon
|
from app.services.detection_georeferencing import pixel_bbox_to_epsg4326_polygon
|
||||||
from app.services.detection_service import DetectionService
|
from app.services.detection_service import DetectionService
|
||||||
from app.services.model_registry_service import ModelRegistryService
|
from app.services.model_registry_service import ModelRegistryService
|
||||||
|
from app.services.model_validation_scope_service import ModelValidationScopeService
|
||||||
from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService
|
from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService
|
||||||
from app.services.yolo_adapter import YoloDetectionAdapter
|
from app.services.yolo_adapter import YoloDetectionAdapter
|
||||||
|
|
||||||
@@ -207,6 +210,28 @@ def _settings(tmp_path: Path, **overrides) -> Settings:
|
|||||||
return Settings(**values)
|
return Settings(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_settings(tmp_path: Path, scope_geometry=None, **overrides) -> Settings:
|
||||||
|
model_path = tmp_path / "model.pt"
|
||||||
|
model_path.write_bytes(b"scope-bound-model")
|
||||||
|
payload = {
|
||||||
|
"schema_version": ModelValidationScopeService.SCHEMA_VERSION,
|
||||||
|
"model_id": "yolo-configured",
|
||||||
|
"model_sha256": sha256(model_path.read_bytes()).hexdigest(),
|
||||||
|
"scope_key": "mol-kempen-test",
|
||||||
|
"crs": "EPSG:4326",
|
||||||
|
"geometry": mapping(scope_geometry or box(4.0, 50.8, 5.5, 52.0)),
|
||||||
|
}
|
||||||
|
manifest_path = tmp_path / "model-validation-scope.json"
|
||||||
|
manifest_path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
|
||||||
|
values = {
|
||||||
|
"yolo_model_path": str(model_path),
|
||||||
|
"yolo_validation_scope_manifest_path": str(manifest_path),
|
||||||
|
"yolo_validation_scope_manifest_sha256": sha256(manifest_path.read_bytes()).hexdigest(),
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return _settings(tmp_path, **values)
|
||||||
|
|
||||||
|
|
||||||
def _write_model_sidecar(
|
def _write_model_sidecar(
|
||||||
model_path: Path,
|
model_path: Path,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
@@ -399,21 +424,49 @@ def test_yolo_runtime_rejects_cpu_device_when_cuda_is_required(tmp_path: Path, m
|
|||||||
|
|
||||||
def test_yolo_validation_scope_requires_persisted_validated_area(tmp_path: Path) -> None:
|
def test_yolo_validation_scope_requires_persisted_validated_area(tmp_path: Path) -> None:
|
||||||
dataset = Dataset(id=uuid4(), project_id=uuid4(), name="image.tif", dataset_type="raster", source="test", area_id=uuid4())
|
dataset = Dataset(id=uuid4(), project_id=uuid4(), name="image.tif", dataset_type="raster", source="test", area_id=uuid4())
|
||||||
wrong_area = Area(id=dataset.area_id, project_id=dataset.project_id, name="Brussels", geometry="MULTIPOLYGON EMPTY")
|
wrong_area = Area(
|
||||||
|
id=dataset.area_id,
|
||||||
|
project_id=dataset.project_id,
|
||||||
|
name="Mol validation bypass",
|
||||||
|
geometry=from_shape(box(-74.1, 40.6, -73.8, 40.9), srid=4326),
|
||||||
|
)
|
||||||
db = FakeSession(objects={(Area, dataset.area_id): wrong_area})
|
db = FakeSession(objects={(Area, dataset.area_id): wrong_area})
|
||||||
|
|
||||||
with pytest.raises(AppError) as exc_info:
|
with pytest.raises(AppError) as exc_info:
|
||||||
DetectionService._validate_model_area_scope(db, dataset, _settings(tmp_path, yolo_validated_area_names="Mol,Kempen"))
|
DetectionService._validate_model_area_scope(db, dataset, _scope_settings(tmp_path))
|
||||||
|
|
||||||
assert exc_info.value.code == "DETECTION_VALIDATION_SCOPE_UNAVAILABLE"
|
assert exc_info.value.code == "DETECTION_VALIDATION_SCOPE_UNAVAILABLE"
|
||||||
|
|
||||||
|
|
||||||
def test_yolo_validation_scope_accepts_bound_mol_area(tmp_path: Path) -> None:
|
def test_yolo_validation_scope_accepts_bound_mol_area(tmp_path: Path) -> None:
|
||||||
dataset = Dataset(id=uuid4(), project_id=uuid4(), name="image.tif", dataset_type="raster", source="test", area_id=uuid4())
|
dataset = Dataset(id=uuid4(), project_id=uuid4(), name="image.tif", dataset_type="raster", source="test", area_id=uuid4())
|
||||||
area = Area(id=dataset.area_id, project_id=dataset.project_id, name="Gemeente Mol", geometry="MULTIPOLYGON EMPTY")
|
area = Area(
|
||||||
|
id=dataset.area_id,
|
||||||
|
project_id=dataset.project_id,
|
||||||
|
name="Een wijzigbare weergavenaam",
|
||||||
|
geometry=from_shape(box(5.0, 51.1, 5.2, 51.3), srid=4326),
|
||||||
|
)
|
||||||
db = FakeSession(objects={(Area, dataset.area_id): area})
|
db = FakeSession(objects={(Area, dataset.area_id): area})
|
||||||
|
|
||||||
DetectionService._validate_model_area_scope(db, dataset, _settings(tmp_path, yolo_validated_area_names="Mol,Kempen"))
|
DetectionService._validate_model_area_scope(db, dataset, _scope_settings(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
def test_yolo_validation_scope_rejects_tampered_manifest(tmp_path: Path) -> None:
|
||||||
|
dataset = Dataset(id=uuid4(), project_id=uuid4(), name="image.tif", dataset_type="raster", source="test", area_id=uuid4())
|
||||||
|
area = Area(
|
||||||
|
id=dataset.area_id,
|
||||||
|
project_id=dataset.project_id,
|
||||||
|
name="Gemeente Mol",
|
||||||
|
geometry=from_shape(box(5.0, 51.1, 5.2, 51.3), srid=4326),
|
||||||
|
)
|
||||||
|
settings = _scope_settings(tmp_path)
|
||||||
|
Path(settings.yolo_validation_scope_manifest_path).write_text("{}", encoding="utf-8")
|
||||||
|
db = FakeSession(objects={(Area, dataset.area_id): area})
|
||||||
|
|
||||||
|
with pytest.raises(AppError) as exc_info:
|
||||||
|
DetectionService._validate_model_area_scope(db, dataset, settings)
|
||||||
|
|
||||||
|
assert exc_info.value.code == "DETECTION_VALIDATION_SCOPE_CHECKSUM_MISMATCH"
|
||||||
|
|
||||||
|
|
||||||
def test_yolo_run_requires_tile_manifest_path(tmp_path: Path) -> None:
|
def test_yolo_run_requires_tile_manifest_path(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ COPY scripts/provision_buildings_addresses_register.py /app/scripts/provision_bu
|
|||||||
COPY scripts/provision_regional_timeseries.py /app/scripts/provision_regional_timeseries.py
|
COPY scripts/provision_regional_timeseries.py /app/scripts/provision_regional_timeseries.py
|
||||||
COPY scripts/geographic_scopes.py /app/scripts/geographic_scopes.py
|
COPY scripts/geographic_scopes.py /app/scripts/geographic_scopes.py
|
||||||
COPY scripts/provision_geographic_scope.py /app/scripts/provision_geographic_scope.py
|
COPY scripts/provision_geographic_scope.py /app/scripts/provision_geographic_scope.py
|
||||||
|
COPY scripts/build_model_validation_scope_manifest.py /app/scripts/build_model_validation_scope_manifest.py
|
||||||
COPY scripts/provision_belgium_north_sea_scope.py /app/scripts/provision_belgium_north_sea_scope.py
|
COPY scripts/provision_belgium_north_sea_scope.py /app/scripts/provision_belgium_north_sea_scope.py
|
||||||
COPY scripts/provision_release_golden_areas.py /app/scripts/provision_release_golden_areas.py
|
COPY scripts/provision_release_golden_areas.py /app/scripts/provision_release_golden_areas.py
|
||||||
COPY scripts/provision_regional_grb_buildings.py /app/scripts/provision_regional_grb_buildings.py
|
COPY scripts/provision_regional_grb_buildings.py /app/scripts/provision_regional_grb_buildings.py
|
||||||
|
|||||||
@@ -137,8 +137,10 @@
|
|||||||
<Config Name="YOLO Device" Target="YOLO_DEVICE" Default="cuda:0" Mode="" Description="Required NVIDIA CUDA inference device." Type="Variable" Display="advanced" Required="true" Mask="false">cuda:0</Config>
|
<Config Name="YOLO Device" Target="YOLO_DEVICE" Default="cuda:0" Mode="" Description="Required NVIDIA CUDA inference device." Type="Variable" Display="advanced" Required="true" Mask="false">cuda:0</Config>
|
||||||
<Config Name="Require CUDA" Target="YOLO_REQUIRE_CUDA" Default="true" Mode="" Description="Fail closed instead of silently falling back to CPU when NVIDIA CUDA is unavailable." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
|
<Config Name="Require CUDA" Target="YOLO_REQUIRE_CUDA" Default="true" Mode="" Description="Fail closed instead of silently falling back to CPU when NVIDIA CUDA is unavailable." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
|
||||||
<Config Name="YOLO Classes" Target="YOLO_MODEL_CLASSES" Default="building" Mode="" Description="Comma-separated classes proven for the active model; the current promoted model is building-only." Type="Variable" Display="advanced" Required="true" Mask="false">building</Config>
|
<Config Name="YOLO Classes" Target="YOLO_MODEL_CLASSES" Default="building" Mode="" Description="Comma-separated classes proven for the active model; the current promoted model is building-only." Type="Variable" Display="advanced" Required="true" Mask="false">building</Config>
|
||||||
<Config Name="Enforce YOLO Scope" Target="YOLO_ENFORCE_VALIDATION_SCOPE" Default="true" Mode="" Description="Reject inference outside persisted validated Areas." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
|
<Config Name="Enforce YOLO Scope" Target="YOLO_ENFORCE_VALIDATION_SCOPE" Default="true" Mode="" Description="Reject inference outside the checksum-bound model validation geometry." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
|
||||||
<Config Name="Validated YOLO Areas" Target="YOLO_VALIDATED_AREA_NAMES" Default="Mol,Kempen" Mode="" Description="Persisted Area name tokens with promotion evidence for the active model." Type="Variable" Display="advanced" Required="true" Mask="false">Mol,Kempen</Config>
|
<Config Name="YOLO Scope Manifest" Target="YOLO_VALIDATION_SCOPE_MANIFEST_PATH" Default="/app/storage/operator-data/model-validation-scopes/active-building-model.json" Mode="" Description="Immutable model-bound EPSG:4326 validation-scope manifest." Type="Variable" Display="advanced" Required="true" Mask="false">/app/storage/operator-data/model-validation-scopes/active-building-model.json</Config>
|
||||||
|
<Config Name="YOLO Scope Manifest SHA256" Target="YOLO_VALIDATION_SCOPE_MANIFEST_SHA256" Default="" Mode="" Description="Exact lowercase SHA-256 of the validation-scope manifest." Type="Variable" Display="advanced" Required="true" Mask="false"></Config>
|
||||||
|
<Config Name="Validated YOLO Areas (Display Only)" Target="YOLO_VALIDATED_AREA_NAMES" Default="Mol,Kempen" Mode="" Description="Deprecated display metadata; never authorizes inference." Type="Variable" Display="advanced" Required="false" Mask="false">Mol,Kempen</Config>
|
||||||
<Config Name="YOLO Image Size" Target="YOLO_IMAGE_SIZE" Default="640" Mode="" Description="Inference image size in pixels." Type="Variable" Display="advanced" Required="true" Mask="false">640</Config>
|
<Config Name="YOLO Image Size" Target="YOLO_IMAGE_SIZE" Default="640" Mode="" Description="Inference image size in pixels." Type="Variable" Display="advanced" Required="true" Mask="false">640</Config>
|
||||||
<Config Name="YOLO Maximum Tiles" Target="YOLO_MAX_TILES" Default="100" Mode="" Description="Hard tile limit per detection run." Type="Variable" Display="advanced" Required="true" Mask="false">100</Config>
|
<Config Name="YOLO Maximum Tiles" Target="YOLO_MAX_TILES" Default="100" Mode="" Description="Hard tile limit per detection run." Type="Variable" Display="advanced" Required="true" Mask="false">100</Config>
|
||||||
<Config Name="YOLO Maximum Detections" Target="YOLO_MAX_DETECTIONS" Default="1000" Mode="" Description="Hard persisted detection limit per run." Type="Variable" Display="advanced" Required="true" Mask="false">1000</Config>
|
<Config Name="YOLO Maximum Detections" Target="YOLO_MAX_DETECTIONS" Default="1000" Mode="" Description="Hard persisted detection limit per run." Type="Variable" Display="advanced" Required="true" Mask="false">1000</Config>
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ YOLO_DEVICE=cuda:0
|
|||||||
YOLO_REQUIRE_CUDA=true
|
YOLO_REQUIRE_CUDA=true
|
||||||
YOLO_MODEL_CLASSES=building
|
YOLO_MODEL_CLASSES=building
|
||||||
YOLO_ENFORCE_VALIDATION_SCOPE=true
|
YOLO_ENFORCE_VALIDATION_SCOPE=true
|
||||||
|
YOLO_VALIDATION_SCOPE_MANIFEST_PATH=/app/storage/operator-data/model-validation-scopes/active-building-model.json
|
||||||
|
YOLO_VALIDATION_SCOPE_MANIFEST_SHA256=
|
||||||
|
# Deprecated display metadata; never used as an inference authorization gate.
|
||||||
YOLO_VALIDATED_AREA_NAMES=Mol,Kempen
|
YOLO_VALIDATED_AREA_NAMES=Mol,Kempen
|
||||||
YOLO_IMAGE_SIZE=640
|
YOLO_IMAGE_SIZE=640
|
||||||
YOLO_MAX_TILES=100
|
YOLO_MAX_TILES=100
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ YOLO_MODEL_DISPLAY_NAME="${YOLO_MODEL_DISPLAY_NAME:-Configured YOLO detector}"
|
|||||||
YOLO_MODEL_VERSION="${YOLO_MODEL_VERSION:-}"
|
YOLO_MODEL_VERSION="${YOLO_MODEL_VERSION:-}"
|
||||||
YOLO_MODEL_CLASSES="${YOLO_MODEL_CLASSES:-building}"
|
YOLO_MODEL_CLASSES="${YOLO_MODEL_CLASSES:-building}"
|
||||||
YOLO_ENFORCE_VALIDATION_SCOPE="${YOLO_ENFORCE_VALIDATION_SCOPE:-true}"
|
YOLO_ENFORCE_VALIDATION_SCOPE="${YOLO_ENFORCE_VALIDATION_SCOPE:-true}"
|
||||||
|
YOLO_VALIDATION_SCOPE_MANIFEST_PATH="${YOLO_VALIDATION_SCOPE_MANIFEST_PATH:-}"
|
||||||
|
YOLO_VALIDATION_SCOPE_MANIFEST_SHA256="${YOLO_VALIDATION_SCOPE_MANIFEST_SHA256:-}"
|
||||||
YOLO_VALIDATED_AREA_NAMES="${YOLO_VALIDATED_AREA_NAMES:-Mol,Kempen}"
|
YOLO_VALIDATED_AREA_NAMES="${YOLO_VALIDATED_AREA_NAMES:-Mol,Kempen}"
|
||||||
YOLO_CONFIG_DIR="${YOLO_CONFIG_DIR:-/app/storage/ultralytics}"
|
YOLO_CONFIG_DIR="${YOLO_CONFIG_DIR:-/app/storage/ultralytics}"
|
||||||
YOLO_DEVICE="${YOLO_DEVICE:-cuda:0}"
|
YOLO_DEVICE="${YOLO_DEVICE:-cuda:0}"
|
||||||
@@ -413,6 +415,8 @@ docker run -d \
|
|||||||
-e YOLO_MODEL_VERSION="$YOLO_MODEL_VERSION" \
|
-e YOLO_MODEL_VERSION="$YOLO_MODEL_VERSION" \
|
||||||
-e YOLO_MODEL_CLASSES="$YOLO_MODEL_CLASSES" \
|
-e YOLO_MODEL_CLASSES="$YOLO_MODEL_CLASSES" \
|
||||||
-e YOLO_ENFORCE_VALIDATION_SCOPE="$YOLO_ENFORCE_VALIDATION_SCOPE" \
|
-e YOLO_ENFORCE_VALIDATION_SCOPE="$YOLO_ENFORCE_VALIDATION_SCOPE" \
|
||||||
|
-e YOLO_VALIDATION_SCOPE_MANIFEST_PATH="$YOLO_VALIDATION_SCOPE_MANIFEST_PATH" \
|
||||||
|
-e YOLO_VALIDATION_SCOPE_MANIFEST_SHA256="$YOLO_VALIDATION_SCOPE_MANIFEST_SHA256" \
|
||||||
-e YOLO_VALIDATED_AREA_NAMES="$YOLO_VALIDATED_AREA_NAMES" \
|
-e YOLO_VALIDATED_AREA_NAMES="$YOLO_VALIDATED_AREA_NAMES" \
|
||||||
-e YOLO_CONFIG_DIR="$YOLO_CONFIG_DIR" \
|
-e YOLO_CONFIG_DIR="$YOLO_CONFIG_DIR" \
|
||||||
-e YOLO_DEVICE="$YOLO_DEVICE" \
|
-e YOLO_DEVICE="$YOLO_DEVICE" \
|
||||||
|
|||||||
+26
-2
@@ -121,8 +121,13 @@ Environment variables:
|
|||||||
- `YOLO_REQUIRE_CUDA` (set to `true` on the production server; inference then
|
- `YOLO_REQUIRE_CUDA` (set to `true` on the production server; inference then
|
||||||
fails closed when CUDA is unavailable or `YOLO_DEVICE` selects CPU)
|
fails closed when CUDA is unavailable or `YOLO_DEVICE` selects CPU)
|
||||||
- `YOLO_MODEL_CLASSES` (the active promoted detector is `building` only)
|
- `YOLO_MODEL_CLASSES` (the active promoted detector is `building` only)
|
||||||
- `YOLO_ENFORCE_VALIDATION_SCOPE` and `YOLO_VALIDATED_AREA_NAMES` (production
|
- `YOLO_ENFORCE_VALIDATION_SCOPE` (keep `true` in production)
|
||||||
rejects inference when the raster is not bound to a persisted validated Area)
|
- `YOLO_VALIDATION_SCOPE_MANIFEST_PATH` and
|
||||||
|
`YOLO_VALIDATION_SCOPE_MANIFEST_SHA256` (production accepts inference only
|
||||||
|
when the exact active model bytes match the manifest and the complete
|
||||||
|
persisted Dataset AOI is covered by its valid EPSG:4326 geometry)
|
||||||
|
- `YOLO_VALIDATED_AREA_NAMES` is deprecated display metadata and never grants
|
||||||
|
inference access
|
||||||
- `YOLO_IMAGE_SIZE`
|
- `YOLO_IMAGE_SIZE`
|
||||||
- `YOLO_MAX_TILES`
|
- `YOLO_MAX_TILES`
|
||||||
- `YOLO_MAX_DETECTIONS`
|
- `YOLO_MAX_DETECTIONS`
|
||||||
@@ -136,6 +141,25 @@ the upstream default would cap recall before QA/QC begins. Operators may lower
|
|||||||
the value for small rasters or raise it for dense urban tiles after reviewing
|
the value for small rasters or raise it for dense urban tiles after reviewing
|
||||||
runtime and false-positive behavior.
|
runtime and false-positive behavior.
|
||||||
|
|
||||||
|
Create a new immutable scope artifact whenever either the model bytes or the
|
||||||
|
governed validation boundary changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python /app/scripts/build_model_validation_scope_manifest.py \
|
||||||
|
--model /app/models/active-building.pt \
|
||||||
|
--model-id yolo-configured \
|
||||||
|
--scope-geojson /app/storage/operator-data/geographic-scopes/kempen-transport-region/kempen_transport_region_boundary_YYYY-MM-DD.geojson \
|
||||||
|
--scope-key kempen-transport-region \
|
||||||
|
--authority "Digitaal Vlaanderen VRBG/Refgem" \
|
||||||
|
--snapshot-date YYYY-MM-DD \
|
||||||
|
--output /app/storage/operator-data/model-validation-scopes/active-building-model.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The command refuses to overwrite an existing manifest and prints the checksum
|
||||||
|
for `YOLO_VALIDATION_SCOPE_MANIFEST_SHA256`. Area names are intentionally not
|
||||||
|
part of this decision: they are mutable presentation text, not accuracy or
|
||||||
|
authorization evidence.
|
||||||
|
|
||||||
After YOLO boxes are georeferenced, configured-YOLO runs apply a GeoIntel
|
After YOLO boxes are georeferenced, configured-YOLO runs apply a GeoIntel
|
||||||
cross-tile duplicate suppression pass before persistence. Candidates are grouped
|
cross-tile duplicate suppression pass before persistence. Candidates are grouped
|
||||||
by canonical class and sorted by confidence; lower-confidence same-class
|
by canonical class and sorted by confidence; lower-confidence same-class
|
||||||
|
|||||||
@@ -12444,3 +12444,31 @@ Open:
|
|||||||
`not_evaluable` without governed evidence. Phase 4 remains **in progress**,
|
`not_evaluable` without governed evidence. Phase 4 remains **in progress**,
|
||||||
Phase 5 remains **not ready**, and promotion/training feedback from protected
|
Phase 5 remains **not ready**, and promotion/training feedback from protected
|
||||||
data is not authorized.
|
data is not authorized.
|
||||||
|
|
||||||
|
## 2026-08-09 - Model accuracy boundary and truthful evidence labels
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Replaced the configured-YOLO Area-name substring gate with a fail-closed,
|
||||||
|
checksum-bound validation-scope manifest. The exact model SHA-256 must match
|
||||||
|
and the immutable EPSG:4326 scope geometry must cover the complete persisted
|
||||||
|
Dataset AOI. Renaming an Area can no longer widen model applicability.
|
||||||
|
- Added an operator tool that builds the model-bound manifest from exact model
|
||||||
|
bytes and governed scope GeoJSON without overwriting prior evidence.
|
||||||
|
- Corrected the detection UI: legacy Mol/Kempen profile scores are now labelled
|
||||||
|
historical calibration context, spatial independence is explicitly unproven,
|
||||||
|
and no profile is marked approved or promotion-ready.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
|
||||||
|
- Targeted backend/UI contract selection: 34 passed.
|
||||||
|
- `git diff --check`: passed before the documentation update; final check is
|
||||||
|
part of the handoff verification.
|
||||||
|
|
||||||
|
### Remaining limitations
|
||||||
|
|
||||||
|
- The current active model is still not nationally validated. A new governed
|
||||||
|
corpus, independent spatial split, representative human review and immutable
|
||||||
|
product benchmark remain required before any production-accuracy claim.
|
||||||
|
- Every deployed model asset needs its own generated scope manifest and exact
|
||||||
|
configured manifest checksum before enforced inference is available.
|
||||||
|
|||||||
+4
-2
@@ -1115,8 +1115,10 @@ This file now starts with the current implementation status. Older preparation/b
|
|||||||
112 Ruff findings and add real frontend lint.
|
112 Ruff findings and add real frontend lint.
|
||||||
- [ ] P2-02: fix CRS ingest, metre buffering and Area geometry/CRS updates;
|
- [ ] P2-02: fix CRS ingest, metre buffering and Area geometry/CRS updates;
|
||||||
auditably quarantine or repair the four legacy Geel detections.
|
auditably quarantine or repair the four legacy Geel detections.
|
||||||
- [ ] P2-03: isolate coverage by source/theme/layer/zone, make official source
|
- [ ] P2-03: isolate coverage by source/theme/layer/zone and make official
|
||||||
identity server-attested and replace mutable-name legal/model scope checks.
|
source identity server-attested. The mutable-name YOLO scope bypass is fixed
|
||||||
|
with a model/checksum-bound geometry manifest; equivalent legal-scope checks
|
||||||
|
still require the same review.
|
||||||
- [ ] P2-04: make derived persistence transactional, require complete
|
- [ ] P2-04: make derived persistence transactional, require complete
|
||||||
RunManifest hashes and expose every fallback/persistence failure.
|
RunManifest hashes and expose every fallback/persistence failure.
|
||||||
- [ ] P2-05: remove every protected-test feedback path, introduce a test vault
|
- [ ] P2-05: remove every protected-test feedback path, introduce a test vault
|
||||||
|
|||||||
@@ -21,11 +21,8 @@ const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const
|
|||||||
const DEFAULT_DETECTION_PAGE_SIZE = 50
|
const DEFAULT_DETECTION_PAGE_SIZE = 50
|
||||||
|
|
||||||
function detectionQualityInterpretation(f1: number | null | undefined): string {
|
function detectionQualityInterpretation(f1: number | null | undefined): string {
|
||||||
if (typeof f1 !== 'number' || !Number.isFinite(f1)) return 'Nog geen gevalideerde kwaliteitsmeting.'
|
if (typeof f1 !== 'number' || !Number.isFinite(f1)) return 'Nog geen onafhankelijke kwaliteitsmeting beschikbaar.'
|
||||||
if (f1 >= 0.85) return 'Sterk resultaat; steekproefcontrole blijft vereist.'
|
return `Historische F1 ${f1.toFixed(3)} is alleen kalibratiecontext. Een actuele, ruimtelijk onafhankelijke QA-run bepaalt of dit resultaat lokaal bruikbaar is.`
|
||||||
if (f1 >= 0.70) return 'Bruikbaar met gerichte handmatige controle.'
|
|
||||||
if (f1 >= 0.50) return 'Verkennend resultaat; beoordeel fouten voor operationeel gebruik.'
|
|
||||||
return 'Onvoldoende betrouwbaar voor operationeel gebruik.'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectionStatusLabel(status: string): string {
|
function detectionStatusLabel(status: string): string {
|
||||||
@@ -311,8 +308,8 @@ export function DetectionLab({
|
|||||||
<p>{yoloRuntimeReady ? `${yoloPreflight?.runtime.cuda_available ? 'GPU' : 'CPU'} · lokaal model gevonden` : 'Controleer de modelconfiguratie onder beheer.'}</p>
|
<p>{yoloRuntimeReady ? `${yoloPreflight?.runtime.cuda_available ? 'GPU' : 'CPU'} · lokaal model gevonden` : 'Controleer de modelconfiguratie onder beheer.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ai-user-summary-card">
|
<div className="ai-user-summary-card">
|
||||||
<span>Validatiescope</span>
|
<span>Bewijsstatus</span>
|
||||||
<strong>{selectedOperatorProfile ? `F1 ${selectedOperatorProfile.f1.toFixed(3)}` : 'Nog niet gekoppeld'}</strong>
|
<strong>{selectedOperatorProfile ? `Historische F1 ${selectedOperatorProfile.f1.toFixed(3)}` : 'Nog niet gekoppeld'}</strong>
|
||||||
<p>{selectedOperatorProfile ? selectedOperatorProfile.validationScope : 'Kies een modelprofiel met gedocumenteerd evaluatiebewijs.'}</p>
|
<p>{selectedOperatorProfile ? selectedOperatorProfile.validationScope : 'Kies een modelprofiel met gedocumenteerd evaluatiebewijs.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={rasterDatasets.length > 0 ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}>
|
<div className={rasterDatasets.length > 0 ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}>
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export function DetectionModelManagement({
|
|||||||
{selectedModelAsset ? 'model gekozen' : 'geen model gekozen'}
|
{selectedModelAsset ? 'model gekozen' : 'geen model gekozen'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="operator-profile-grid" aria-label="Gevalideerde YOLO-profielen">
|
<div className="operator-profile-grid" aria-label="Historische YOLO-controleprofielen">
|
||||||
{DETECTION_OPERATOR_PROFILES.map((profile) => {
|
{DETECTION_OPERATOR_PROFILES.map((profile) => {
|
||||||
const profileAsset = modelAssets.find((asset) => asset.model_asset_id === profile.modelAssetId)
|
const profileAsset = modelAssets.find((asset) => asset.model_asset_id === profile.modelAssetId)
|
||||||
const profileSelected =
|
const profileSelected =
|
||||||
@@ -154,18 +154,18 @@ export function DetectionModelManagement({
|
|||||||
>
|
>
|
||||||
<div className="operator-profile-card-header">
|
<div className="operator-profile-card-header">
|
||||||
<strong>{profile.displayName}</strong>
|
<strong>{profile.displayName}</strong>
|
||||||
<span className={profile.defaultApproved ? 'status-badge status-badge-ready' : 'status-badge'}>
|
<span className="status-badge">
|
||||||
{profile.defaultApproved ? 'standaardprofiel' : 'kandidaat, extra controle vereist'}
|
{profile.independentTestProven ? 'onafhankelijk getoetst' : 'historisch, geen releasebewijs'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{profile.description}</p>
|
<p>{profile.description}</p>
|
||||||
<div className="operator-profile-metrics">
|
<div className="operator-profile-metrics">
|
||||||
<span>drempel {profile.confidenceThreshold.toFixed(2)}</span>
|
<span>drempel {profile.confidenceThreshold.toFixed(2)}</span>
|
||||||
<span>precisie {profile.precision.toFixed(3)}</span>
|
<span>historische precisie {profile.precision.toFixed(3)}</span>
|
||||||
<span>herkenningsgraad {profile.recall.toFixed(3)}</span>
|
<span>historische herkenningsgraad {profile.recall.toFixed(3)}</span>
|
||||||
<span>F1 {profile.f1.toFixed(3)}</span>
|
<span>historische F1 {profile.f1.toFixed(3)}</span>
|
||||||
<span>testgebieden {profile.positiveSampleCount}</span>
|
<span>positieve controles {profile.positiveSampleCount}</span>
|
||||||
<span>max. achtergrondfouten {profile.maxBackgroundDetections}</span>
|
<span>gemeten achtergrondfouten {profile.maxBackgroundDetections}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="field-guidance">{profile.limitationMessage}</p>
|
<p className="field-guidance">{profile.limitationMessage}</p>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ export interface DetectionOperatorProfile {
|
|||||||
displayName: string
|
displayName: string
|
||||||
modelAssetId: string
|
modelAssetId: string
|
||||||
confidenceThreshold: number
|
confidenceThreshold: number
|
||||||
defaultApproved: boolean
|
independentTestProven: boolean
|
||||||
promotionRecommendation: 'none' | 'promote_candidate'
|
|
||||||
precision: number
|
precision: number
|
||||||
recall: number
|
recall: number
|
||||||
f1: number
|
f1: number
|
||||||
@@ -21,51 +20,48 @@ export const DETECTION_OPERATOR_PROFILES: DetectionOperatorProfile[] = [
|
|||||||
displayName: 'Aanbevolen controleprofiel kleine gebouwen',
|
displayName: 'Aanbevolen controleprofiel kleine gebouwen',
|
||||||
modelAssetId: 'geointel-building-yolov8s-smallbld-minpx3-img640-ft30-pt',
|
modelAssetId: 'geointel-building-yolov8s-smallbld-minpx3-img640-ft30-pt',
|
||||||
confidenceThreshold: 0.15,
|
confidenceThreshold: 0.15,
|
||||||
defaultApproved: true,
|
independentTestProven: false,
|
||||||
promotionRecommendation: 'promote_candidate',
|
|
||||||
precision: 0.6140895327792112,
|
precision: 0.6140895327792112,
|
||||||
recall: 0.6062221049337548,
|
recall: 0.6062221049337548,
|
||||||
f1: 0.6068607646002744,
|
f1: 0.6068607646002744,
|
||||||
positiveSampleCount: 7,
|
positiveSampleCount: 7,
|
||||||
maxBackgroundDetections: 0,
|
maxBackgroundDetections: 0,
|
||||||
validationScope: '7 onafhankelijke testgebieden in Mol en de Kempen',
|
validationScope: 'historische operatorcontrole in Mol en de Kempen; ruimtelijke onafhankelijkheid niet bewezen',
|
||||||
description:
|
description:
|
||||||
'Aanbevolen profiel met een evenwicht tussen gevonden en gemiste kleine gebouwen, opnieuw gemeten over zeven onafhankelijke testgebieden in Mol en de Kempen.',
|
'Historisch controleprofiel met een evenwicht tussen gevonden en gemiste kleine gebouwen. Gebruik dit als startpunt voor lokale QA, niet als vrijgavebewijs.',
|
||||||
limitationMessage:
|
limitationMessage:
|
||||||
'De drie lege-achtergrondtests zijn geslaagd. Postel blijft met 47,5% F1 het moeilijkste testgebied; behandel elke detectie als een controlekandidaat en niet als grondwaarheid.',
|
'Slechts drie pure-achtergrondbeelden en geen onafhankelijke hold-out ondersteunen deze historische meting. Postel bleef het moeilijkste gebied; behandel elke detectie als controlekandidaat en niet als grondwaarheid.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'expanded-balanced-review',
|
id: 'expanded-balanced-review',
|
||||||
displayName: 'Voorgaand gebalanceerd controleprofiel',
|
displayName: 'Voorgaand gebalanceerd controleprofiel',
|
||||||
modelAssetId: 'geointel-building-yolov8s-aoi1024expandedminpx4vis035e50-pt',
|
modelAssetId: 'geointel-building-yolov8s-aoi1024expandedminpx4vis035e50-pt',
|
||||||
confidenceThreshold: 0.15,
|
confidenceThreshold: 0.15,
|
||||||
defaultApproved: true,
|
independentTestProven: false,
|
||||||
promotionRecommendation: 'promote_candidate',
|
|
||||||
precision: 0.6470590036169351,
|
precision: 0.6470590036169351,
|
||||||
recall: 0.4699913836847832,
|
recall: 0.4699913836847832,
|
||||||
f1: 0.5432865390636915,
|
f1: 0.5432865390636915,
|
||||||
positiveSampleCount: 7,
|
positiveSampleCount: 7,
|
||||||
maxBackgroundDetections: 0,
|
maxBackgroundDetections: 0,
|
||||||
validationScope: '7 onafhankelijke testgebieden in Mol en de Kempen',
|
validationScope: 'historische operatorcontrole in Mol en de Kempen; ruimtelijke onafhankelijkheid niet bewezen',
|
||||||
description: 'Voorgaand profiel voor controles waarbij minder foutieve vondsten belangrijker zijn dan maximale dekking.',
|
description: 'Historisch profiel voor lokale controles waarbij minder foutieve vondsten belangrijker zijn dan maximale dekking.',
|
||||||
limitationMessage:
|
limitationMessage:
|
||||||
'De lege-achtergrondtest is geslaagd. Dit profiel vindt minder onterechte objecten, maar mist meer kleine gebouwen dan het aanbevolen profiel.',
|
'Dit profiel vond historisch minder onterechte objecten, maar miste meer kleine gebouwen. De meting is geen onafhankelijke productbenchmark.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'conservative-review',
|
id: 'conservative-review',
|
||||||
displayName: 'Conservatief controleprofiel',
|
displayName: 'Conservatief controleprofiel',
|
||||||
modelAssetId: 'geointel-building-yolov8s-aoi1024bg512r3e50-pt',
|
modelAssetId: 'geointel-building-yolov8s-aoi1024bg512r3e50-pt',
|
||||||
confidenceThreshold: 0.35,
|
confidenceThreshold: 0.35,
|
||||||
defaultApproved: true,
|
independentTestProven: false,
|
||||||
promotionRecommendation: 'promote_candidate',
|
|
||||||
precision: 0.840006,
|
precision: 0.840006,
|
||||||
recall: 0.202135,
|
recall: 0.202135,
|
||||||
f1: 0.32086574003576274,
|
f1: 0.32086574003576274,
|
||||||
positiveSampleCount: 7,
|
positiveSampleCount: 7,
|
||||||
maxBackgroundDetections: 0,
|
maxBackgroundDetections: 0,
|
||||||
validationScope: '7 onafhankelijke testgebieden in Mol en de Kempen',
|
validationScope: 'historische operatorcontrole in Mol en de Kempen; ruimtelijke onafhankelijkheid niet bewezen',
|
||||||
description: 'Profiel met hoge precisie voor controles waarbij zo weinig mogelijk foutieve vondsten zwaarder wegen dan volledige dekking.',
|
description: 'Historisch profiel voor lokale controles waarbij zo weinig mogelijk foutieve vondsten zwaarder wegen dan volledige dekking.',
|
||||||
limitationMessage:
|
limitationMessage:
|
||||||
'Goedgekeurd na de lege-achtergrondtest. Resultaten in dun bebouwde context blijven altijd controlebewijs en geen automatische waarheid.',
|
'De historische precisie gaat samen met zeer lage herkenningsgraad. Resultaten blijven controlebewijs en geen automatische waarheid of releasebewijs.',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Build an immutable geographic validation scope bound to exact model bytes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from shapely.geometry import mapping, shape
|
||||||
|
from shapely.ops import unary_union
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_VERSION = "geointel.model-validation-scope/v1"
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def read_scope_geometry(path: Path):
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
payload_type = payload.get("type") if isinstance(payload, dict) else None
|
||||||
|
if payload_type == "FeatureCollection":
|
||||||
|
geometries = [shape(feature.get("geometry")) for feature in payload.get("features", [])]
|
||||||
|
geometry = unary_union([item for item in geometries if not item.is_empty])
|
||||||
|
elif payload_type == "Feature":
|
||||||
|
geometry = shape(payload.get("geometry"))
|
||||||
|
else:
|
||||||
|
geometry = shape(payload)
|
||||||
|
if geometry.is_empty or not geometry.is_valid or geometry.geom_type not in {"Polygon", "MultiPolygon"}:
|
||||||
|
raise ValueError("Scope input must resolve to one non-empty valid Polygon or MultiPolygon")
|
||||||
|
return geometry
|
||||||
|
|
||||||
|
|
||||||
|
def build_manifest(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
|
model_path = args.model.expanduser().resolve()
|
||||||
|
scope_path = args.scope_geojson.expanduser().resolve()
|
||||||
|
if not model_path.is_file():
|
||||||
|
raise FileNotFoundError(f"Model file not found: {model_path}")
|
||||||
|
if not scope_path.is_file():
|
||||||
|
raise FileNotFoundError(f"Scope GeoJSON not found: {scope_path}")
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"model_id": args.model_id,
|
||||||
|
"model_sha256": file_sha256(model_path),
|
||||||
|
"scope_key": args.scope_key,
|
||||||
|
"crs": "EPSG:4326",
|
||||||
|
"geometry": mapping(read_scope_geometry(scope_path)),
|
||||||
|
"source": {
|
||||||
|
"scope_geojson_path": str(scope_path),
|
||||||
|
"scope_geojson_sha256": file_sha256(scope_path),
|
||||||
|
"authority": args.authority,
|
||||||
|
"snapshot_date": args.snapshot_date,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--model", type=Path, required=True)
|
||||||
|
parser.add_argument("--model-id", required=True)
|
||||||
|
parser.add_argument("--scope-geojson", type=Path, required=True)
|
||||||
|
parser.add_argument("--scope-key", required=True)
|
||||||
|
parser.add_argument("--authority", required=True)
|
||||||
|
parser.add_argument("--snapshot-date", required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
output = args.output.expanduser().resolve()
|
||||||
|
if output.exists():
|
||||||
|
raise FileExistsError(f"Refusing to overwrite immutable scope manifest: {output}")
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
encoded = (json.dumps(build_manifest(args), ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||||
|
output.write_bytes(encoded)
|
||||||
|
print(json.dumps({"manifest_path": str(output), "manifest_sha256": sha256(encoded).hexdigest()}, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user