feat: add map-driven orthophoto analysis
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user