feat: add map-driven orthophoto analysis
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 02:01:03 +02:00
parent 845c4696e7
commit daccd3869a
29 changed files with 1300 additions and 26 deletions
+7
View File
@@ -5,6 +5,13 @@ DATABASE_URL=postgresql+psycopg://geointel:geointel@localhost:5432/geointel?conn
STORAGE_ROOT=./storage
MAX_UPLOAD_MB=500
CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202
ORTHOPHOTO_ENABLED=true
ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms
ORTHOPHOTO_WMS_LAYER=Ortho
ORTHOPHOTO_RESOLUTION_M=1.0
ORTHOPHOTO_MIN_SIDE_M=128
ORTHOPHOTO_MAX_SIDE_M=1024
ORTHOPHOTO_CACHE_TTL_HOURS=24
YOLO_ENABLED=false
YOLO_MODELS_DIR=/app/models
YOLO_MODEL_PATH=
+15
View File
@@ -7,6 +7,21 @@
# Changelog
## Sprint 196 Map-driven official orthophoto analysis (2026-07-15)
- Added an explicit bounded endpoint for the official Digitaal Vlaanderen
most-recent winter orthophoto WMS, with EPSG:31370 georeferencing, 128-1,024
metre side limits, response guards, exact-request reuse and complete
Dataset/DatasetVersion provenance through DatasetService.
- Connected a drawn map rectangle to one building-analysis action: official
raster acquisition, canonical tiling, active local YOLO inference, persisted
detections, existing GRB QA and MapLibre output.
- Kept all fetches user-triggered and backend-only. No startup fetch,
browser-side WMS call, model download, direct persistence write or fabricated
detection/QA value was introduced.
- Added Docker/Unraid controls and focused service, CRS, persistence,
safety-bound and canonical-envelope regressions.
## Sprint 195 Guided raster-to-detection workflow (2026-07-14)
- Replaced the Detection Lab's manual manifest-path prerequisite with one guided action that creates canonical 512 px raster tiles with 64 px overlap, reuses an existing manifest, validates raster size and the local YOLO runtime, runs persisted detection and loads the persisted GeoJSON result on the existing MapLibre map.
+16
View File
@@ -1057,3 +1057,19 @@ PostGIS intersection path.
- `bash scripts/backend_test.sh`
- `bash scripts/backend_dev.sh`
- `bash scripts/smoke_backend_import.sh`
## Bounded official orthophoto acquisition
`POST /api/v1/projects/{project_id}/datasets/orthophoto/acquire` accepts an
explicit EPSG:4326 map rectangle and stores the official Digitaal Vlaanderen
`OMWRGBMRVL`/`Ortho` response as a canonical EPSG:31370 raster Dataset. The
default safety envelope is 128-1,024 m per side, 1 m/pixel, 32 MiB and a
24-hour exact-request cache. It runs synchronously behind the existing Job
abstraction and never during startup.
Settings: `ORTHOPHOTO_ENABLED`, `ORTHOPHOTO_WMS_URL`,
`ORTHOPHOTO_WMS_LAYER`, `ORTHOPHOTO_RESOLUTION_M`,
`ORTHOPHOTO_MIN_SIDE_M`, `ORTHOPHOTO_MAX_SIDE_M`,
`ORTHOPHOTO_TIMEOUT_SECONDS`, `ORTHOPHOTO_MAX_RESPONSE_MB` and
`ORTHOPHOTO_CACHE_TTL_HOURS`. Keep the official HTTPS URL and 1 m profile
unless a separately verified deployment/model profile requires a change.
+18
View File
@@ -21,6 +21,7 @@ from app.schemas import (
RasterNdviRequest,
RasterNdwiRequest,
RasterNdbiRequest,
OrthophotoAcquireRequest,
VectorBBoxResponse,
VectorBufferRequest,
VectorClipRequest,
@@ -38,6 +39,7 @@ from app.services.raster_operations_service import RasterOperationsService
from app.services.vector_operations_service import VectorOperationsService
from app.services.vector_feature_service import VectorFeatureService
from app.services.dataset_service import DatasetService
from app.services.orthophoto_acquisition_service import OrthophotoAcquisitionService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}", tags=["datasets"])
@@ -125,6 +127,22 @@ async def upload_dataset(
return envelope(created.model_dump())
@router.post("/datasets/orthophoto/acquire", response_model=dict)
def acquire_bounded_orthophoto(
project_id: UUID,
payload: OrthophotoAcquireRequest,
db: Session = Depends(get_db),
):
job = JobService.run_sync_job(
db=db,
project_id=project_id,
job_type="raster.orthophoto.acquire",
parameters=payload.model_dump(mode="json"),
operation=lambda: OrthophotoAcquisitionService.acquire(db, project_id, payload),
)
return envelope(job)
@router.get("/datasets", response_model=dict)
def list_datasets(
project_id: UUID,
+12
View File
@@ -19,6 +19,18 @@ class Settings(BaseSettings):
)
storage_root: str = Field(default="./storage", validation_alias="STORAGE_ROOT")
max_upload_mb: int = Field(default=500, validation_alias="MAX_UPLOAD_MB")
orthophoto_enabled: bool = Field(default=True, validation_alias="ORTHOPHOTO_ENABLED")
orthophoto_wms_url: str = Field(
default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms",
validation_alias="ORTHOPHOTO_WMS_URL",
)
orthophoto_wms_layer: str = Field(default="Ortho", validation_alias="ORTHOPHOTO_WMS_LAYER")
orthophoto_resolution_m: float = Field(default=1.0, gt=0, validation_alias="ORTHOPHOTO_RESOLUTION_M")
orthophoto_min_side_m: float = Field(default=128.0, gt=0, validation_alias="ORTHOPHOTO_MIN_SIDE_M")
orthophoto_max_side_m: float = Field(default=1024.0, gt=0, validation_alias="ORTHOPHOTO_MAX_SIDE_M")
orthophoto_timeout_seconds: int = Field(default=120, ge=1, validation_alias="ORTHOPHOTO_TIMEOUT_SECONDS")
orthophoto_max_response_mb: int = Field(default=32, ge=1, validation_alias="ORTHOPHOTO_MAX_RESPONSE_MB")
orthophoto_cache_ttl_hours: int = Field(default=24, ge=0, validation_alias="ORTHOPHOTO_CACHE_TTL_HOURS")
redis_url: str | None = Field(default=None, validation_alias="REDIS_URL")
log_level: str = Field(default="INFO", validation_alias="GEOINTEL_LOG_LEVEL")
database_statement_timeout_ms: int = Field(default=5_000, validation_alias="DATABASE_STATEMENT_TIMEOUT_MS")
+3
View File
@@ -31,6 +31,7 @@ from .segmentation import (
)
from .health import HealthResponse, SystemCapabilities
from .job import JobCreate, JobList, JobRead, JobStatus
from .orthophoto import OrthophotoAcquireRequest, OrthophotoAcquisitionResult
from .external import (
ExternalFetchRequest,
ExternalFetchResponse,
@@ -125,6 +126,8 @@ __all__ = [
"JobList",
"JobRead",
"JobStatus",
"OrthophotoAcquireRequest",
"OrthophotoAcquisitionResult",
"VectorBBoxResponse",
"VectorClipRequest",
"VectorBufferRequest",
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from uuid import UUID
from pydantic import BaseModel
from .operations import VectorSelectionBBox
class OrthophotoAcquireRequest(BaseModel):
bbox: VectorSelectionBBox
area_id: UUID | None = None
force_refresh: bool = False
class OrthophotoAcquisitionResult(BaseModel):
output_dataset_id: UUID
reused: bool
provider: str
layer: str
width: int
height: int
resolution_m: float
bbox_epsg4326: list[float]
bbox_epsg31370: list[float]
attribution: str
limitation_message: str
+86
View File
@@ -410,6 +410,92 @@ class DatasetService:
return DatasetService._to_response(dataset)
@staticmethod
def import_raster_bytes(
db: Session,
*,
project_id: UUID,
filename: str,
content: bytes,
source: str,
source_name: str,
source_metadata: dict[str, Any],
provenance_metadata: dict[str, Any],
area_id: UUID | None = None,
source_version: str | None = None,
content_type: str = "image/tiff",
) -> DatasetCreateResponse:
if not db.get(Project, project_id):
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
if area_id is not None:
area = db.get(Area, area_id)
if not area:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
if area.project_id != project_id:
raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400)
if not content:
raise AppError(code="INVALID_UPLOAD", message="Raster artifact is empty", status_code=400)
safe_filename = DatasetService._validate_upload_filename(filename)
if DatasetService._extension_for_path(safe_filename) not in DatasetService.RASTER_EXTENSIONS:
raise AppError(code="INVALID_UPLOAD", message="Raster artifacts require a GeoTIFF filename", status_code=415)
dataset_id = uuid.uuid4()
storage_info = StorageService.persist_dataset_file(
project_id=str(project_id),
dataset_id=str(dataset_id),
dataset_type="raster",
original_filename=safe_filename,
content=content,
content_type=content_type,
)
try:
metadata = extract_raster_metadata(storage_info["storage_path"])
dataset = Dataset(
id=dataset_id,
project_id=project_id,
area_id=area_id,
name=safe_filename,
dataset_type="raster",
source=source,
dataset_role="source",
source_name=source_name,
source_metadata=source_metadata,
provenance_metadata=provenance_metadata,
imported_at=datetime.now(timezone.utc),
source_version=source_version,
storage_path=storage_info["storage_path"],
original_filename=storage_info["original_filename"],
stored_filename=storage_info["stored_filename"],
content_type=storage_info["content_type"],
size_bytes=storage_info["size_bytes"],
checksum_sha256=storage_info["checksum_sha256"],
crs=metadata.get("crs"),
bounds_json=DatasetService._extract_raster_bounds_json(metadata),
resolution_json=DatasetService._extract_raster_resolution_json(metadata),
bands_json=DatasetService._extract_raster_bands_json(metadata),
metadata_json=metadata,
status="ready",
)
db.add(dataset)
db.add(
DatasetVersion(
dataset_id=dataset.id,
version=1,
storage_path=dataset.storage_path,
source_version=dataset.source_version,
checksum_sha256=dataset.checksum_sha256,
source_metadata=dataset.source_metadata,
provenance_metadata=dataset.provenance_metadata,
)
)
db.commit()
db.refresh(dataset)
return DatasetService._to_response(dataset)
except Exception:
db.rollback()
StorageService.remove_dataset_file(storage_info["storage_path"])
raise
@staticmethod
def import_partitioned_vector_artifact(
db: Session,
@@ -0,0 +1,315 @@
from __future__ import annotations
import hashlib
import json
import math
import warnings
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from uuid import UUID
from geoalchemy2.shape import to_shape
from pyproj import Transformer
from shapely.geometry import box
from shapely.ops import transform as shapely_transform
from app.core.config import Settings, get_settings
from app.core.errors import AppError
from app.models import Area, Dataset, Project
from app.schemas.orthophoto import OrthophotoAcquireRequest, OrthophotoAcquisitionResult
from app.services.dataset_service import DatasetService
class OrthophotoAcquisitionService:
PROVIDER = "digitaal_vlaanderen_orthophoto"
ATTRIBUTION = "Bron: Orthofotomozaiek Vlaanderen, Digitaal Vlaanderen"
CATALOG_URL = "https://www.vlaanderen.be/datavindplaats/catalogus/orthofotomozaiek-middenschalig-winteropnamen-kleur-meest-recent-vlaanderen"
LIMITATION = "Meest recente samengestelde winterorthofoto op het moment van de aanvraag; geen historische opnamedatum per pixel."
@staticmethod
def _prepared_request(
payload: OrthophotoAcquireRequest,
settings: Settings,
) -> dict[str, Any]:
if payload.bbox.crs.upper() != "EPSG:4326":
raise AppError(code="INVALID_CRS", message="Orthophoto selection bbox must use EPSG:4326", status_code=400)
min_x = float(payload.bbox.min_x)
min_y = float(payload.bbox.min_y)
max_x = float(payload.bbox.max_x)
max_y = float(payload.bbox.max_y)
if not all(math.isfinite(value) for value in (min_x, min_y, max_x, max_y)) or min_x >= max_x or min_y >= max_y:
raise AppError(code="INVALID_BBOX", message="Orthophoto selection must be a finite non-empty rectangle", status_code=400)
transformer = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
lambert_bounds = transformer.transform_bounds(min_x, min_y, max_x, max_y, densify_pts=21)
width_m = lambert_bounds[2] - lambert_bounds[0]
height_m = lambert_bounds[3] - lambert_bounds[1]
if width_m < settings.orthophoto_min_side_m or height_m < settings.orthophoto_min_side_m:
raise AppError(
code="ORTHOPHOTO_SELECTION_TOO_SMALL",
message=f"Select an area of at least {settings.orthophoto_min_side_m:.0f} by {settings.orthophoto_min_side_m:.0f} metres",
status_code=422,
)
if width_m > settings.orthophoto_max_side_m or height_m > settings.orthophoto_max_side_m:
raise AppError(
code="ORTHOPHOTO_SELECTION_TOO_LARGE",
message=f"Select an area no larger than {settings.orthophoto_max_side_m:.0f} by {settings.orthophoto_max_side_m:.0f} metres",
details={"width_m": width_m, "height_m": height_m},
status_code=422,
)
width = max(1, math.ceil(width_m / settings.orthophoto_resolution_m))
height = max(1, math.ceil(height_m / settings.orthophoto_resolution_m))
bbox_4326 = [min_x, min_y, max_x, max_y]
bbox_31370 = [float(value) for value in lambert_bounds]
request_identity = {
"provider": OrthophotoAcquisitionService.PROVIDER,
"wms_url": settings.orthophoto_wms_url,
"layer": settings.orthophoto_wms_layer,
"bbox_epsg4326": [round(value, 8) for value in bbox_4326],
"bbox_epsg31370": [round(value, 3) for value in bbox_31370],
"width": width,
"height": height,
"resolution_m": settings.orthophoto_resolution_m,
}
request_hash = hashlib.sha256(json.dumps(request_identity, sort_keys=True).encode("utf-8")).hexdigest()
params = {
"SERVICE": "WMS",
"VERSION": "1.3.0",
"REQUEST": "GetMap",
"LAYERS": settings.orthophoto_wms_layer,
"STYLES": "",
"FORMAT": "image/tiff",
"CRS": "EPSG:31370",
"BBOX": ",".join(f"{value:.3f}" for value in bbox_31370),
"WIDTH": str(width),
"HEIGHT": str(height),
}
return {
**request_identity,
"request_hash": request_hash,
"request_url": f"{settings.orthophoto_wms_url}?{urlencode(params)}",
"params": params,
"bbox_epsg4326": bbox_4326,
"bbox_epsg31370": bbox_31370,
}
@staticmethod
def _validate_area_scope(db, project_id: UUID, area_id: UUID | None, bbox_epsg4326: list[float]) -> None:
if not db.get(Project, project_id):
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
if area_id is None:
return
area = db.get(Area, area_id)
if not area:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
if area.project_id != project_id:
raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400)
selection = box(*bbox_epsg4326)
area_geometry = to_shape(area.geometry)
transformer = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
selection_metric = shapely_transform(transformer.transform, selection)
area_metric = shapely_transform(transformer.transform, area_geometry)
overlap_ratio = area_metric.intersection(selection_metric).area / selection_metric.area
if overlap_ratio < 0.99:
raise AppError(
code="ORTHOPHOTO_SELECTION_OUTSIDE_AREA",
message="Keep the orthophoto rectangle inside the selected work area",
details={"coverage_ratio": overlap_ratio},
status_code=422,
)
@staticmethod
def _cached_dataset(db, project_id: UUID, filename: str, settings: Settings) -> Dataset | None:
if settings.orthophoto_cache_ttl_hours <= 0:
return None
candidate = (
db.query(Dataset)
.filter(
Dataset.project_id == project_id,
Dataset.name == filename,
Dataset.source_name == OrthophotoAcquisitionService.PROVIDER,
Dataset.status == "ready",
)
.order_by(Dataset.imported_at.desc())
.first()
)
if not candidate or not candidate.storage_path or not Path(candidate.storage_path).is_file():
return None
imported_at = candidate.imported_at
if imported_at is None:
return None
if imported_at.tzinfo is None:
imported_at = imported_at.replace(tzinfo=UTC)
if datetime.now(UTC) - imported_at > timedelta(hours=settings.orthophoto_cache_ttl_hours):
return None
return candidate
@staticmethod
def _fetch(request_url: str, settings: Settings, opener: Callable[..., Any] | None = None) -> tuple[bytes, str]:
request = Request(request_url, headers={"User-Agent": "GeoIntel/0.1 bounded-orthophoto-acquisition"})
open_request = opener or urlopen
try:
with open_request(request, timeout=settings.orthophoto_timeout_seconds) as response:
content_type = str(response.headers.get("Content-Type", ""))
content_length = response.headers.get("Content-Length")
max_bytes = settings.orthophoto_max_response_mb * 1024 * 1024
if content_length and int(content_length) > max_bytes:
raise AppError(code="ORTHOPHOTO_RESPONSE_TOO_LARGE", message="Official orthophoto response exceeds the configured size limit", status_code=502)
content = response.read(max_bytes + 1)
except AppError:
raise
except (HTTPError, URLError, TimeoutError, OSError) as exc:
raise AppError(
code="ORTHOPHOTO_PROVIDER_UNAVAILABLE",
message="The official orthophoto service could not complete the bounded request",
details={"reason": str(exc)},
status_code=502,
) from exc
if len(content) > settings.orthophoto_max_response_mb * 1024 * 1024:
raise AppError(code="ORTHOPHOTO_RESPONSE_TOO_LARGE", message="Official orthophoto response exceeds the configured size limit", status_code=502)
if "image" not in content_type.lower() and "tiff" not in content_type.lower():
preview = content[:300].decode("utf-8", errors="replace")
raise AppError(
code="ORTHOPHOTO_PROVIDER_INVALID_RESPONSE",
message="The official orthophoto service did not return an image",
details={"content_type": content_type, "response_preview": preview},
status_code=502,
)
return content, content_type
@staticmethod
def _georeference_tiff(content: bytes, prepared: dict[str, Any]) -> bytes:
try:
from rasterio.io import MemoryFile
from rasterio.errors import NotGeoreferencedWarning
from rasterio.transform import from_bounds
except ImportError as exc:
raise AppError(code="RASTER_PROCESSING_UNAVAILABLE", message="Rasterio is required for orthophoto acquisition", status_code=503) from exc
try:
with MemoryFile(content) as source_memory:
with warnings.catch_warnings():
warnings.simplefilter("ignore", NotGeoreferencedWarning)
with source_memory.open() as source:
if source.width != prepared["width"] or source.height != prepared["height"] or source.count < 3:
raise AppError(
code="ORTHOPHOTO_PROVIDER_INVALID_RESPONSE",
message="Official orthophoto dimensions or RGB bands do not match the bounded request",
details={"width": source.width, "height": source.height, "bands": source.count},
status_code=502,
)
image = source.read()
profile = source.profile.copy()
profile.update(
driver="GTiff",
crs="EPSG:31370",
transform=from_bounds(*prepared["bbox_epsg31370"], source.width, source.height),
compress="deflate",
tiled=False,
)
with MemoryFile() as output_memory:
with output_memory.open(**profile) as output:
output.write(image)
output.update_tags(
source="Digitaal Vlaanderen OMWRGBMRVL WMS Ortho layer",
source_url=prepared["request_url"],
attribution=OrthophotoAcquisitionService.ATTRIBUTION,
acquisition="explicit_bounded_map_selection",
)
return output_memory.read()
except AppError:
raise
except Exception as exc:
raise AppError(
code="ORTHOPHOTO_PROVIDER_INVALID_RESPONSE",
message="The official orthophoto response is not a readable GeoTIFF",
details={"reason": str(exc)},
status_code=502,
) from exc
@staticmethod
def acquire(
db,
project_id: UUID,
payload: OrthophotoAcquireRequest,
*,
settings: Settings | None = None,
opener: Callable[..., Any] | None = None,
) -> dict[str, Any]:
resolved_settings = settings or get_settings()
if not resolved_settings.orthophoto_enabled:
raise AppError(code="ORTHOPHOTO_NOT_CONFIGURED", message="Official orthophoto acquisition is disabled", status_code=503)
prepared = OrthophotoAcquisitionService._prepared_request(payload, resolved_settings)
OrthophotoAcquisitionService._validate_area_scope(db, project_id, payload.area_id, prepared["bbox_epsg4326"])
filename = f"orthofoto_selectie_{prepared['request_hash'][:12]}.tif"
cached = None if payload.force_refresh else OrthophotoAcquisitionService._cached_dataset(db, project_id, filename, resolved_settings)
if cached is not None:
return OrthophotoAcquisitionResult(
output_dataset_id=cached.id,
reused=True,
provider=OrthophotoAcquisitionService.PROVIDER,
layer=resolved_settings.orthophoto_wms_layer,
width=prepared["width"],
height=prepared["height"],
resolution_m=resolved_settings.orthophoto_resolution_m,
bbox_epsg4326=prepared["bbox_epsg4326"],
bbox_epsg31370=prepared["bbox_epsg31370"],
attribution=OrthophotoAcquisitionService.ATTRIBUTION,
limitation_message=OrthophotoAcquisitionService.LIMITATION,
).model_dump(mode="json")
raw_content, response_content_type = OrthophotoAcquisitionService._fetch(prepared["request_url"], resolved_settings, opener)
geotiff_content = OrthophotoAcquisitionService._georeference_tiff(raw_content, prepared)
acquired_at = datetime.now(UTC)
dataset = DatasetService.import_raster_bytes(
db,
project_id=project_id,
area_id=payload.area_id,
filename=filename,
content=geotiff_content,
source="Digitaal Vlaanderen OMWRGBMRVL WMS",
source_name=OrthophotoAcquisitionService.PROVIDER,
source_version=f"most_recent_at_{acquired_at.date().isoformat()}",
content_type="image/tiff",
source_metadata={
"provider": OrthophotoAcquisitionService.PROVIDER,
"service": "WMS",
"service_version": "1.3.0",
"layer": resolved_settings.orthophoto_wms_layer,
"catalog_url": OrthophotoAcquisitionService.CATALOG_URL,
"attribution": OrthophotoAcquisitionService.ATTRIBUTION,
"license_note": "Gebruik volgens het gebruiksrecht geografische webdiensten van Digitaal Vlaanderen.",
},
provenance_metadata={
"acquisition": "explicit_bounded_map_selection",
"acquired_at": acquired_at.isoformat(),
"request_hash": prepared["request_hash"],
"request_url": prepared["request_url"],
"response_content_type": response_content_type,
"bbox_epsg4326": prepared["bbox_epsg4326"],
"bbox_epsg31370": prepared["bbox_epsg31370"],
"width": prepared["width"],
"height": prepared["height"],
"resolution_m": resolved_settings.orthophoto_resolution_m,
"limitation_message": OrthophotoAcquisitionService.LIMITATION,
},
)
return OrthophotoAcquisitionResult(
output_dataset_id=dataset.id,
reused=False,
provider=OrthophotoAcquisitionService.PROVIDER,
layer=resolved_settings.orthophoto_wms_layer,
width=prepared["width"],
height=prepared["height"],
resolution_m=resolved_settings.orthophoto_resolution_m,
bbox_epsg4326=prepared["bbox_epsg4326"],
bbox_epsg31370=prepared["bbox_epsg31370"],
attribution=OrthophotoAcquisitionService.ATTRIBUTION,
limitation_message=OrthophotoAcquisitionService.LIMITATION,
).model_dump(mode="json")
@@ -21,7 +21,9 @@ def test_guided_detection_reuses_canonical_raster_and_detection_apis() -> None:
assert "tile_size: 512" in hook
assert "overlap: 64" in hook
assert "detectionApi.getYoloPreflight" in hook
assert "await executeDetection(selectedProjectId, datasetId, manifestPath)" in hook
assert "const result = await executeDetection(" in hook
assert "effectiveModelId" in hook
assert "effectiveModelAssetId" in hook
assert "await loadDetectionResults(result.analysis_run_id)" in hook
assert "model_id: selectedDetectionModelId" in hook
assert "model_asset_id: selectedModelAssetId || null" in hook
@@ -0,0 +1,333 @@
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
import numpy as np
import pytest
import rasterio
from fastapi.testclient import TestClient
from geoalchemy2.shape import from_shape
from pyproj import Transformer
from rasterio.io import MemoryFile
from rasterio.transform import from_origin
from shapely.geometry import MultiPolygon, box
from app.core.config import Settings
from app.core.errors import AppError
from app.db.session import get_db
from app.main import app
from app.models import Area, Dataset, DatasetVersion, Job, Project
from app.schemas.orthophoto import OrthophotoAcquireRequest
from app.services.orthophoto_acquisition_service import OrthophotoAcquisitionService
ROOT = Path(__file__).resolve().parents[2]
class FakeSession:
def __init__(self, rows: dict[tuple[type, object], object] | None = None, query_result=None):
self.rows = rows or {}
self.query_result = query_result
self.added: list[object] = []
def get(self, model, row_id):
row = self.rows.get((model, row_id))
if row is not None:
return row
return next((item for item in self.added if isinstance(item, model) and item.id == row_id), None)
def add(self, row):
self.added.append(row)
def commit(self):
return None
def rollback(self):
return None
def refresh(self, row):
return row
def query(self, _model):
return FakeQuery(self.query_result)
class FakeQuery:
def __init__(self, result):
self.result = result
def filter(self, *_args):
return self
def order_by(self, *_args):
return self
def first(self):
return self.result
class FakeImageResponse:
def __init__(self, content: bytes):
self.content = content
self.headers = {
"Content-Type": "image/tiff",
"Content-Length": str(len(content)),
}
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def read(self, limit: int) -> bytes:
return self.content[:limit]
def _selection_payload(*, side_m: float = 512.0, force_refresh: bool = True, area_id=None) -> OrthophotoAcquireRequest:
west, south = 199_000.0, 210_000.0
transformer = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True)
min_lon, min_lat = transformer.transform(west, south)
max_lon, max_lat = transformer.transform(west + side_m, south + side_m)
return OrthophotoAcquireRequest(
bbox={
"min_x": min_lon,
"min_y": min_lat,
"max_x": max_lon,
"max_y": max_lat,
"crs": "EPSG:4326",
},
area_id=area_id,
force_refresh=force_refresh,
)
def _source_tiff(width: int, height: int) -> bytes:
pixels = np.zeros((3, height, width), dtype=np.uint8)
pixels[0, :, :] = 92
pixels[1, :, :] = 126
pixels[2, :, :] = 84
with MemoryFile() as memory:
with memory.open(
driver="GTiff",
width=width,
height=height,
count=3,
dtype="uint8",
transform=from_origin(0, height, 1, 1),
) as output:
output.write(pixels)
return memory.read()
def test_orthophoto_request_is_bounded_and_uses_official_wms_contract() -> None:
settings = Settings(_env_file=None)
prepared = OrthophotoAcquisitionService._prepared_request(_selection_payload(), settings)
# A north-up WGS84 rectangle becomes slightly wider after the bounded
# EPSG:31370 transform; the service must still keep it near the requested scale.
assert 500 <= prepared["width"] <= 540
assert 500 <= prepared["height"] <= 540
assert prepared["params"]["CRS"] == "EPSG:31370"
assert prepared["params"]["LAYERS"] == "Ortho"
assert "geo.api.vlaanderen.be/OMWRGBMRVL/wms" in prepared["request_url"]
assert len(prepared["request_hash"]) == 64
@pytest.mark.parametrize(
("side_m", "expected_code"),
[(64.0, "ORTHOPHOTO_SELECTION_TOO_SMALL"), (1_200.0, "ORTHOPHOTO_SELECTION_TOO_LARGE")],
)
def test_orthophoto_request_rejects_unsafe_selection_sizes(side_m: float, expected_code: str) -> None:
with pytest.raises(AppError) as exc_info:
OrthophotoAcquisitionService._prepared_request(_selection_payload(side_m=side_m), Settings(_env_file=None))
assert exc_info.value.code == expected_code
def test_orthophoto_acquisition_persists_georeferenced_raster_and_provenance(tmp_path) -> None:
project_id = uuid4()
area_id = uuid4()
payload = _selection_payload(area_id=area_id)
area_geometry = MultiPolygon(
[
box(
payload.bbox.min_x - 0.01,
payload.bbox.min_y - 0.01,
payload.bbox.max_x + 0.01,
payload.bbox.max_y + 0.01,
)
]
)
db = FakeSession(
{
(Project, project_id): Project(id=project_id, name="Mol operationele werkruimte"),
(Area, area_id): Area(
id=area_id,
project_id=project_id,
name="Gemeente Mol",
geometry=from_shape(area_geometry, srid=4326),
),
}
)
settings = Settings(_env_file=None, storage_root=str(tmp_path), orthophoto_resolution_m=1.0)
prepared = OrthophotoAcquisitionService._prepared_request(payload, settings)
response = FakeImageResponse(_source_tiff(prepared["width"], prepared["height"]))
result = OrthophotoAcquisitionService.acquire(
db,
project_id,
payload,
settings=settings,
opener=lambda *_args, **_kwargs: response,
)
datasets = [row for row in db.added if isinstance(row, Dataset)]
versions = [row for row in db.added if isinstance(row, DatasetVersion)]
assert len(datasets) == 1
assert len(versions) == 1
dataset = datasets[0]
assert result["output_dataset_id"] == str(dataset.id)
assert result["reused"] is False
assert dataset.project_id == project_id
assert dataset.area_id == area_id
assert dataset.dataset_type == "raster"
assert dataset.dataset_role == "source"
assert dataset.source_name == "digitaal_vlaanderen_orthophoto"
assert dataset.crs == "EPSG:31370"
assert dataset.provenance_metadata["acquisition"] == "explicit_bounded_map_selection"
assert dataset.provenance_metadata["request_hash"] == prepared["request_hash"]
assert dataset.source_metadata["attribution"].startswith("Bron: Orthofotomozaiek Vlaanderen")
assert dataset.storage_path is not None
with rasterio.open(dataset.storage_path) as stored:
assert stored.crs.to_epsg() == 31370
assert stored.count == 3
assert stored.width == prepared["width"]
assert stored.height == prepared["height"]
assert list(stored.bounds) == pytest.approx(prepared["bbox_epsg31370"], abs=0.01)
def test_orthophoto_acquisition_rejects_selection_outside_persisted_area() -> None:
project_id = uuid4()
area_id = uuid4()
payload = _selection_payload(area_id=area_id)
db = FakeSession(
{
(Project, project_id): Project(id=project_id, name="Mol"),
(Area, area_id): Area(
id=area_id,
project_id=project_id,
name="Unrelated area",
geometry=from_shape(MultiPolygon([box(3.0, 50.0, 3.1, 50.1)]), srid=4326),
),
}
)
with pytest.raises(AppError) as exc_info:
OrthophotoAcquisitionService.acquire(db, project_id, payload, settings=Settings(_env_file=None))
assert exc_info.value.code == "ORTHOPHOTO_SELECTION_OUTSIDE_AREA"
def test_orthophoto_acquisition_reuses_fresh_exact_request_without_provider_call(tmp_path) -> None:
project_id = uuid4()
payload = _selection_payload(force_refresh=False)
prepared = OrthophotoAcquisitionService._prepared_request(payload, Settings(_env_file=None))
stored_path = tmp_path / "cached.tif"
stored_path.write_bytes(b"persisted")
cached = Dataset(
id=uuid4(),
project_id=project_id,
name=f"orthofoto_selectie_{prepared['request_hash'][:12]}.tif",
dataset_type="raster",
source="Digitaal Vlaanderen",
source_name="digitaal_vlaanderen_orthophoto",
status="ready",
storage_path=str(stored_path),
imported_at=datetime.now(UTC),
)
db = FakeSession({(Project, project_id): Project(id=project_id, name="Mol")}, query_result=cached)
result = OrthophotoAcquisitionService.acquire(
db,
project_id,
payload,
settings=Settings(_env_file=None),
opener=lambda *_args, **_kwargs: pytest.fail("fresh cached request must not call the provider"),
)
assert result["output_dataset_id"] == str(cached.id)
assert result["reused"] is True
assert db.added == []
def test_orthophoto_provider_rejects_non_image_response() -> None:
response = FakeImageResponse(b"<ServiceException>invalid layer</ServiceException>")
response.headers["Content-Type"] = "text/xml"
with pytest.raises(AppError) as exc_info:
OrthophotoAcquisitionService._fetch(
"https://geo.api.vlaanderen.be/OMWRGBMRVL/wms",
Settings(_env_file=None),
opener=lambda *_args, **_kwargs: response,
)
assert exc_info.value.code == "ORTHOPHOTO_PROVIDER_INVALID_RESPONSE"
def test_orthophoto_endpoint_returns_canonical_job_envelope(monkeypatch) -> None:
project_id = uuid4()
output_dataset_id = uuid4()
db = FakeSession()
monkeypatch.setattr(
OrthophotoAcquisitionService,
"acquire",
lambda *_args, **_kwargs: {
"output_dataset_id": str(output_dataset_id),
"reused": False,
"provider": "digitaal_vlaanderen_orthophoto",
},
)
payload = _selection_payload(force_refresh=False).model_dump(mode="json")
app.dependency_overrides[get_db] = lambda: db
try:
response = TestClient(app).post(f"/api/v1/projects/{project_id}/datasets/orthophoto/acquire", json=payload)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert set(body) == {"data"}
assert body["data"]["status"] == "success"
assert body["data"]["job_type"] == "raster.orthophoto.acquire"
assert body["data"]["output_dataset_id"] == str(output_dataset_id)
assert body["data"]["result_json"]["provider"] == "digitaal_vlaanderen_orthophoto"
assert any(isinstance(row, Job) for row in db.added)
def test_frontend_connects_map_selection_to_existing_detection_and_qa_flows() -> None:
app_source = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
hook_source = (ROOT / "frontend" / "src" / "hooks" / "useMapOrthophotoAnalysis.ts").read_text(encoding="utf-8")
map_source = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8")
assert "useMapOrthophotoAnalysis" in app_source
assert "onRunOrthophotoAnalysis={mapOrthophotoAnalysis.run}" in app_source
assert "datasetsApi.acquireOrthophoto" in hook_source
assert "prepareAndRunDetection(datasetId)" in hook_source
assert "compareDetectionRunWithReference" in hook_source
assert "compareDetectionRunWithReference(analysisRunId, referenceDatasetId, false)" in app_source
assert "Herken gebouwen" in map_source
assert "Officieel luchtbeeld, lokaal AI-model" in map_source
def test_unraid_runtime_exposes_bounded_orthophoto_settings() -> None:
compose = (ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8")
runner = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8")
template = (ROOT / "deploy" / "unraid" / "geointel-unraid-template.xml").read_text(encoding="utf-8")
for name in ("ORTHOPHOTO_ENABLED", "ORTHOPHOTO_WMS_URL", "ORTHOPHOTO_RESOLUTION_M", "ORTHOPHOTO_MAX_SIDE_M"):
assert name in compose
assert name in runner
assert name in template
@@ -30,4 +30,8 @@
<Config Name="Postgres Password" Target="GEOINTEL_POSTGRES_PASSWORD" Default="change-me-before-shared-use" Mode="" Description="Embedded PostGIS database password. Change before shared use." Type="Variable" Display="advanced" Required="true" Mask="true">change-me-before-shared-use</Config>
<Config Name="CORS Origins" Target="GEOINTEL_CORS_ORIGINS" Default="http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202" Mode="" Description="Comma-separated browser origins allowed to call the backend directly." Type="Variable" Display="advanced" Required="false" Mask="false">http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202</Config>
<Config Name="Max Upload MB" Target="GEOINTEL_MAX_UPLOAD_MB" Default="500" Mode="" Description="Maximum upload size in MiB enforced by the backend settings." Type="Variable" Display="advanced" Required="true" Mask="false">500</Config>
<Config Name="Official Orthophoto Acquisition" Target="ORTHOPHOTO_ENABLED" Default="true" Mode="" Description="Allow explicit bounded map selections to request the official Digitaal Vlaanderen orthophoto WMS." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
<Config Name="Orthophoto WMS URL" Target="ORTHOPHOTO_WMS_URL" Default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms" Mode="" Description="Official Digitaal Vlaanderen most-recent winter orthophoto WMS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geo.api.vlaanderen.be/OMWRGBMRVL/wms</Config>
<Config Name="Orthophoto Resolution (m)" Target="ORTHOPHOTO_RESOLUTION_M" Default="1.0" Mode="" Description="Requested analysis sampling in metres per pixel. Keep at 1.0 for the active building model profile." Type="Variable" Display="advanced" Required="true" Mask="false">1.0</Config>
<Config Name="Orthophoto Maximum Side (m)" Target="ORTHOPHOTO_MAX_SIDE_M" Default="1024" Mode="" Description="Safety limit for each selected rectangle side before external acquisition and local inference." Type="Variable" Display="advanced" Required="true" Mask="false">1024</Config>
</Container>
+9
View File
@@ -24,6 +24,15 @@ GEOINTEL_CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202,http://192.168
# Upload guard in MiB.
GEOINTEL_MAX_UPLOAD_MB=500
# Explicit, bounded acquisition from the official Digitaal Vlaanderen WMS.
ORTHOPHOTO_ENABLED=true
ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms
ORTHOPHOTO_WMS_LAYER=Ortho
ORTHOPHOTO_RESOLUTION_M=1.0
ORTHOPHOTO_MIN_SIDE_M=128
ORTHOPHOTO_MAX_SIDE_M=1024
ORTHOPHOTO_CACHE_TTL_HOURS=24
# Optional configured-YOLO runtime. Keep disabled unless a local model is mounted.
GEOINTEL_INSTALL_AI=false
YOLO_ENABLED=false
+14
View File
@@ -20,6 +20,13 @@ GEOINTEL_POSTGRES_USER="${GEOINTEL_POSTGRES_USER:-geointel}"
GEOINTEL_POSTGRES_PASSWORD="${GEOINTEL_POSTGRES_PASSWORD:-geointel}"
GEOINTEL_CORS_ORIGINS="${GEOINTEL_CORS_ORIGINS:-http://localhost:${GEOINTEL_FRONTEND_PORT},http://127.0.0.1:${GEOINTEL_FRONTEND_PORT},http://192.168.10.150:${GEOINTEL_FRONTEND_PORT}}"
GEOINTEL_MAX_UPLOAD_MB="${GEOINTEL_MAX_UPLOAD_MB:-500}"
ORTHOPHOTO_ENABLED="${ORTHOPHOTO_ENABLED:-true}"
ORTHOPHOTO_WMS_URL="${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms}"
ORTHOPHOTO_WMS_LAYER="${ORTHOPHOTO_WMS_LAYER:-Ortho}"
ORTHOPHOTO_RESOLUTION_M="${ORTHOPHOTO_RESOLUTION_M:-1.0}"
ORTHOPHOTO_MIN_SIDE_M="${ORTHOPHOTO_MIN_SIDE_M:-128}"
ORTHOPHOTO_MAX_SIDE_M="${ORTHOPHOTO_MAX_SIDE_M:-1024}"
ORTHOPHOTO_CACHE_TTL_HOURS="${ORTHOPHOTO_CACHE_TTL_HOURS:-24}"
YOLO_ENABLED="${YOLO_ENABLED:-false}"
YOLO_MODELS_DIR="${YOLO_MODELS_DIR:-/app/models}"
YOLO_MODEL_PATH="${YOLO_MODEL_PATH:-}"
@@ -82,6 +89,13 @@ docker run -d \
-e GEOINTEL_STORAGE_ROOT=/app/storage \
-e GEOINTEL_CORS_ORIGINS="$GEOINTEL_CORS_ORIGINS" \
-e GEOINTEL_MAX_UPLOAD_MB="$GEOINTEL_MAX_UPLOAD_MB" \
-e ORTHOPHOTO_ENABLED="$ORTHOPHOTO_ENABLED" \
-e ORTHOPHOTO_WMS_URL="$ORTHOPHOTO_WMS_URL" \
-e ORTHOPHOTO_WMS_LAYER="$ORTHOPHOTO_WMS_LAYER" \
-e ORTHOPHOTO_RESOLUTION_M="$ORTHOPHOTO_RESOLUTION_M" \
-e ORTHOPHOTO_MIN_SIDE_M="$ORTHOPHOTO_MIN_SIDE_M" \
-e ORTHOPHOTO_MAX_SIDE_M="$ORTHOPHOTO_MAX_SIDE_M" \
-e ORTHOPHOTO_CACHE_TTL_HOURS="$ORTHOPHOTO_CACHE_TTL_HOURS" \
-e YOLO_ENABLED="$YOLO_ENABLED" \
-e YOLO_MODELS_DIR="$YOLO_MODELS_DIR" \
-e YOLO_MODEL_PATH="$YOLO_MODEL_PATH" \
+7
View File
@@ -18,6 +18,13 @@ services:
GEOINTEL_STORAGE_ROOT: /app/storage
GEOINTEL_CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202}
GEOINTEL_MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500}
ORTHOPHOTO_ENABLED: ${ORTHOPHOTO_ENABLED:-true}
ORTHOPHOTO_WMS_URL: ${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms}
ORTHOPHOTO_WMS_LAYER: ${ORTHOPHOTO_WMS_LAYER:-Ortho}
ORTHOPHOTO_RESOLUTION_M: ${ORTHOPHOTO_RESOLUTION_M:-1.0}
ORTHOPHOTO_MIN_SIDE_M: ${ORTHOPHOTO_MIN_SIDE_M:-128}
ORTHOPHOTO_MAX_SIDE_M: ${ORTHOPHOTO_MAX_SIDE_M:-1024}
ORTHOPHOTO_CACHE_TTL_HOURS: ${ORTHOPHOTO_CACHE_TTL_HOURS:-24}
YOLO_ENABLED: ${YOLO_ENABLED:-false}
YOLO_MODELS_DIR: ${YOLO_MODELS_DIR:-/app/models}
YOLO_MODEL_PATH: ${YOLO_MODEL_PATH:-}
+7
View File
@@ -23,6 +23,13 @@ services:
STORAGE_ROOT: /app/storage
CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202}
MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500}
ORTHOPHOTO_ENABLED: ${ORTHOPHOTO_ENABLED:-true}
ORTHOPHOTO_WMS_URL: ${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms}
ORTHOPHOTO_WMS_LAYER: ${ORTHOPHOTO_WMS_LAYER:-Ortho}
ORTHOPHOTO_RESOLUTION_M: ${ORTHOPHOTO_RESOLUTION_M:-1.0}
ORTHOPHOTO_MIN_SIDE_M: ${ORTHOPHOTO_MIN_SIDE_M:-128}
ORTHOPHOTO_MAX_SIDE_M: ${ORTHOPHOTO_MAX_SIDE_M:-1024}
ORTHOPHOTO_CACHE_TTL_HOURS: ${ORTHOPHOTO_CACHE_TTL_HOURS:-24}
YOLO_ENABLED: ${YOLO_ENABLED:-false}
YOLO_MODELS_DIR: ${YOLO_MODELS_DIR:-/app/models}
YOLO_MODEL_PATH: ${YOLO_MODEL_PATH:-}
+14
View File
@@ -199,6 +199,20 @@ detector fixtures or download weights. A zero detection count is acceptable on
the synthetic demo raster; production usefulness still requires validation on
real georeferenced orthophotos and reference vectors.
### Map-driven building analysis
The primary map can hand an explicit EPSG:4326 rectangle to the bounded
orthophoto acquisition endpoint. Its canonical raster Dataset then uses the
unchanged configured-YOLO pipeline: 512 px tiles with 64 px overlap, preflight,
local inference, Job + AnalysisRun + Detection persistence and persisted
GeoJSON. When a ready GRB buildings reference Dataset exists, the same action
launches existing detection QA and persists QualityCheck and Metric rows.
This flow does not download a model, bypass the model registry, write directly
to Detection/vector tables or present AI boxes as official building truth.
The 1 m request sampling is an operational model profile; provenance retains
the official orthophoto source and latest-mosaic limitation.
### Real-data detection and QA validation
The real operational validation path uses operator-provided files rather than
+30
View File
@@ -191,6 +191,36 @@ Response: `DatasetRead` with extracted metadata if supported.
Vector uploads remain stored as original files and are also persisted into `vector_features` as queryable PostGIS state.
### POST `/api/v1/projects/{project_id}/datasets/orthophoto/acquire`
Explicitly acquire a bounded most-recent winter orthophoto selection from the
official Digitaal Vlaanderen `OMWRGBMRVL` WMS `Ortho` layer.
```json
{
"bbox": {"min_x": 5.10, "min_y": 51.17, "max_x": 5.11, "max_y": 51.18, "crs": "EPSG:4326"},
"area_id": "optional-project-area-uuid",
"force_refresh": false
}
```
The canonical envelope contains a synchronous Job. Its `output_dataset_id`
identifies the raster Dataset; `result_json` contains provider, layer, pixel
dimensions, EPSG:4326/EPSG:31370 bounds, sampling resolution, attribution,
cache reuse and limitation text.
Safety contract:
- every side must measure between 128 m and 1,024 m in EPSG:31370;
- an optional `area_id` must belong to the project and cover at least 99% of
the rectangle;
- defaults are 1 m/pixel, a 32 MiB response limit and 24-hour exact-request
reuse;
- WMS bytes are georeferenced to EPSG:31370 and persisted only through
`DatasetService`; no fetch runs on startup;
- this is the latest mosaic available at request time, not a historical
observation date for every pixel.
### GET `/api/v1/projects/{project_id}/datasets`
List datasets.
+27
View File
@@ -8076,3 +8076,30 @@ Live operational proof:
Next:
- Add bounded operator-triggered orthophoto acquisition from a drawn map rectangle, then hand that raster to this proven guided pipeline. Keep external acquisition out of browser/startup paths and retain explicit source licensing/provenance.
## Sprint 196 - Map-driven official orthophoto analysis (2026-07-15)
Implemented:
- Added explicit project-scoped acquisition for the official Digitaal
Vlaanderen `OMWRGBMRVL` WMS `Ortho` layer.
- Enforced EPSG:4326 input, EPSG:31370 metric bounds, 128-1,024 m side limits,
optional persisted-Area coverage, 1 m sampling, timeout/response limits and
24-hour exact-request reuse.
- Georeferenced the RGB TIFF and persisted it, its DatasetVersion, source,
request URL/hash, bounds, attribution and latest-mosaic limitation only
through DatasetService.
- Added one simple map action chaining acquisition, existing raster tiling,
configured-YOLO inference, Detection persistence and existing GRB detection
QA before showing the persisted result on MapLibre.
- Exposed orthophoto settings through Docker Compose, the all-in-one Unraid
runtime script and DockerMan template.
Validation before deployment:
- Focused service/API tests passed, including in-memory TIFF georeferencing,
persistence and the canonical Job envelope.
- Frontend TypeScript typecheck and production build passed.
Next:
- Deploy to Tower, execute one bounded Mol rectangle through the real WMS,
local model and GRB QA, and verify persistence plus browser state before
accepting the flow as operational.
+24
View File
@@ -1,5 +1,29 @@
# Data Sources
## Orthofotomozaiek Vlaanderen - meest recent
- Naam: Orthofotomozaiek middenschalig, winteropnamen, kleur, meest recent
- Beheerder: Digitaal Vlaanderen
- Type: RGB-raster via WMS
- Operationele laag: `OMWRGBMRVL` / `Ortho`
- CRS bij opslag: `EPSG:31370`
- Gebruik: expliciet begrensde luchtbeeldanalyse en lokale gebouwdetectie
- Autoriteit: officiele beeldbron; AI-detecties zelf zijn niet-autoritatief
De hoofdkaart kan na een getekende rechthoek een expliciete, begrensde WMS
GetMap-aanvraag uitvoeren. De backend aanvaardt alleen rechthoeken van 128 tot
1.024 meter per zijde, samplet standaard op 1 m/pixel voor het actieve lokale
modelprofiel en bewaart het gegeorefereerde GeoTIFF via DatasetService met
aanvraag, checksum, bron, attributie en beperking. Een identieke selectie mag
24 uur worden hergebruikt. Er is geen startupfetch en de browser bevraagt de
externe WMS nooit rechtstreeks.
De bron is de meest recente samengestelde wintermozaiek op het moment van de
aanvraag. GeoIntel verzint geen historische pixelopnamedatum. Bronnen:
- https://www.vlaanderen.be/datavindplaats/catalogus/orthofotomozaiek-middenschalig-winteropnamen-kleur-meest-recent-vlaanderen
- https://www.vlaanderen.be/digitaal-vlaanderen/onze-diensten-en-platformen/luchtopnamen/gebruik-orthofotomozaieken
Dit document verzamelt concrete databronnen voor GeoIntel Kempen.
## GRB — Basiskaart Vlaanderen
+1
View File
@@ -15,6 +15,7 @@
- [x] Reduce end-user noise by moving technical projects, source metadata, QA evidence, provider internals and model diagnostics behind explicit advanced disclosures.
- [x] Default Detection Lab to the configured local YOLO asset and present measured model quality and control requirements honestly.
- [x] Extend official population and land-use time series from Mol to the approved 28-municipality regional scope.
- [x] Connect a drawn rectangle to bounded official orthophoto acquisition, local configured-YOLO detection and persisted GRB QA.
This file now starts with the current implementation status. Older preparation/backlog sections are preserved below as historical planning context and should not be treated as the live sprint board without checking `docs/CODEX_EXECUTION_LOG.md`.
+7
View File
@@ -27,6 +27,13 @@ choose-theme, draw-area, read-result flow.
The primary workflow is deliberately short: choose a municipality or the complete region, choose a data theme, drag a rectangle on the MapLibre map and read the resulting PostGIS evidence. Releasing the drag runs the active theme query and every other available theme query for the same EPSG:4326 bbox. The result panel shows selection area, exact intersection totals, active-theme density, source identity and bounded feature properties. Map rendering remains capped at 1,000 features while `total_feature_count` reports the exact database count.
For a rectangle in `Laatste toestand`, `Herken gebouwen` runs the complete
operational image path without opening the technical AI screen: bounded
official orthophoto acquisition, raster persistence, safe tiling, the active
local YOLO model, Detection persistence and automatic QA against ready GRB
buildings. The panel shows all stages and errors; successful detections open as
an explicit AI-result overlay. Rectangles must be 128-1,024 m per side.
The theme catalog currently recognizes buildings, population, forest/green, water, roads and parcels from dataset names and canonical `reference_layer_name` metadata. A theme is enabled only when a ready persisted vector dataset exists; otherwise it states `Bron nog niet ingeladen`. This prevents missing population or land-cover sources from appearing as zero-valued observations. The previous technical Map workspace remains available through `Geavanceerde werkbank` for derived datasets, QA/QC evidence and export operations.
The workbench uses a task-based shell instead of a single long panel stack. `App.tsx` still owns shared orchestration state, but Map is the default product entry and Overview, Data, QA/QC, AI Labs, Exports and System remain secondary workspaces with a persistent top context bar and an optional selection-detail drawer.
+21
View File
@@ -24,6 +24,7 @@ import { useMapSelectionDataset } from './hooks/useMapSelectionDataset'
import { useMapSelectionQa } from './hooks/useMapSelectionQa'
import { useMapWorkspaceState } from './hooks/useMapWorkspaceState'
import { useMapSelectionExtract } from './hooks/useMapSelectionExtract'
import { useMapOrthophotoAnalysis } from './hooks/useMapOrthophotoAnalysis'
import { useProviderCapabilities } from './hooks/useProviderCapabilities'
import { useProjectWorkspace } from './hooks/useProjectWorkspace'
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
@@ -278,6 +279,7 @@ function App(): JSX.Element {
runDetection,
uploadDetectionRaster,
prepareAndRunDetection,
compareDetectionRunWithReference,
runDetectionQa,
runDetectionCalibration,
applyDetectionOperatorProfile,
@@ -468,6 +470,20 @@ function App(): JSX.Element {
loadQualityChecks,
loadProjectData,
})
const mapOrthophotoAnalysis = useMapOrthophotoAnalysis({
selectedProjectId,
selectedAreaId: selectedMapAreaId,
datasets,
loadProjectData,
prepareAndRunDetection: (datasetId) => prepareAndRunDetection(datasetId, 'yolo-configured'),
compareDetectionRunWithReference: (analysisRunId, referenceDatasetId) =>
compareDetectionRunWithReference(analysisRunId, referenceDatasetId, false),
onAnalysisReady: () => {
setMapContentMode('analysis')
setMapLayerVisible(true)
setActiveWorkspace('map')
},
})
const openMapSelectionQualityEvidence = () => {
setActiveWorkspace('analysis')
}
@@ -990,6 +1006,10 @@ function App(): JSX.Element {
mapSelectionQaError={mapSelectionQaError}
mapSelectionQaResult={mapSelectionQaResult}
latestMapSelectionQualityCheckId={latestMapSelectionQualityCheckId}
orthophotoAnalysisStage={mapOrthophotoAnalysis.stage}
orthophotoAnalysisStatus={mapOrthophotoAnalysis.status}
orthophotoAnalysisError={mapOrthophotoAnalysis.error}
orthophotoAnalysisRunning={mapOrthophotoAnalysis.running}
availableMapDatasets={availableMapDatasets}
selectedMapDatasetId={selectedDataset && isVectorDatasetType(selectedDataset.dataset_type) ? selectedDataset.id : ''}
selectedFeature={selectedMapFeature}
@@ -1010,6 +1030,7 @@ function App(): JSX.Element {
onSelectMapQaReferenceDataset={setSelectedMapQaReferenceDatasetId}
onRunMapSelectionQa={runMapSelectionQa}
onOpenMapSelectionQualityEvidence={openMapSelectionQualityEvidence}
onRunOrthophotoAnalysis={mapOrthophotoAnalysis.run}
onClearQualityEvidence={clearQualityEvidenceGeoJson}
/>
) : null}
@@ -441,6 +441,10 @@ interface MapWorkspaceProps {
mapSelectionQaError: string | null
mapSelectionQaResult: QaComparisonResult | null
latestMapSelectionQualityCheckId: string | null
orthophotoAnalysisStage: 'idle' | 'acquiring' | 'detecting' | 'validating' | 'complete' | 'failed'
orthophotoAnalysisStatus: string
orthophotoAnalysisError: string | null
orthophotoAnalysisRunning: boolean
availableMapDatasets: DatasetCreateResponse[]
selectedMapDatasetId: string
onSelectMapArea: (areaId: string) => void
@@ -460,6 +464,7 @@ interface MapWorkspaceProps {
onSelectMapQaReferenceDataset: (datasetId: string) => void
onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise<QaComparisonResult | null>
onOpenMapSelectionQualityEvidence: () => void
onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise<boolean>
onClearQualityEvidence?: () => void
}
@@ -509,6 +514,10 @@ export function MapWorkspace({
mapSelectionQaError,
mapSelectionQaResult,
latestMapSelectionQualityCheckId,
orthophotoAnalysisStage,
orthophotoAnalysisStatus,
orthophotoAnalysisError,
orthophotoAnalysisRunning,
availableMapDatasets,
selectedMapDatasetId,
onSelectMapArea,
@@ -528,6 +537,7 @@ export function MapWorkspace({
onSelectMapQaReferenceDataset,
onRunMapSelectionQa,
onOpenMapSelectionQualityEvidence,
onRunOrthophotoAnalysis,
onClearQualityEvidence,
}: MapWorkspaceProps): JSX.Element {
const [advancedMode, setAdvancedMode] = useState(false)
@@ -1159,6 +1169,34 @@ export function MapWorkspace({
</div>
</div>
{analysisMode === 'current' && mapSelectionBbox ? (
<div className={`geo-image-analysis geo-image-analysis-${orthophotoAnalysisStage}`}>
<div>
<span>Beeldanalyse</span>
<strong>Gebouwen herkennen op luchtbeeld</strong>
<small>Officieel luchtbeeld, lokaal AI-model en automatische controle met GRB.</small>
</div>
<button
className="primary-action"
disabled={orthophotoAnalysisRunning}
type="button"
onClick={() => void onRunOrthophotoAnalysis(mapSelectionBbox)}
>
{orthophotoAnalysisStage === 'acquiring'
? 'Luchtbeeld ophalen...'
: orthophotoAnalysisStage === 'detecting'
? 'Gebouwen herkennen...'
: orthophotoAnalysisStage === 'validating'
? 'Controleren...'
: orthophotoAnalysisStage === 'complete'
? 'Opnieuw analyseren'
: 'Herken gebouwen'}
</button>
{orthophotoAnalysisStatus ? <p role="status">{orthophotoAnalysisStatus}</p> : null}
{orthophotoAnalysisError ? <p className="error" role="alert">{orthophotoAnalysisError}</p> : null}
</div>
) : null}
{!mapSelectionBbox ? (
<div className="geo-results-empty">
<strong>Nog geen gebied geselecteerd</strong>
+56 -25
View File
@@ -229,12 +229,18 @@ export function useDetectionWorkflow({
}
}
const executeDetection = async (projectId: string, datasetId: string, manifestPath: string | null) => {
const executeDetection = async (
projectId: string,
datasetId: string,
manifestPath: string | null,
modelId = selectedDetectionModelId,
modelAssetId = selectedModelAssetId,
) => {
const result = await detectionApi.run({
project_id: projectId,
dataset_id: datasetId,
model_id: selectedDetectionModelId,
model_asset_id: selectedModelAssetId || null,
model_id: modelId,
model_asset_id: modelAssetId || null,
confidence_threshold: detectionConfidenceThreshold,
tile_manifest_path: manifestPath,
parameters_json: {},
@@ -303,24 +309,31 @@ export function useDetectionWorkflow({
}
}
const prepareAndRunDetection = async (): Promise<boolean> => {
const prepareAndRunDetection = async (
datasetIdOverride?: string,
modelIdOverride?: string,
): Promise<DetectionRunResponse | null> => {
if (!selectedProjectId) {
setDetectionRunError('De regionale werkruimte is nog niet geladen')
return false
return null
}
const datasetId = selectedDetectionDatasetId || rasterDatasets[0]?.id
const datasetId = datasetIdOverride || selectedDetectionDatasetId || rasterDatasets[0]?.id
if (!datasetId) {
setDetectionRunError('Kies of voeg eerst een gegeorefereerd luchtbeeld toe')
return false
return null
}
const selectedModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId)
if (!selectedModel?.configured || selectedDetectionModelId === 'manual-fixture-detector') {
const effectiveModelId = modelIdOverride || selectedDetectionModelId
const effectiveModelAssetId = effectiveModelId === 'yolo-configured'
? modelAssets.find((asset) => asset.active)?.model_asset_id ?? selectedModelAssetId
: selectedModelAssetId
const selectedModel = detectionModels.find((model) => model.model_id === effectiveModelId)
if (!selectedModel?.configured || effectiveModelId === 'manual-fixture-detector') {
setDetectionRunError(selectedModel?.limitation_message ?? 'Het gekozen analysemodel is niet beschikbaar')
return false
return null
}
if (selectedDetectionModelId === 'yolo-configured' && modelAssets.length > 0 && !selectedModelAssetId) {
if (effectiveModelId === 'yolo-configured' && modelAssets.length > 0 && !effectiveModelAssetId) {
setDetectionRunError('Kies eerst een lokaal modelbestand')
return false
return null
}
setDetectionRunError(null)
@@ -355,7 +368,7 @@ export function useDetectionWorkflow({
setDetectionWorkflowStage('validating')
const preflight = await detectionApi.getYoloPreflight({
tile_manifest_path: manifestPath,
model_asset_id: selectedModelAssetId || null,
model_asset_id: effectiveModelAssetId || null,
})
setYoloPreflight(preflight)
setYoloPreflightError(null)
@@ -370,46 +383,63 @@ export function useDetectionWorkflow({
}
setDetectionWorkflowStage('detecting')
await executeDetection(selectedProjectId, datasetId, manifestPath)
const result = await executeDetection(
selectedProjectId,
datasetId,
manifestPath,
effectiveModelId,
effectiveModelAssetId,
)
setDetectionWorkflowStage('complete')
return true
return result
} catch (error) {
setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt'))
setDetectionWorkflowStage('failed')
return false
return null
} finally {
setRunningDetection(false)
}
}
const runDetectionQa = async () => {
if (!selectedDetectionRunId) {
const compareDetectionRunWithReference = async (
analysisRunId: string,
referenceDatasetId: string,
useCurrentFilters = true,
): Promise<DetectionQaResult | null> => {
if (!analysisRunId) {
setDetectionQaError('Select a detection run')
return
return null
}
if (!detectionReferenceDatasetId) {
if (!referenceDatasetId) {
setDetectionQaError('Select a reference dataset')
return
return null
}
setSelectedDetectionRunId(analysisRunId)
setDetectionReferenceDatasetId(referenceDatasetId)
setDetectionQaError(null)
setDetectionQaResult(null)
setRunningDetectionQa(true)
try {
const result = await detectionApi.compareWithReference(selectedDetectionRunId, {
reference_dataset_id: detectionReferenceDatasetId,
const result = await detectionApi.compareWithReference(analysisRunId, {
reference_dataset_id: referenceDatasetId,
iou_threshold: qaIouThreshold,
class_name: detectionClassFilter || null,
min_confidence: detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
class_name: useCurrentFilters ? detectionClassFilter || null : null,
min_confidence: useCurrentFilters && detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
})
setDetectionQaResult(result)
await loadQualityChecks(selectedProjectId)
return result
} catch (error) {
setDetectionQaError(formatError(error, 'Detection QA failed'))
return null
} finally {
setRunningDetectionQa(false)
}
}
const runDetectionQa = async (): Promise<DetectionQaResult | null> =>
compareDetectionRunWithReference(selectedDetectionRunId, detectionReferenceDatasetId)
const runDetectionCalibration = async () => {
if (!selectedProjectId) {
setDetectionCalibrationError('Select a project before calibration')
@@ -560,6 +590,7 @@ export function useDetectionWorkflow({
runDetection,
uploadDetectionRaster,
prepareAndRunDetection,
compareDetectionRunWithReference,
runDetectionQa,
runDetectionCalibration,
applyDetectionOperatorProfile,
@@ -0,0 +1,121 @@
import { useState } from 'react'
import { datasetsApi } from '../services/api'
import type {
DatasetCreateResponse,
DetectionQaResult,
DetectionRunResponse,
OrthophotoAcquisitionResult,
VectorSelectionBBox,
} from '../types'
import { formatError } from '../lib/formatError'
export type MapOrthophotoAnalysisStage =
| 'idle'
| 'acquiring'
| 'detecting'
| 'validating'
| 'complete'
| 'failed'
interface MapOrthophotoAnalysisOptions {
selectedProjectId: string | null
selectedAreaId: string
datasets: DatasetCreateResponse[]
loadProjectData: (projectId: string) => Promise<unknown>
prepareAndRunDetection: (datasetId?: string) => Promise<DetectionRunResponse | null>
compareDetectionRunWithReference: (
analysisRunId: string,
referenceDatasetId: string,
) => Promise<DetectionQaResult | null>
onAnalysisReady: () => void
}
function findBuildingReference(datasets: DatasetCreateResponse[]): DatasetCreateResponse | null {
return datasets.find(
(dataset) =>
dataset.status === 'ready' &&
dataset.dataset_role === 'reference' &&
dataset.source_name === 'grb' &&
dataset.reference_layer_name === 'buildings',
) ?? null
}
export function useMapOrthophotoAnalysis({
selectedProjectId,
selectedAreaId,
datasets,
loadProjectData,
prepareAndRunDetection,
compareDetectionRunWithReference,
onAnalysisReady,
}: MapOrthophotoAnalysisOptions) {
const [stage, setStage] = useState<MapOrthophotoAnalysisStage>('idle')
const [status, setStatus] = useState('')
const [error, setError] = useState<string | null>(null)
const [lastResult, setLastResult] = useState<OrthophotoAcquisitionResult | null>(null)
const run = async (bbox: VectorSelectionBBox): Promise<boolean> => {
if (!selectedProjectId) {
setError('De regionale werkruimte is nog niet geladen.')
setStage('failed')
return false
}
setError(null)
setLastResult(null)
setStage('acquiring')
setStatus('1/3 Officieel luchtbeeld voor de rechthoek ophalen...')
try {
const job = await datasetsApi.acquireOrthophoto(selectedProjectId, {
bbox,
area_id: selectedAreaId || undefined,
})
const acquisition = job.result_json as unknown as OrthophotoAcquisitionResult | null
const datasetId = job.output_dataset_id || acquisition?.output_dataset_id
if (job.status !== 'success' || !datasetId || !acquisition) {
throw new Error(job.error_message || 'Het officiële luchtbeeld werd niet als dataset bewaard.')
}
setLastResult(acquisition)
await loadProjectData(selectedProjectId)
setStage('detecting')
setStatus('2/3 Lokaal AI-model herkent gebouwen...')
const detection = await prepareAndRunDetection(datasetId)
if (!detection) {
throw new Error('De beeldanalyse stopte. Open Beeldanalyse voor de technische oorzaak.')
}
const reference = findBuildingReference(datasets)
if (reference) {
setStage('validating')
setStatus('3/3 Resultaat vergelijken met officiële GRB-gebouwen...')
const quality = await compareDetectionRunWithReference(detection.analysis_run_id, reference.id)
setStatus(
quality
? `Analyse klaar: ${detection.detection_count.toLocaleString('nl-BE')} gebouwen herkend en gecontroleerd.`
: `Analyse klaar: ${detection.detection_count.toLocaleString('nl-BE')} gebouwen herkend; kwaliteitscontrole kon niet afronden.`,
)
} else {
setStatus(
`Analyse klaar: ${detection.detection_count.toLocaleString('nl-BE')} gebouwen herkend. De GRB-referentielaag ontbreekt voor automatische controle.`,
)
}
setStage('complete')
onAnalysisReady()
return true
} catch (caught) {
setError(formatError(caught, 'De kaartgestuurde beeldanalyse is mislukt'))
setStatus('Analyse gestopt.')
setStage('failed')
return false
}
}
return {
stage,
status,
error,
lastResult,
running: stage === 'acquiring' || stage === 'detecting' || stage === 'validating',
run,
}
}
+3
View File
@@ -16,6 +16,7 @@ import type {
RasterNdviRequest,
RasterNdwiRequest,
RasterNdbiRequest,
OrthophotoAcquireRequest,
} from '../../types'
export const datasetsApi = {
@@ -81,6 +82,8 @@ export const datasetsApi = {
}
return apiMultipart<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/upload`, form)
},
acquireOrthophoto: (projectId: string, payload: OrthophotoAcquireRequest): Promise<JobRead> =>
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/orthophoto/acquire`, payload),
refreshMetadata: (projectId: string, datasetId: string): Promise<DatasetCreateResponse> =>
apiPost<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/metadata/refresh`, {}),
inspectRaster: (projectId: string, datasetId: string): Promise<RasterInspectResponse> =>
+62
View File
@@ -6045,6 +6045,68 @@ section {
animation: geo-spin 0.8s linear infinite;
}
.geo-image-analysis {
display: grid;
gap: 0.55rem;
border: 1px solid #b9cec7;
border-left: 3px solid #176a5c;
border-radius: 6px;
padding: 0.65rem;
background: #f4faf8;
}
.geo-image-analysis > div {
display: grid;
gap: 0.14rem;
}
.geo-image-analysis span {
color: #4f6a62;
font-size: 0.61rem;
font-weight: 850;
text-transform: uppercase;
}
.geo-image-analysis strong {
color: #173e38;
font-size: 0.78rem;
}
.geo-image-analysis small,
.geo-image-analysis p {
margin: 0;
color: #64736d;
font-size: 0.65rem;
line-height: 1.4;
}
.geo-image-analysis > button {
width: 100%;
min-height: 2.35rem;
}
.geo-image-analysis-acquiring,
.geo-image-analysis-detecting,
.geo-image-analysis-validating {
border-left-color: #b17a31;
background: #fffbeb;
}
.geo-image-analysis-complete {
border-left-color: #277749;
background: #f1faf4;
}
.geo-image-analysis-failed {
border-color: #e2b9b5;
border-left-color: #aa3f37;
background: #fff7f6;
}
.geo-image-analysis .error {
color: #8b2d2d;
}
@keyframes geo-spin {
to { transform: rotate(360deg); }
}
+20
View File
@@ -298,6 +298,26 @@ export interface VectorSelectionBBox {
crs?: 'EPSG:4326'
}
export interface OrthophotoAcquireRequest {
bbox: VectorSelectionBBox
area_id?: string
force_refresh?: boolean
}
export interface OrthophotoAcquisitionResult {
output_dataset_id: string
reused: boolean
provider: string
layer: string
width: number
height: number
resolution_m: number
bbox_epsg4326: number[]
bbox_epsg31370: number[]
attribution: string
limitation_message: string
}
export interface MapViewportState {
bbox: VectorSelectionBBox
zoom: number