546 lines
19 KiB
Python
546 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
from hashlib import sha256
|
|
import json
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from geoalchemy2.shape import to_shape
|
|
|
|
from app.core.config import Settings
|
|
from app.models import Dataset, Project, Segmentation, SourceRegistry, SourceSnapshot
|
|
from app.services.detection_georeferencing import pixel_points_to_epsg4326_polygon
|
|
from app.services.model_registry_service import ModelRegistryService
|
|
from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService
|
|
from app.services.segmentation_service import SegmentationService
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
class FakeSession:
|
|
def __init__(self, objects=None) -> None:
|
|
self.objects = objects or {}
|
|
self.added = []
|
|
self.commits = 0
|
|
self.refreshes = []
|
|
|
|
def get(self, model, item_id):
|
|
return self.objects.get((model, item_id))
|
|
|
|
def add(self, item) -> None:
|
|
self.added.append(item)
|
|
if getattr(item, "id", None) is not None:
|
|
self.objects[(item.__class__, item.id)] = item
|
|
|
|
def commit(self) -> None:
|
|
self.commits += 1
|
|
|
|
def refresh(self, item) -> None:
|
|
self.refreshes.append(item)
|
|
|
|
|
|
class AvailableSegAdapter:
|
|
def __init__(self, settings: Settings) -> None:
|
|
self.settings = settings
|
|
|
|
@staticmethod
|
|
def dependencies_available() -> bool:
|
|
return True
|
|
|
|
def load_model(self, model_path: Path):
|
|
return object()
|
|
|
|
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]:
|
|
return [
|
|
{
|
|
"class_name": "building",
|
|
"confidence": 0.91,
|
|
"points": [[10.0, 20.0], [30.0, 20.0], [30.0, 40.0], [10.0, 40.0]],
|
|
"bbox": [10.0, 20.0, 30.0, 40.0],
|
|
"properties": {"class_id": 0},
|
|
}
|
|
]
|
|
|
|
|
|
class ClassAgnosticSamAdapter(AvailableSegAdapter):
|
|
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]:
|
|
return [
|
|
{
|
|
"class_name": "segment",
|
|
"confidence": None,
|
|
"points": [[5.0, 5.0], [25.0, 5.0], [25.0, 25.0], [5.0, 25.0]],
|
|
"bbox": [5.0, 5.0, 25.0, 25.0],
|
|
"properties": {"class_id": -1},
|
|
}
|
|
]
|
|
|
|
|
|
class MissingDependencySegAdapter(AvailableSegAdapter):
|
|
@staticmethod
|
|
def dependencies_available() -> bool:
|
|
return False
|
|
|
|
|
|
class NeverLoadSegAdapter(AvailableSegAdapter):
|
|
load_calls = 0
|
|
|
|
def load_model(self, model_path: Path):
|
|
type(self).load_calls += 1
|
|
raise AssertionError("unmanifested weights must not reach adapter.load_model")
|
|
|
|
|
|
def _project_and_dataset(dataset_type: str = "raster"):
|
|
project_id = uuid4()
|
|
dataset_id = uuid4()
|
|
source_id = uuid4()
|
|
snapshot_id = uuid4()
|
|
checksum = "a" * 64
|
|
project = Project(id=project_id, name="Mol")
|
|
source = SourceRegistry(
|
|
id=source_id,
|
|
source_key="digitaal_vlaanderen_orthophoto",
|
|
display_name="Governed orthophoto test source",
|
|
classification="contextual",
|
|
authority_name="Digitaal Vlaanderen",
|
|
authority_scope_json={"zone": "Flanders", "role": "imagery"},
|
|
)
|
|
snapshot = SourceSnapshot(
|
|
id=snapshot_id,
|
|
source_registry_id=source_id,
|
|
snapshot_key="configured-segmentation-orthophoto",
|
|
checksum_sha256=checksum,
|
|
ingest_status="ingested",
|
|
freshness_status="current",
|
|
)
|
|
dataset = Dataset(
|
|
id=dataset_id,
|
|
project_id=project_id,
|
|
name="ortho.tif",
|
|
dataset_type=dataset_type,
|
|
source="digitaal_vlaanderen_orthophoto",
|
|
source_name="digitaal_vlaanderen_orthophoto",
|
|
storage_path="storage/uploads/ortho.tif",
|
|
checksum_sha256=checksum,
|
|
source_registry_id=source_id,
|
|
source_snapshot_id=snapshot_id,
|
|
data_contract_key="geointel.raster.geotiff",
|
|
data_contract_version="1.0.0",
|
|
validation_status="passed",
|
|
provenance_status="complete",
|
|
lineage_status="not_applicable",
|
|
quarantine_status="not_quarantined",
|
|
status="ready",
|
|
)
|
|
dataset.source_registry = source
|
|
dataset.source_snapshot = snapshot
|
|
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
|
|
return db, project_id, dataset_id
|
|
|
|
|
|
def _settings(tmp_path: Path, **overrides) -> Settings:
|
|
values = {
|
|
"yolo_seg_enabled": True,
|
|
"yolo_seg_model_path": str(tmp_path / "seg.pt"),
|
|
"sam_enabled": True,
|
|
"sam_model_path": str(tmp_path / "sam.pt"),
|
|
"yolo_max_tiles": 4,
|
|
}
|
|
values.update(overrides)
|
|
return Settings(**values)
|
|
|
|
|
|
def _write_model_sidecar(
|
|
model_path: Path,
|
|
*,
|
|
model_id: str,
|
|
framework: str,
|
|
source_version: str | None,
|
|
db: FakeSession | None = None,
|
|
) -> None:
|
|
"""Create explicit local test evidence; no production code creates sidecars."""
|
|
|
|
model_sha256 = sha256(model_path.read_bytes()).hexdigest()
|
|
source_registry_id = uuid4()
|
|
source_snapshot_id = uuid4()
|
|
resolved_source_version = source_version or "test-v1"
|
|
if db is not None:
|
|
source_registry = SourceRegistry(
|
|
id=source_registry_id,
|
|
source_key="model",
|
|
display_name="Governed test model artifact",
|
|
classification="experimental",
|
|
authority_name="GeoIntel test fixture",
|
|
freshness_status="current",
|
|
ingest_status="configured",
|
|
)
|
|
source_snapshot = SourceSnapshot(
|
|
id=source_snapshot_id,
|
|
source_registry_id=source_registry_id,
|
|
snapshot_key=f"model-{model_id}-{resolved_source_version}",
|
|
source_version=resolved_source_version,
|
|
checksum_sha256=model_sha256,
|
|
freshness_status="current",
|
|
ingest_status="ingested",
|
|
)
|
|
db.objects[(SourceRegistry, source_registry_id)] = source_registry
|
|
db.objects[(SourceSnapshot, source_snapshot_id)] = source_snapshot
|
|
payload = {
|
|
"schema_version": RuntimeModelProvenanceService.MANIFEST_SCHEMA_VERSION,
|
|
"data_contract": {"key": "geointel.model.pytorch", "version": "1.0.0"},
|
|
"model": {
|
|
"model_id": model_id,
|
|
"task_type": "segmentation",
|
|
"sha256": model_sha256,
|
|
"model_format": "pytorch",
|
|
"framework": framework,
|
|
"class_mapping": {"0": "segment"},
|
|
"source_version": resolved_source_version,
|
|
},
|
|
"source": {
|
|
"source_registry_id": str(source_registry_id),
|
|
"source_snapshot_id": str(source_snapshot_id),
|
|
"source_registry_key": "model",
|
|
"source_snapshot_checksum_sha256": model_sha256,
|
|
},
|
|
"lineage": {
|
|
"upstream_asset_ids": ["test-training-corpus"],
|
|
"upstream_checksums_sha256": ["a" * 64],
|
|
"transformations": [
|
|
{"name": "test-training", "version": "1.0.0", "checksum_sha256": "b" * 64}
|
|
],
|
|
},
|
|
"metadata": {"training_manifest_sha256": "c" * 64},
|
|
"imported_at": "2026-08-01T10:00:00+00:00",
|
|
}
|
|
payload["metadata"]["runtime_manifest_sha256"] = RuntimeModelProvenanceService.manifest_self_checksum(payload)
|
|
RuntimeModelProvenanceService.manifest_path_for_model(model_path).write_text(
|
|
json.dumps(payload, sort_keys=True),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _write_configured_model_sidecars(
|
|
tmp_path: Path,
|
|
settings: Settings,
|
|
*,
|
|
include_yolo: bool = True,
|
|
include_sam: bool = True,
|
|
db: FakeSession | None = None,
|
|
) -> None:
|
|
if include_yolo:
|
|
_write_model_sidecar(
|
|
tmp_path / "seg.pt",
|
|
model_id=settings.yolo_seg_model_id,
|
|
framework="ultralytics/pytorch",
|
|
source_version=settings.yolo_seg_model_version,
|
|
db=db,
|
|
)
|
|
if include_sam:
|
|
_write_model_sidecar(
|
|
tmp_path / "sam.pt",
|
|
model_id=settings.sam_model_id,
|
|
framework="ultralytics/sam",
|
|
source_version=settings.sam_model_version,
|
|
db=db,
|
|
)
|
|
|
|
|
|
def _manifest(tmp_path: Path, tile_count: int = 1) -> Path:
|
|
tiles = []
|
|
for index in range(tile_count):
|
|
tile_path = tmp_path / f"tile_{index:04d}.tif"
|
|
tile_path.write_bytes(b"fixture")
|
|
tiles.append(
|
|
{
|
|
"path": str(tile_path),
|
|
"pixel_window": [0, 0, 100, 100],
|
|
"bounds": [4.0, 51.0, 5.0, 52.0],
|
|
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
|
|
"index": index,
|
|
}
|
|
)
|
|
manifest_path = tmp_path / "manifest.json"
|
|
manifest_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"tile_set_id": "tiles-fixture",
|
|
"source_dataset_id": str(uuid4()),
|
|
"source_raster_id": str(uuid4()),
|
|
"tile_size": 100,
|
|
"overlap": 0,
|
|
"count": tile_count,
|
|
"tiles": tiles,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return manifest_path
|
|
|
|
|
|
def test_segmentation_models_report_not_configured_when_disabled(tmp_path: Path) -> None:
|
|
settings = _settings(tmp_path, yolo_seg_enabled=False, sam_enabled=False)
|
|
|
|
models = {
|
|
model.model_id: model
|
|
for model in ModelRegistryService.list_segmentation_model_capabilities(settings=settings)
|
|
}
|
|
|
|
assert models["yolo-seg-configured"].configured is False
|
|
assert models["yolo-seg-configured"].status == "not_configured"
|
|
assert models["sam-configured"].configured is False
|
|
assert models["sam-configured"].status == "not_configured"
|
|
|
|
|
|
def test_segmentation_models_report_dependency_unavailable(tmp_path: Path) -> None:
|
|
(tmp_path / "seg.pt").write_bytes(b"weights")
|
|
(tmp_path / "sam.pt").write_bytes(b"weights")
|
|
settings = _settings(tmp_path)
|
|
|
|
models = {
|
|
model.model_id: model
|
|
for model in ModelRegistryService.list_segmentation_model_capabilities(
|
|
settings=settings,
|
|
yolo_seg_adapter_class=MissingDependencySegAdapter,
|
|
sam_adapter_class=MissingDependencySegAdapter,
|
|
)
|
|
}
|
|
|
|
assert models["yolo-seg-configured"].status == "dependency_unavailable"
|
|
assert models["sam-configured"].status == "dependency_unavailable"
|
|
|
|
|
|
def test_segmentation_models_require_runtime_provenance_sidecars(tmp_path: Path) -> None:
|
|
(tmp_path / "seg.pt").write_bytes(b"unmanifested yolo segmentation weights")
|
|
(tmp_path / "sam.pt").write_bytes(b"unmanifested sam weights")
|
|
settings = _settings(tmp_path)
|
|
|
|
models = {
|
|
model.model_id: model
|
|
for model in ModelRegistryService.list_segmentation_model_capabilities(
|
|
settings=settings,
|
|
yolo_seg_adapter_class=AvailableSegAdapter,
|
|
sam_adapter_class=ClassAgnosticSamAdapter,
|
|
)
|
|
}
|
|
|
|
assert models["yolo-seg-configured"].configured is False
|
|
assert models["yolo-seg-configured"].status == "contract_incomplete"
|
|
assert models["sam-configured"].configured is False
|
|
assert models["sam-configured"].status == "contract_incomplete"
|
|
|
|
|
|
def test_segmentation_models_report_configured_with_local_weights(tmp_path: Path) -> None:
|
|
(tmp_path / "seg.pt").write_bytes(b"weights")
|
|
(tmp_path / "sam.pt").write_bytes(b"weights")
|
|
settings = _settings(tmp_path)
|
|
_write_configured_model_sidecars(tmp_path, settings)
|
|
|
|
models = {
|
|
model.model_id: model
|
|
for model in ModelRegistryService.list_segmentation_model_capabilities(
|
|
settings=settings,
|
|
yolo_seg_adapter_class=AvailableSegAdapter,
|
|
sam_adapter_class=ClassAgnosticSamAdapter,
|
|
)
|
|
}
|
|
|
|
assert models["yolo-seg-configured"].configured is True
|
|
assert models["yolo-seg-configured"].status == "configured"
|
|
assert models["sam-configured"].configured is True
|
|
assert models["sam-configured"].status == "configured"
|
|
|
|
|
|
def test_segmentation_dependency_check_uses_real_imports_not_find_spec() -> None:
|
|
source = (ROOT / "backend" / "app" / "services" / "segmentation_adapter.py").read_text(encoding="utf-8")
|
|
|
|
assert 'find_spec("ultralytics")' not in source
|
|
assert "import ultralytics" in source
|
|
assert "import torch" in source
|
|
|
|
|
|
def test_configured_segmentation_requires_tile_manifest(tmp_path: Path) -> None:
|
|
(tmp_path / "seg.pt").write_bytes(b"weights")
|
|
db, project_id, dataset_id = _project_and_dataset()
|
|
settings = _settings(tmp_path)
|
|
_write_configured_model_sidecars(tmp_path, settings, include_sam=False, db=db)
|
|
|
|
with pytest.raises(Exception) as exc_info:
|
|
SegmentationService.run_segmentation(
|
|
db=db,
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
model_id="yolo-seg-configured",
|
|
confidence_threshold=0.5,
|
|
settings=settings,
|
|
yolo_seg_adapter_class=AvailableSegAdapter,
|
|
sam_adapter_class=ClassAgnosticSamAdapter,
|
|
)
|
|
|
|
assert getattr(exc_info.value, "code", None) == "SEGMENTATION_TILE_MANIFEST_REQUIRED"
|
|
|
|
|
|
def test_configured_segmentation_fails_before_adapter_load_without_sidecar(tmp_path: Path) -> None:
|
|
(tmp_path / "seg.pt").write_bytes(b"unmanifested weights")
|
|
db, project_id, dataset_id = _project_and_dataset()
|
|
settings = _settings(tmp_path)
|
|
NeverLoadSegAdapter.load_calls = 0
|
|
|
|
response = SegmentationService.run_segmentation(
|
|
db=db,
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
model_id="yolo-seg-configured",
|
|
confidence_threshold=0.5,
|
|
tile_manifest_path=str(_manifest(tmp_path)),
|
|
settings=settings,
|
|
yolo_seg_adapter_class=NeverLoadSegAdapter,
|
|
sam_adapter_class=ClassAgnosticSamAdapter,
|
|
)
|
|
|
|
assert response.status == "failed"
|
|
assert response.error_code == "SEGMENTATION_MODEL_UNAVAILABLE"
|
|
assert NeverLoadSegAdapter.load_calls == 0
|
|
|
|
|
|
def test_configured_segmentation_rejects_unbound_model_snapshot_before_adapter_load(tmp_path: Path) -> None:
|
|
(tmp_path / "seg.pt").write_bytes(b"structurally valid but unbound weights")
|
|
db, project_id, dataset_id = _project_and_dataset()
|
|
settings = _settings(tmp_path)
|
|
# The sidecar passes catalog validation but its source registry/snapshot
|
|
# was never registered in this production-session fixture.
|
|
_write_configured_model_sidecars(tmp_path, settings, include_sam=False)
|
|
NeverLoadSegAdapter.load_calls = 0
|
|
|
|
response = SegmentationService.run_segmentation(
|
|
db=db,
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
model_id="yolo-seg-configured",
|
|
confidence_threshold=0.5,
|
|
tile_manifest_path=str(_manifest(tmp_path)),
|
|
settings=settings,
|
|
yolo_seg_adapter_class=NeverLoadSegAdapter,
|
|
sam_adapter_class=ClassAgnosticSamAdapter,
|
|
)
|
|
|
|
assert response.status == "failed"
|
|
assert response.error_code == "MODEL_PROVENANCE_SOURCE_REGISTRY_NOT_FOUND"
|
|
assert NeverLoadSegAdapter.load_calls == 0
|
|
|
|
|
|
def test_configured_yolo_seg_run_persists_georeferenced_masks(tmp_path: Path) -> None:
|
|
(tmp_path / "seg.pt").write_bytes(b"weights")
|
|
db, project_id, dataset_id = _project_and_dataset()
|
|
settings = _settings(tmp_path)
|
|
_write_configured_model_sidecars(tmp_path, settings, include_sam=False, db=db)
|
|
manifest_path = _manifest(tmp_path)
|
|
|
|
response = SegmentationService.run_segmentation(
|
|
db=db,
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
model_id="yolo-seg-configured",
|
|
confidence_threshold=0.5,
|
|
tile_manifest_path=str(manifest_path),
|
|
settings=settings,
|
|
yolo_seg_adapter_class=AvailableSegAdapter,
|
|
sam_adapter_class=ClassAgnosticSamAdapter,
|
|
)
|
|
|
|
assert response.status == "success"
|
|
assert response.segmentation_count == 1
|
|
persisted = [item for item in db.added if isinstance(item, Segmentation)]
|
|
assert len(persisted) == 1
|
|
segmentation = persisted[0]
|
|
assert segmentation.class_name == "building"
|
|
assert segmentation.confidence == pytest.approx(0.91)
|
|
geometry = to_shape(segmentation.geometry)
|
|
assert geometry.geom_type == "MultiPolygon"
|
|
min_x, min_y, max_x, max_y = geometry.bounds
|
|
assert 4.0 <= min_x <= 5.0
|
|
assert 51.0 <= min_y <= 52.0
|
|
assert max_x <= 5.0
|
|
assert max_y <= 52.0
|
|
assert segmentation.area_m2 is not None and segmentation.area_m2 > 0
|
|
assert segmentation.provenance_json["inference"] == "local"
|
|
assert segmentation.provenance_json["model_id"] == "yolo-seg-configured"
|
|
assert segmentation.provenance_json["runtime_model_provenance"]["data_contract_key"] == "geointel.model.pytorch"
|
|
|
|
|
|
def test_configured_sam_run_is_class_agnostic(tmp_path: Path) -> None:
|
|
(tmp_path / "sam.pt").write_bytes(b"weights")
|
|
db, project_id, dataset_id = _project_and_dataset()
|
|
settings = _settings(tmp_path)
|
|
_write_configured_model_sidecars(tmp_path, settings, include_yolo=False, db=db)
|
|
manifest_path = _manifest(tmp_path)
|
|
|
|
response = SegmentationService.run_segmentation(
|
|
db=db,
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
model_id="sam-configured",
|
|
confidence_threshold=0.5,
|
|
tile_manifest_path=str(manifest_path),
|
|
settings=settings,
|
|
yolo_seg_adapter_class=AvailableSegAdapter,
|
|
sam_adapter_class=ClassAgnosticSamAdapter,
|
|
)
|
|
|
|
assert response.status == "success"
|
|
assert response.segmentation_count == 1
|
|
persisted = [item for item in db.added if isinstance(item, Segmentation)]
|
|
assert persisted[0].class_name == "segment"
|
|
assert persisted[0].confidence is None
|
|
|
|
|
|
def test_unconfigured_segmentation_run_fails_closed(tmp_path: Path) -> None:
|
|
db, project_id, dataset_id = _project_and_dataset()
|
|
settings = _settings(tmp_path, yolo_seg_enabled=False)
|
|
manifest_path = _manifest(tmp_path)
|
|
|
|
response = SegmentationService.run_segmentation(
|
|
db=db,
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
model_id="yolo-seg-configured",
|
|
confidence_threshold=0.5,
|
|
tile_manifest_path=str(manifest_path),
|
|
settings=settings,
|
|
yolo_seg_adapter_class=AvailableSegAdapter,
|
|
sam_adapter_class=ClassAgnosticSamAdapter,
|
|
)
|
|
|
|
assert response.status == "failed"
|
|
assert response.error_code == "SEGMENTATION_MODEL_UNAVAILABLE"
|
|
assert not [item for item in db.added if isinstance(item, Segmentation)]
|
|
|
|
|
|
def test_pixel_points_to_epsg4326_polygon_uses_tile_transform() -> None:
|
|
tile = {
|
|
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
|
|
"bounds": [4.0, 51.0, 5.0, 52.0],
|
|
"pixel_window": [0, 0, 100, 100],
|
|
}
|
|
|
|
polygon = pixel_points_to_epsg4326_polygon(
|
|
points=[[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]],
|
|
tile=tile,
|
|
crs="EPSG:4326",
|
|
)
|
|
|
|
min_x, min_y, max_x, max_y = polygon.bounds
|
|
assert min_x == pytest.approx(4.0)
|
|
assert max_x == pytest.approx(5.0)
|
|
assert min_y == pytest.approx(51.0)
|
|
assert max_y == pytest.approx(52.0)
|
|
|
|
|
|
def test_pixel_points_to_epsg4326_polygon_rejects_degenerate_input() -> None:
|
|
tile = {"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01]}
|
|
|
|
with pytest.raises(Exception) as exc_info:
|
|
pixel_points_to_epsg4326_polygon(points=[[0.0, 0.0], [1.0, 1.0]], tile=tile)
|
|
|
|
assert getattr(exc_info.value, "code", None) == "SEGMENTATION_INVALID_MASK"
|