feat(scope): make Belgium and North Sea operational default
This commit is contained in:
@@ -74,6 +74,14 @@ MDK_BATHYMETRY_PROBE_ENABLED=true
|
|||||||
MDK_BATHYMETRY_WCS_URL=https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs
|
MDK_BATHYMETRY_WCS_URL=https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs
|
||||||
MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS=20
|
MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS=20
|
||||||
MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB=4
|
MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB=4
|
||||||
|
# Bounded MDK acquisition stays fail-closed until the readiness probe reports
|
||||||
|
# "reachable" and an advertised coverage id is configured explicitly.
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_ENABLED=false
|
||||||
|
MDK_BATHYMETRY_COVERAGE_ID=
|
||||||
|
MDK_BATHYMETRY_REQUEST_CRS=EPSG:4326
|
||||||
|
MDK_BATHYMETRY_MAX_BBOX_DEG2=0.25
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS=120
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB=160
|
||||||
THEMATIC_RASTER_ENABLED=true
|
THEMATIC_RASTER_ENABLED=true
|
||||||
THEMATIC_RASTER_WCS_URL=https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs
|
THEMATIC_RASTER_WCS_URL=https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs
|
||||||
THEMATIC_RASTER_MIN_SIDE_M=100
|
THEMATIC_RASTER_MIN_SIDE_M=100
|
||||||
@@ -94,6 +102,21 @@ YOLO_MAX_TILES=100
|
|||||||
YOLO_MAX_DETECTIONS=1000
|
YOLO_MAX_DETECTIONS=1000
|
||||||
YOLO_DUPLICATE_IOU_THRESHOLD=0.5
|
YOLO_DUPLICATE_IOU_THRESHOLD=0.5
|
||||||
YOLO_BATCH_SIZE=1
|
YOLO_BATCH_SIZE=1
|
||||||
|
|
||||||
|
# Local segmentation models. GeoIntel never downloads model weights
|
||||||
|
# automatically; point these to existing local files to enable inference.
|
||||||
|
YOLO_SEG_ENABLED=false
|
||||||
|
YOLO_SEG_MODEL_PATH=
|
||||||
|
YOLO_SEG_MODEL_ID=yolo-seg-configured
|
||||||
|
YOLO_SEG_MODEL_DISPLAY_NAME=Configured YOLO segmentation
|
||||||
|
YOLO_SEG_MODEL_VERSION=
|
||||||
|
SAM_ENABLED=false
|
||||||
|
SAM_MODEL_PATH=
|
||||||
|
SAM_MODEL_ID=sam-configured
|
||||||
|
SAM_MODEL_DISPLAY_NAME=Configured SAM segmentation
|
||||||
|
SAM_MODEL_VERSION=
|
||||||
|
SEGMENTATION_MAX_MASKS_PER_TILE=300
|
||||||
|
SEGMENTATION_DUPLICATE_IOU_THRESHOLD=0.5
|
||||||
ENABLE_GRB_WFS=false
|
ENABLE_GRB_WFS=false
|
||||||
GRB_WFS_URL=
|
GRB_WFS_URL=
|
||||||
OSM_OVERPASS_URL=https://overpass-api.de/api/interpreter
|
OSM_OVERPASS_URL=https://overpass-api.de/api/interpreter
|
||||||
|
|||||||
@@ -7,6 +7,72 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased - Post-V1 capability completion (2026-07-19)
|
||||||
|
|
||||||
|
- Made `Belgium and North Sea Workbench` the unconditional frontend startup
|
||||||
|
context, moved the initial MapLibre viewport to national extent and removed
|
||||||
|
Mol/Kempen defaults from project/area forms and end-user source copy. Mol and
|
||||||
|
Kempen remain golden regression data only.
|
||||||
|
- Added bounded official UrbIS Land Cover products for Brussels using the
|
||||||
|
live-validated `urbisvector:Blocks` WFS layer: total land-cover blocks,
|
||||||
|
FO/GB forest and park blocks, and WB permanent-water blocks. Persisted
|
||||||
|
geometries retain source class codes and expose real hectare metrics.
|
||||||
|
- Restricted the production model-asset catalog to the explicit
|
||||||
|
`YOLO_MODEL_PATH` file so training/smoke checkpoints no longer pollute the
|
||||||
|
end-user selector. The Detection Lab now states the local Mol/Kempen
|
||||||
|
validation scope and explicitly warns that the model is not nationally
|
||||||
|
validated.
|
||||||
|
|
||||||
|
- Implemented real local segmentation inference: `YoloSegmentationAdapter` and
|
||||||
|
`SamSegmentationAdapter` (ultralytics interface) run over existing raster
|
||||||
|
tile manifests, georeference mask polygons to EPSG:4326, suppress duplicate
|
||||||
|
masks by IoU, compute geodesic areas and persist `Segmentation` rows with
|
||||||
|
local-inference provenance. The segmentation model registry now reports
|
||||||
|
`yolo-seg-configured` and `sam-configured` dynamically from
|
||||||
|
`YOLO_SEG_ENABLED`/`YOLO_SEG_MODEL_PATH` and `SAM_ENABLED`/`SAM_MODEL_PATH`.
|
||||||
|
Everything stays fail-closed: no weights are downloaded automatically and a
|
||||||
|
missing file or dependency reports an explicit unavailable status.
|
||||||
|
- Implemented bounded MDK Belgian North Sea bathymetry acquisition
|
||||||
|
(`POST /datasets/bathymetry/mdk/acquire`): WCS 1.0.0 GetCoverage behind the
|
||||||
|
existing strict-TLS readiness probe. Acquisition requires explicit
|
||||||
|
`MDK_BATHYMETRY_ACQUISITION_ENABLED=true`, a coverage id advertised by the
|
||||||
|
live capabilities document, a bounded EPSG:4326 bbox, GeoTIFF validation via
|
||||||
|
rasterio and persists LAT vertical-reference provenance. The bathymetry
|
||||||
|
source registry now reports `acquisition_supported=true` with
|
||||||
|
`configured=false` until the operator opts in.
|
||||||
|
- Added the live-validated `urbis_street_axes` product
|
||||||
|
(`urbisvector:StreetAxes`, INSPIRE_ID identity, LineString geometry) so the
|
||||||
|
Brussels roads theme becomes operational through the existing bounded UrbIS
|
||||||
|
WFS engine. The live capabilities advertise no hydrography feature type, so
|
||||||
|
Brussels surface water intentionally remains `not_configured`.
|
||||||
|
- Extended `.env.example` with the new segmentation and MDK acquisition
|
||||||
|
variables and updated `docs/KNOWN_LIMITATIONS.md` and `docs/TODO.md`.
|
||||||
|
|
||||||
|
### Functional audit fixes (beyond documented scope)
|
||||||
|
|
||||||
|
- Runs can no longer be orphaned in `running`: rejected fixture payloads,
|
||||||
|
unexpected inference errors and the unreachable model fall-through in both
|
||||||
|
`DetectionService.run_detection` and `SegmentationService.run_segmentation`
|
||||||
|
now mark the analysis run and job `failed` before propagating the error, and
|
||||||
|
`JobService.run_sync_job` marks the job failed on unexpected non-AppError
|
||||||
|
exceptions as well (`tests/test_run_state_consistency.py`).
|
||||||
|
- `/api/v1/system/capabilities` no longer hardcodes `sam=false`; it reports
|
||||||
|
the real configured state of the SAM segmentation capability.
|
||||||
|
- `POST /exports/geojson` fails closed with `INVALID_EXPORT_REQUEST` instead of
|
||||||
|
silently returning an empty envelope when no export target matches.
|
||||||
|
- The segmentation workbench now auto-selects a configured non-fixture
|
||||||
|
segmentation model when one exists, mirroring the detection workbench.
|
||||||
|
- Runtime parity: `YOLO_SEG_*`, `SAM_*`, `SEGMENTATION_*` and
|
||||||
|
`MDK_BATHYMETRY_ACQUISITION_*` are now wired through `docker-compose.yml`,
|
||||||
|
`docker-compose.unraid.yml`, `deploy/unraid/run-dockerman-container.sh`,
|
||||||
|
`deploy/unraid/geointel.env.example` and the DockerMan template. Without
|
||||||
|
this the new segmentation and bathymetry features could never be enabled in
|
||||||
|
the deployed runtimes. Compose deployments now also reconcile interrupted
|
||||||
|
runs after a restart (`GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP=true`),
|
||||||
|
matching the Unraid runtime. Guarded by
|
||||||
|
`test_segmentation_and_mdk_acquisition_are_configurable_in_every_runtime`
|
||||||
|
and `test_compose_reconciles_interrupted_runs_after_restart_like_unraid_runtime`.
|
||||||
|
|
||||||
## 1.0.0 - Final Belgium and Belgian North Sea release (2026-07-19)
|
## 1.0.0 - Final Belgium and Belgian North Sea release (2026-07-19)
|
||||||
|
|
||||||
- Fixed rectangle analysis so it materializes and reads all applicable
|
- Fixed rectangle analysis so it materializes and reads all applicable
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ from app.schemas import (
|
|||||||
BathymetryProfileAcquireRequest,
|
BathymetryProfileAcquireRequest,
|
||||||
BathymetryRasterSelectionRequest,
|
BathymetryRasterSelectionRequest,
|
||||||
BathymetryRasterSelectionResponse,
|
BathymetryRasterSelectionResponse,
|
||||||
|
MdkBathymetryAcquireRequest,
|
||||||
ThematicRasterAcquireRequest,
|
ThematicRasterAcquireRequest,
|
||||||
ThematicRasterProductRead,
|
ThematicRasterProductRead,
|
||||||
ThematicRasterSelectionResponse,
|
ThematicRasterSelectionResponse,
|
||||||
@@ -92,6 +93,7 @@ from app.services.flood_hazard_acquisition_service import FloodHazardAcquisition
|
|||||||
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService
|
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService
|
||||||
from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService
|
from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService
|
||||||
from app.services.bathymetry_raster_analysis_service import BathymetryRasterAnalysisService
|
from app.services.bathymetry_raster_analysis_service import BathymetryRasterAnalysisService
|
||||||
|
from app.services.mdk_bathymetry_acquisition_service import MdkBathymetryAcquisitionService
|
||||||
from app.services.mdk_bathymetry_probe_service import MdkBathymetryProbeService
|
from app.services.mdk_bathymetry_probe_service import MdkBathymetryProbeService
|
||||||
from app.services.thematic_raster_acquisition_service import ThematicRasterAcquisitionService
|
from app.services.thematic_raster_acquisition_service import ThematicRasterAcquisitionService
|
||||||
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
|
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
|
||||||
@@ -342,6 +344,25 @@ def probe_mdk_bathymetry_readiness(project_id: UUID, db: Session = Depends(get_d
|
|||||||
return envelope(MdkBathymetryProbeService.probe())
|
return envelope(MdkBathymetryProbeService.probe())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/datasets/bathymetry/mdk/acquire",
|
||||||
|
response_model=Envelope[JobRead],
|
||||||
|
)
|
||||||
|
def acquire_bounded_mdk_bathymetry(
|
||||||
|
project_id: UUID,
|
||||||
|
payload: MdkBathymetryAcquireRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
job = JobService.run_sync_job(
|
||||||
|
db=db,
|
||||||
|
project_id=project_id,
|
||||||
|
job_type="raster.mdk_bathymetry.acquire",
|
||||||
|
parameters=payload.model_dump(mode="json"),
|
||||||
|
operation=lambda: MdkBathymetryAcquisitionService.acquire(db, project_id, payload),
|
||||||
|
)
|
||||||
|
return envelope(job)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/datasets/bathymetry/profiles/acquire",
|
"/datasets/bathymetry/profiles/acquire",
|
||||||
response_model=Envelope[JobRead],
|
response_model=Envelope[JobRead],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.errors import AppError
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.schemas import Envelope
|
from app.schemas import Envelope
|
||||||
from app.schemas.export import (
|
from app.schemas.export import (
|
||||||
@@ -47,7 +48,11 @@ def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db))
|
|||||||
)
|
)
|
||||||
if payload.dataset_id is not None:
|
if payload.dataset_id is not None:
|
||||||
return envelope(ExportService.export_dataset_geojson(db, payload.dataset_id, payload.name).model_dump(mode="json"))
|
return envelope(ExportService.export_dataset_geojson(db, payload.dataset_id, payload.name).model_dump(mode="json"))
|
||||||
return envelope({})
|
raise AppError(
|
||||||
|
code="INVALID_EXPORT_REQUEST",
|
||||||
|
message="GeoJSON export request does not match any supported export target",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/metadata", response_model=Envelope[ExportCreateResponse])
|
@router.post("/metadata", response_model=Envelope[ExportCreateResponse])
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ def capabilities() -> SystemCapabilitiesEnvelope:
|
|||||||
)
|
)
|
||||||
yolo_configured = bool(configured_yolo and configured_yolo.configured)
|
yolo_configured = bool(configured_yolo and configured_yolo.configured)
|
||||||
yolo_status = configured_yolo.status if configured_yolo else "not_configured"
|
yolo_status = configured_yolo.status if configured_yolo else "not_configured"
|
||||||
|
configured_sam = ModelRegistryService.get_model_capability(
|
||||||
|
settings.sam_model_id,
|
||||||
|
settings=settings,
|
||||||
|
task_type="segmentation",
|
||||||
|
)
|
||||||
postgis_ready = _database_checks()["postgis"].startswith("ok:")
|
postgis_ready = _database_checks()["postgis"].startswith("ok:")
|
||||||
return SystemCapabilitiesEnvelope(
|
return SystemCapabilitiesEnvelope(
|
||||||
data=SystemCapabilities(
|
data=SystemCapabilities(
|
||||||
@@ -155,7 +160,7 @@ def capabilities() -> SystemCapabilitiesEnvelope:
|
|||||||
geopandas=_dependency_enabled("geopandas"),
|
geopandas=_dependency_enabled("geopandas"),
|
||||||
yolo=yolo_configured,
|
yolo=yolo_configured,
|
||||||
yolo_status=yolo_status,
|
yolo_status=yolo_status,
|
||||||
sam=False,
|
sam=bool(configured_sam and configured_sam.configured),
|
||||||
grb="bounded",
|
grb="bounded",
|
||||||
sentinel="planned",
|
sentinel="planned",
|
||||||
version=settings.app_version,
|
version=settings.app_version,
|
||||||
|
|||||||
@@ -247,6 +247,27 @@ class Settings(BaseSettings):
|
|||||||
default="https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs",
|
default="https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs",
|
||||||
validation_alias="THEMATIC_RASTER_WCS_URL",
|
validation_alias="THEMATIC_RASTER_WCS_URL",
|
||||||
)
|
)
|
||||||
|
mdk_bathymetry_acquisition_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
validation_alias="MDK_BATHYMETRY_ACQUISITION_ENABLED",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_coverage_id: str | None = Field(default=None, validation_alias="MDK_BATHYMETRY_COVERAGE_ID")
|
||||||
|
mdk_bathymetry_request_crs: str = Field(default="EPSG:4326", validation_alias="MDK_BATHYMETRY_REQUEST_CRS")
|
||||||
|
mdk_bathymetry_max_bbox_deg2: float = Field(
|
||||||
|
default=0.25,
|
||||||
|
gt=0,
|
||||||
|
validation_alias="MDK_BATHYMETRY_MAX_BBOX_DEG2",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_acquisition_timeout_seconds: int = Field(
|
||||||
|
default=120,
|
||||||
|
ge=1,
|
||||||
|
validation_alias="MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_acquisition_max_response_mb: int = Field(
|
||||||
|
default=160,
|
||||||
|
ge=1,
|
||||||
|
validation_alias="MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB",
|
||||||
|
)
|
||||||
thematic_raster_min_side_m: float = Field(default=100.0, gt=0, validation_alias="THEMATIC_RASTER_MIN_SIDE_M")
|
thematic_raster_min_side_m: float = Field(default=100.0, gt=0, validation_alias="THEMATIC_RASTER_MIN_SIDE_M")
|
||||||
thematic_raster_max_side_m: float = Field(default=60_000.0, gt=0, validation_alias="THEMATIC_RASTER_MAX_SIDE_M")
|
thematic_raster_max_side_m: float = Field(default=60_000.0, gt=0, validation_alias="THEMATIC_RASTER_MAX_SIDE_M")
|
||||||
thematic_raster_max_pixels: int = Field(default=30_000_000, ge=1, validation_alias="THEMATIC_RASTER_MAX_PIXELS")
|
thematic_raster_max_pixels: int = Field(default=30_000_000, ge=1, validation_alias="THEMATIC_RASTER_MAX_PIXELS")
|
||||||
@@ -272,6 +293,29 @@ class Settings(BaseSettings):
|
|||||||
yolo_max_detections: int = Field(default=1000, validation_alias="YOLO_MAX_DETECTIONS")
|
yolo_max_detections: int = Field(default=1000, validation_alias="YOLO_MAX_DETECTIONS")
|
||||||
yolo_duplicate_iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0, validation_alias="YOLO_DUPLICATE_IOU_THRESHOLD")
|
yolo_duplicate_iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0, validation_alias="YOLO_DUPLICATE_IOU_THRESHOLD")
|
||||||
yolo_batch_size: int = Field(default=1, validation_alias="YOLO_BATCH_SIZE")
|
yolo_batch_size: int = Field(default=1, validation_alias="YOLO_BATCH_SIZE")
|
||||||
|
yolo_seg_enabled: bool = Field(default=False, validation_alias="YOLO_SEG_ENABLED")
|
||||||
|
yolo_seg_model_path: str | None = Field(default=None, validation_alias="YOLO_SEG_MODEL_PATH")
|
||||||
|
yolo_seg_model_id: str = Field(default="yolo-seg-configured", validation_alias="YOLO_SEG_MODEL_ID")
|
||||||
|
yolo_seg_model_display_name: str = Field(
|
||||||
|
default="Configured YOLO segmentation",
|
||||||
|
validation_alias="YOLO_SEG_MODEL_DISPLAY_NAME",
|
||||||
|
)
|
||||||
|
yolo_seg_model_version: str | None = Field(default=None, validation_alias="YOLO_SEG_MODEL_VERSION")
|
||||||
|
sam_enabled: bool = Field(default=False, validation_alias="SAM_ENABLED")
|
||||||
|
sam_model_path: str | None = Field(default=None, validation_alias="SAM_MODEL_PATH")
|
||||||
|
sam_model_id: str = Field(default="sam-configured", validation_alias="SAM_MODEL_ID")
|
||||||
|
sam_model_display_name: str = Field(
|
||||||
|
default="Configured SAM segmentation",
|
||||||
|
validation_alias="SAM_MODEL_DISPLAY_NAME",
|
||||||
|
)
|
||||||
|
sam_model_version: str | None = Field(default=None, validation_alias="SAM_MODEL_VERSION")
|
||||||
|
segmentation_max_masks_per_tile: int = Field(default=300, ge=1, validation_alias="SEGMENTATION_MAX_MASKS_PER_TILE")
|
||||||
|
segmentation_duplicate_iou_threshold: float = Field(
|
||||||
|
default=0.5,
|
||||||
|
ge=0.0,
|
||||||
|
le=1.0,
|
||||||
|
validation_alias="SEGMENTATION_DUPLICATE_IOU_THRESHOLD",
|
||||||
|
)
|
||||||
ollama_enabled: bool = Field(default=False, validation_alias="OLLAMA_ENABLED")
|
ollama_enabled: bool = Field(default=False, validation_alias="OLLAMA_ENABLED")
|
||||||
ollama_base_url: str = Field(default="http://127.0.0.1:11434", validation_alias="OLLAMA_BASE_URL")
|
ollama_base_url: str = Field(default="http://127.0.0.1:11434", validation_alias="OLLAMA_BASE_URL")
|
||||||
ollama_default_model: str = Field(default="qwen3.5:9b", validation_alias="OLLAMA_DEFAULT_MODEL")
|
ollama_default_model: str = Field(default="qwen3.5:9b", validation_alias="OLLAMA_DEFAULT_MODEL")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class Project(Base):
|
|||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
region: Mapped[str] = mapped_column(String(120), default="Kempen")
|
region: Mapped[str] = mapped_column(String(120), default="Belgium and Belgian North Sea")
|
||||||
status: Mapped[str] = mapped_column(String(32), default="active")
|
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ from .bathymetry import (
|
|||||||
BathymetryRasterSelectionSummary,
|
BathymetryRasterSelectionSummary,
|
||||||
BathymetrySourceProbeRead,
|
BathymetrySourceProbeRead,
|
||||||
BathymetrySourceRead,
|
BathymetrySourceRead,
|
||||||
|
MdkBathymetryAcquireRequest,
|
||||||
|
MdkBathymetryAcquisitionResult,
|
||||||
)
|
)
|
||||||
from .thematic_raster import (
|
from .thematic_raster import (
|
||||||
ThematicRasterAcquireRequest,
|
ThematicRasterAcquireRequest,
|
||||||
@@ -265,6 +267,8 @@ __all__ = [
|
|||||||
"BathymetryPartitionFinalizationResult",
|
"BathymetryPartitionFinalizationResult",
|
||||||
"BathymetrySourceProbeRead",
|
"BathymetrySourceProbeRead",
|
||||||
"BathymetrySourceRead",
|
"BathymetrySourceRead",
|
||||||
|
"MdkBathymetryAcquireRequest",
|
||||||
|
"MdkBathymetryAcquisitionResult",
|
||||||
"ThematicRasterAcquireRequest",
|
"ThematicRasterAcquireRequest",
|
||||||
"ThematicRasterAcquisitionResult",
|
"ThematicRasterAcquisitionResult",
|
||||||
"ThematicRasterMetric",
|
"ThematicRasterMetric",
|
||||||
|
|||||||
@@ -111,6 +111,24 @@ class BathymetrySourceProbeRead(BaseModel):
|
|||||||
limitation_message: str
|
limitation_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class MdkBathymetryAcquireRequest(BaseModel):
|
||||||
|
bbox: VectorSelectionBBox
|
||||||
|
area_id: UUID | None = None
|
||||||
|
force_refresh: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MdkBathymetryAcquisitionResult(BaseModel):
|
||||||
|
output_dataset_id: UUID
|
||||||
|
reused: bool
|
||||||
|
provider: str
|
||||||
|
coverage_id: str
|
||||||
|
bbox_epsg4326: list[float]
|
||||||
|
vertical_reference: str
|
||||||
|
resolution_m: float = Field(gt=0)
|
||||||
|
attribution: str
|
||||||
|
limitation_message: str
|
||||||
|
|
||||||
|
|
||||||
class BathymetryRasterSelectionRequest(BaseModel):
|
class BathymetryRasterSelectionRequest(BaseModel):
|
||||||
bbox: VectorSelectionBBox
|
bbox: VectorSelectionBBox
|
||||||
area_id: UUID | None = None
|
area_id: UUID | None = None
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel
|
|||||||
class ProjectCreate(BaseModel):
|
class ProjectCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
region: str | None = "Kempen"
|
region: str | None = "Belgium and Belgian North Sea"
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
class ProjectUpdate(BaseModel):
|
||||||
|
|||||||
@@ -134,8 +134,37 @@ class BathymetryProfileAcquisitionService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_sources() -> list[dict[str, Any]]:
|
def list_sources(settings=None) -> list[dict[str, Any]]:
|
||||||
return [BathymetrySourceRead(**item).model_dump() for item in BathymetryProfileAcquisitionService._SOURCES]
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
resolved_settings = settings or get_settings()
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for source in BathymetryProfileAcquisitionService._SOURCES:
|
||||||
|
item = dict(source)
|
||||||
|
if item["key"] == "mdk_bcp_bathymetry":
|
||||||
|
mdk_configured = bool(
|
||||||
|
resolved_settings.mdk_bathymetry_acquisition_enabled
|
||||||
|
and (resolved_settings.mdk_bathymetry_coverage_id or "").strip()
|
||||||
|
)
|
||||||
|
item["acquisition_supported"] = True
|
||||||
|
item["configured"] = mdk_configured
|
||||||
|
if mdk_configured:
|
||||||
|
item["integration_status"] = "operational"
|
||||||
|
item["limitation_message"] = (
|
||||||
|
"Begrensde WCS-acquisitie is expliciet ingeschakeld en draait alleen wanneer de "
|
||||||
|
"live readiness-probe bereikbaar is en het geconfigureerde coverage-id door de "
|
||||||
|
"capabilities wordt geadverteerd. Dieptes blijven LAT-gerefereerd; watervolume "
|
||||||
|
"blijft zonder compatibel wateroppervlak niet ondersteund."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
item["limitation_message"] = (
|
||||||
|
"Begrensde WCS-acquisitie bestaat maar staat uit. Zet "
|
||||||
|
"MDK_BATHYMETRY_ACQUISITION_ENABLED=true en configureer MDK_BATHYMETRY_COVERAGE_ID "
|
||||||
|
"pas nadat de readiness-probe live 'reachable' rapporteert. Er wordt nooit "
|
||||||
|
"onbeveiligd of ongevalideerd gedownload."
|
||||||
|
)
|
||||||
|
items.append(item)
|
||||||
|
return [BathymetrySourceRead(**item).model_dump() for item in items]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate_bbox(payload: BathymetryProfileAcquireRequest) -> tuple[float, float, float, float]:
|
def _validate_bbox(payload: BathymetryProfileAcquireRequest) -> tuple[float, float, float, float]:
|
||||||
|
|||||||
@@ -272,11 +272,11 @@ SOURCE_DEFINITIONS = (
|
|||||||
attribution="Brussels UrbIS",
|
attribution="Brussels UrbIS",
|
||||||
license_note="Consult the license of the selected UrbIS dataset.",
|
license_note="Consult the license of the selected UrbIS dataset.",
|
||||||
limitation_message=(
|
limitation_message=(
|
||||||
"Bounded UrbIS buildings and cadastral parcels are operational; "
|
"Bounded UrbIS buildings, cadastral parcels, street axes and Land Cover blocks are operational. "
|
||||||
"other Brussels themes remain unavailable until separately governed."
|
"Permanent water uses the official WB block class; no separate hydrography network is inferred."
|
||||||
),
|
),
|
||||||
materialized_source_names=("urbis",),
|
materialized_source_names=("urbis",),
|
||||||
operational_themes=("buildings", "parcels"),
|
operational_themes=("buildings", "parcels", "roads", "surface_water", "land_cover_use"),
|
||||||
),
|
),
|
||||||
_contract(
|
_contract(
|
||||||
source_name="rbins_marine_reporting_units",
|
source_name="rbins_marine_reporting_units",
|
||||||
@@ -341,7 +341,10 @@ SOURCE_DEFINITIONS = (
|
|||||||
source_url="https://www.vlaanderen.be/datavindplaats",
|
source_url="https://www.vlaanderen.be/datavindplaats",
|
||||||
attribution="Agentschap Maritieme Dienstverlening en Kust (MDK)",
|
attribution="Agentschap Maritieme Dienstverlening en Kust (MDK)",
|
||||||
license_note="Consult the official product license before acquisition.",
|
license_note="Consult the official product license before acquisition.",
|
||||||
limitation_message="Strict-TLS acquisition and vertical datum evidence are not yet sufficient; no depths are synthesized.",
|
limitation_message=(
|
||||||
|
"Bounded strict-TLS WCS acquisition is implemented but stays disabled until the operator enables it "
|
||||||
|
"with a live-validated coverage id; no depths are synthesized."
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -377,6 +380,9 @@ REGIONAL_THEME_DATASETS: dict[str, dict[str, dict[str, tuple[str, ...]]]] = {
|
|||||||
"urbis": {
|
"urbis": {
|
||||||
"buildings": {"urbis": ("buildings",)},
|
"buildings": {"urbis": ("buildings",)},
|
||||||
"parcels": {"urbis": ("parcels",)},
|
"parcels": {"urbis": ("parcels",)},
|
||||||
|
"roads": {"urbis": ("roads",)},
|
||||||
|
"surface_water": {"urbis": ("water",)},
|
||||||
|
"land_cover_use": {"urbis": ("space_occupation", "forest")},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,75 @@ def pixel_bbox_to_epsg4326_polygon(bbox: list[float], tile: dict[str, Any], crs:
|
|||||||
return polygon
|
return polygon
|
||||||
|
|
||||||
|
|
||||||
|
def pixel_points_to_epsg4326_polygon(points: list[list[float]], tile: dict[str, Any], crs: str | None = None) -> Polygon:
|
||||||
|
if not isinstance(points, list) or len(points) < 3:
|
||||||
|
raise AppError(
|
||||||
|
code="SEGMENTATION_INVALID_MASK",
|
||||||
|
message="Segmentation mask polygon must contain at least three pixel points",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
pixel_points = [(float(point[0]), float(point[1])) for point in points]
|
||||||
|
except (TypeError, ValueError, IndexError) as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="SEGMENTATION_INVALID_MASK",
|
||||||
|
message="Segmentation mask polygon points must be numeric [x, y] pairs",
|
||||||
|
status_code=422,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
transform = tile.get("transform")
|
||||||
|
if isinstance(transform, list) and len(transform) >= 6:
|
||||||
|
coordinates = [_apply_gdal_transform(transform, x, y) for x, y in pixel_points]
|
||||||
|
else:
|
||||||
|
coordinates = [_project_pixel_with_bounds(tile, x, y) for x, y in pixel_points]
|
||||||
|
|
||||||
|
source_crs = crs or tile.get("crs") or tile.get("source_crs") or "EPSG:4326"
|
||||||
|
if str(source_crs).upper() not in {"EPSG:4326", "4326"}:
|
||||||
|
transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
|
||||||
|
coordinates = [transformer.transform(x, y) for x, y in coordinates]
|
||||||
|
|
||||||
|
if coordinates[0] != coordinates[-1]:
|
||||||
|
coordinates.append(coordinates[0])
|
||||||
|
polygon = Polygon(coordinates)
|
||||||
|
if not polygon.is_valid:
|
||||||
|
from shapely.validation import make_valid
|
||||||
|
|
||||||
|
repaired = make_valid(polygon)
|
||||||
|
polygon = _largest_polygon(repaired)
|
||||||
|
if polygon is None or polygon.is_empty or not polygon.is_valid or polygon.area <= 0:
|
||||||
|
raise AppError(
|
||||||
|
code="SEGMENTATION_INVALID_GEOMETRY",
|
||||||
|
message="Georeferenced segmentation geometry is invalid",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
return polygon
|
||||||
|
|
||||||
|
|
||||||
|
def _largest_polygon(geometry: Any) -> Polygon | None:
|
||||||
|
if isinstance(geometry, Polygon):
|
||||||
|
return geometry
|
||||||
|
candidates = [geom for geom in getattr(geometry, "geoms", []) if isinstance(geom, Polygon) and geom.area > 0]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
return max(candidates, key=lambda geom: geom.area)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_pixel_with_bounds(tile: dict[str, Any], px: float, py: float) -> tuple[float, float]:
|
||||||
|
bounds = tile.get("bounds")
|
||||||
|
pixel_window = tile.get("pixel_window")
|
||||||
|
if not (isinstance(bounds, list) and len(bounds) == 4 and isinstance(pixel_window, list) and len(pixel_window) == 4):
|
||||||
|
raise AppError(
|
||||||
|
code="DETECTION_TILE_MANIFEST_INVALID",
|
||||||
|
message="Tile manifest entries require transform or bounds plus pixel_window for georeferencing",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
left, bottom, right, top = [float(value) for value in bounds]
|
||||||
|
_, _, width, height = [float(value) for value in pixel_window]
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise AppError(code="DETECTION_TILE_MANIFEST_INVALID", message="Tile pixel_window must have positive size", status_code=422)
|
||||||
|
return (left + (px / width) * (right - left), top - (py / height) * (top - bottom))
|
||||||
|
|
||||||
|
|
||||||
def _apply_gdal_transform(transform: list[float], x: float, y: float) -> tuple[float, float]:
|
def _apply_gdal_transform(transform: list[float], x: float, y: float) -> tuple[float, float]:
|
||||||
c, a, b, f, d, e = [float(value) for value in transform[:6]]
|
c, a, b, f, d, e = [float(value) for value in transform[:6]]
|
||||||
return (a * x + b * y + c, d * x + e * y + f)
|
return (a * x + b * y + c, d * x + e * y + f)
|
||||||
|
|||||||
@@ -130,18 +130,23 @@ class DetectionService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if model.model_id == "manual-fixture-detector":
|
if model.model_id == "manual-fixture-detector":
|
||||||
detections = DetectionService._persist_fixture_detections(
|
try:
|
||||||
db=db,
|
detections = DetectionService._persist_fixture_detections(
|
||||||
project_id=project_id,
|
db=db,
|
||||||
dataset_id=dataset_id,
|
project_id=project_id,
|
||||||
analysis_run=analysis_run,
|
dataset_id=dataset_id,
|
||||||
job=job,
|
analysis_run=analysis_run,
|
||||||
model_name=model.model_id,
|
job=job,
|
||||||
model_version=model.version,
|
model_name=model.model_id,
|
||||||
raw_detections=parameters.get("fixture_detections"),
|
model_version=model.version,
|
||||||
confidence_threshold=confidence_threshold,
|
raw_detections=parameters.get("fixture_detections"),
|
||||||
class_filter=class_filter or [],
|
confidence_threshold=confidence_threshold,
|
||||||
)
|
class_filter=class_filter or [],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# A rejected fixture payload must never leave the run stuck in "running".
|
||||||
|
DetectionService._fail_run_after_exception(db, analysis_run, job, exc, fallback_code="DETECTION_INTERNAL_ERROR")
|
||||||
|
raise
|
||||||
DetectionService._mark_success(db, analysis_run, job, detection_count=len(detections))
|
DetectionService._mark_success(db, analysis_run, job, detection_count=len(detections))
|
||||||
return DetectionRunResponse(
|
return DetectionRunResponse(
|
||||||
analysis_run_id=analysis_run.id,
|
analysis_run_id=analysis_run.id,
|
||||||
@@ -183,6 +188,10 @@ class DetectionService:
|
|||||||
error_code=exc.code,
|
error_code=exc.code,
|
||||||
message=exc.message,
|
message=exc.message,
|
||||||
)
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# An unexpected inference error must never leave the run stuck in "running".
|
||||||
|
DetectionService._fail_run_after_exception(db, analysis_run, job, exc, fallback_code="DETECTION_INTERNAL_ERROR")
|
||||||
|
raise
|
||||||
DetectionService._mark_success(db, analysis_run, job, detection_count=len(detections), extra_result=postprocess_summary)
|
DetectionService._mark_success(db, analysis_run, job, detection_count=len(detections), extra_result=postprocess_summary)
|
||||||
return DetectionRunResponse(
|
return DetectionRunResponse(
|
||||||
analysis_run_id=analysis_run.id,
|
analysis_run_id=analysis_run.id,
|
||||||
@@ -195,8 +204,28 @@ class DetectionService:
|
|||||||
message="YOLO detections persisted.",
|
message="YOLO detections persisted.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DetectionService._mark_failed(
|
||||||
|
db,
|
||||||
|
analysis_run,
|
||||||
|
job,
|
||||||
|
code="DETECTION_MODEL_UNAVAILABLE",
|
||||||
|
message="Detection model is unavailable",
|
||||||
|
)
|
||||||
raise AppError(code="DETECTION_MODEL_UNAVAILABLE", message="Detection model is unavailable", status_code=503)
|
raise AppError(code="DETECTION_MODEL_UNAVAILABLE", message="Detection model is unavailable", status_code=503)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fail_run_after_exception(db, analysis_run: AnalysisRun, job: Job, exc: Exception, fallback_code: str) -> None:
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
code = getattr(exc, "code", None) or fallback_code
|
||||||
|
message = getattr(exc, "message", None) or "Unexpected internal error during analysis run"
|
||||||
|
try:
|
||||||
|
DetectionService._mark_failed(db, analysis_run, job, code=str(code), message=str(message))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_run(db, analysis_run_id: uuid.UUID) -> DetectionRunRead:
|
def get_run(db, analysis_run_id: uuid.UUID) -> DetectionRunRead:
|
||||||
run = db.get(AnalysisRun, analysis_run_id)
|
run = db.get(AnalysisRun, analysis_run_id)
|
||||||
|
|||||||
@@ -76,6 +76,22 @@ class JobService:
|
|||||||
result_json["output_dataset_id"] = str(result_json["output_dataset_id"])
|
result_json["output_dataset_id"] = str(result_json["output_dataset_id"])
|
||||||
payload["result_json"] = result_json
|
payload["result_json"] = result_json
|
||||||
raise
|
raise
|
||||||
|
except Exception:
|
||||||
|
# An unexpected error must never leave the job stuck in "running".
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
JobService.mark_failed(
|
||||||
|
db,
|
||||||
|
created.id,
|
||||||
|
error_message="Unexpected internal error during synchronous job execution",
|
||||||
|
details={"code": "JOB_INTERNAL_ERROR"},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _coerce_payload(payload: dict[str, Any] | None) -> dict[str, Any]:
|
def _coerce_payload(payload: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any, Callable
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.models import Dataset
|
||||||
|
from app.schemas.bathymetry import MdkBathymetryAcquireRequest, MdkBathymetryAcquisitionResult
|
||||||
|
from app.services.dataset_service import DatasetService
|
||||||
|
from app.services.mdk_bathymetry_probe_service import MdkBathymetryProbeService
|
||||||
|
|
||||||
|
|
||||||
|
class MdkBathymetryAcquisitionService:
|
||||||
|
"""Bounded, fail-closed GetCoverage acquisition for the MDK Belgian North Sea depth model.
|
||||||
|
|
||||||
|
Acquisition only runs when:
|
||||||
|
|
||||||
|
- the operator explicitly enabled acquisition and configured a coverage id,
|
||||||
|
- the live strict-TLS readiness probe reports ``reachable``,
|
||||||
|
- the configured coverage id is advertised by the live capabilities document,
|
||||||
|
- the requested EPSG:4326 bbox stays within the configured size bound.
|
||||||
|
|
||||||
|
No depth values are ever synthesized, no insecure TLS fallback exists and the
|
||||||
|
LAT vertical reference is persisted with every artifact so it can never be
|
||||||
|
silently compared with TAW or mDNG data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROVIDER = "mdk_bcp_bathymetry"
|
||||||
|
VERTICAL_REFERENCE = "LAT"
|
||||||
|
NATIVE_RESOLUTION_M = 20.0
|
||||||
|
MAX_PIXELS_PER_SIDE = 4096
|
||||||
|
LIMITATION = (
|
||||||
|
"Dieptewaarden zijn LAT-gerefereerd en gelden voor de bemonsterde survey-periode van het officiële "
|
||||||
|
"MDK-model. LAT mag nooit zonder gedocumenteerde datumtransformatie met TAW- of mDNG-gegevens worden "
|
||||||
|
"vergeleken; watervolume blijft zonder compatibel wateroppervlak niet ondersteund."
|
||||||
|
)
|
||||||
|
ATTRIBUTION = "Agentschap Maritieme Dienstverlening en Kust (MDK)"
|
||||||
|
LICENSE_NOTE = "Consult the official MDK product license before redistribution."
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def acquire(
|
||||||
|
db,
|
||||||
|
project_id: UUID,
|
||||||
|
payload: MdkBathymetryAcquireRequest,
|
||||||
|
*,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
opener: Callable[..., Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
resolved_settings = settings or get_settings()
|
||||||
|
if not resolved_settings.mdk_bathymetry_acquisition_enabled:
|
||||||
|
raise AppError(
|
||||||
|
code="MDK_BATHYMETRY_ACQUISITION_DISABLED",
|
||||||
|
message=(
|
||||||
|
"MDK bathymetry acquisition is disabled. Enable it explicitly with "
|
||||||
|
"MDK_BATHYMETRY_ACQUISITION_ENABLED=true after the readiness probe reports reachable."
|
||||||
|
),
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
coverage_id = (resolved_settings.mdk_bathymetry_coverage_id or "").strip()
|
||||||
|
if not coverage_id:
|
||||||
|
raise AppError(
|
||||||
|
code="MDK_BATHYMETRY_COVERAGE_NOT_CONFIGURED",
|
||||||
|
message="MDK_BATHYMETRY_COVERAGE_ID is not configured; GeoIntel will not guess coverage identifiers.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
bbox = MdkBathymetryAcquisitionService._validated_bbox(payload, resolved_settings)
|
||||||
|
|
||||||
|
probe = MdkBathymetryProbeService.probe(settings=resolved_settings, opener=opener)
|
||||||
|
if probe.get("status") != "reachable":
|
||||||
|
raise AppError(
|
||||||
|
code="MDK_BATHYMETRY_ENDPOINT_NOT_READY",
|
||||||
|
message="The live MDK readiness probe does not report a reachable, TLS-verified WCS endpoint.",
|
||||||
|
details={"probe_status": probe.get("status"), "probe_message": probe.get("message")},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
if coverage_id not in (probe.get("coverage_identifiers") or []):
|
||||||
|
raise AppError(
|
||||||
|
code="MDK_BATHYMETRY_COVERAGE_NOT_ADVERTISED",
|
||||||
|
message="The configured coverage id is not advertised by the live MDK capabilities document.",
|
||||||
|
details={
|
||||||
|
"configured_coverage_id": coverage_id,
|
||||||
|
"advertised_coverage_identifiers": probe.get("coverage_identifiers") or [],
|
||||||
|
},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
|
||||||
|
request_url = MdkBathymetryAcquisitionService._get_coverage_url(resolved_settings, coverage_id, bbox)
|
||||||
|
request_hash = hashlib.sha256(request_url.encode("utf-8")).hexdigest()
|
||||||
|
filename = f"mdk_bathymetry_{request_hash[:12]}.tif"
|
||||||
|
|
||||||
|
if not payload.force_refresh:
|
||||||
|
cached = MdkBathymetryAcquisitionService._cached_dataset(db, project_id, filename)
|
||||||
|
if cached is not None:
|
||||||
|
return MdkBathymetryAcquisitionResult(
|
||||||
|
output_dataset_id=cached.id,
|
||||||
|
reused=True,
|
||||||
|
provider=MdkBathymetryAcquisitionService.PROVIDER,
|
||||||
|
coverage_id=coverage_id,
|
||||||
|
bbox_epsg4326=bbox,
|
||||||
|
vertical_reference=MdkBathymetryAcquisitionService.VERTICAL_REFERENCE,
|
||||||
|
resolution_m=MdkBathymetryAcquisitionService.NATIVE_RESOLUTION_M,
|
||||||
|
attribution=MdkBathymetryAcquisitionService.ATTRIBUTION,
|
||||||
|
limitation_message=MdkBathymetryAcquisitionService.LIMITATION,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
content, content_type = MdkBathymetryAcquisitionService._fetch(request_url, resolved_settings, opener)
|
||||||
|
validation = MdkBathymetryAcquisitionService._validate_geotiff(content)
|
||||||
|
acquired_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
dataset = DatasetService.import_raster_bytes(
|
||||||
|
db,
|
||||||
|
project_id=project_id,
|
||||||
|
area_id=payload.area_id,
|
||||||
|
filename=filename,
|
||||||
|
content=content,
|
||||||
|
source=f"MDK Belgian Continental Shelf WCS {coverage_id}",
|
||||||
|
source_name=MdkBathymetryAcquisitionService.PROVIDER,
|
||||||
|
source_metadata={
|
||||||
|
"provider": MdkBathymetryAcquisitionService.PROVIDER,
|
||||||
|
"service": "WCS",
|
||||||
|
"service_version": "1.0.0",
|
||||||
|
"coverage_id": coverage_id,
|
||||||
|
"vertical_reference": MdkBathymetryAcquisitionService.VERTICAL_REFERENCE,
|
||||||
|
"native_resolution_m": MdkBathymetryAcquisitionService.NATIVE_RESOLUTION_M,
|
||||||
|
"bbox_epsg4326": bbox,
|
||||||
|
"attribution": MdkBathymetryAcquisitionService.ATTRIBUTION,
|
||||||
|
"license_note": MdkBathymetryAcquisitionService.LICENSE_NOTE,
|
||||||
|
"raster_validation": validation,
|
||||||
|
},
|
||||||
|
provenance_metadata={
|
||||||
|
"acquisition": "explicit_bounded_wcs_get_coverage",
|
||||||
|
"acquired_at": acquired_at.isoformat(),
|
||||||
|
"request_url": request_url,
|
||||||
|
"request_hash": request_hash,
|
||||||
|
"response_content_type": content_type,
|
||||||
|
"coverage_sha256": hashlib.sha256(content).hexdigest(),
|
||||||
|
"probe_status": probe.get("status"),
|
||||||
|
"probe_response_sha256": probe.get("response_sha256"),
|
||||||
|
"probe_checked_at": probe.get("checked_at"),
|
||||||
|
"limitation_message": MdkBathymetryAcquisitionService.LIMITATION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return MdkBathymetryAcquisitionResult(
|
||||||
|
output_dataset_id=dataset.id,
|
||||||
|
reused=False,
|
||||||
|
provider=MdkBathymetryAcquisitionService.PROVIDER,
|
||||||
|
coverage_id=coverage_id,
|
||||||
|
bbox_epsg4326=bbox,
|
||||||
|
vertical_reference=MdkBathymetryAcquisitionService.VERTICAL_REFERENCE,
|
||||||
|
resolution_m=MdkBathymetryAcquisitionService.NATIVE_RESOLUTION_M,
|
||||||
|
attribution=MdkBathymetryAcquisitionService.ATTRIBUTION,
|
||||||
|
limitation_message=MdkBathymetryAcquisitionService.LIMITATION,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validated_bbox(payload: MdkBathymetryAcquireRequest, settings: Settings) -> list[float]:
|
||||||
|
bbox = payload.bbox
|
||||||
|
min_x, min_y, max_x, max_y = (
|
||||||
|
float(bbox.min_x),
|
||||||
|
float(bbox.min_y),
|
||||||
|
float(bbox.max_x),
|
||||||
|
float(bbox.max_y),
|
||||||
|
)
|
||||||
|
if max_x <= min_x or max_y <= min_y:
|
||||||
|
raise AppError(
|
||||||
|
code="MDK_BATHYMETRY_INVALID_BBOX",
|
||||||
|
message="The requested bbox must have positive width and height in EPSG:4326.",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
area_deg2 = (max_x - min_x) * (max_y - min_y)
|
||||||
|
if area_deg2 > float(settings.mdk_bathymetry_max_bbox_deg2):
|
||||||
|
raise AppError(
|
||||||
|
code="MDK_BATHYMETRY_BBOX_TOO_LARGE",
|
||||||
|
message="The requested bbox exceeds the configured bounded acquisition size.",
|
||||||
|
details={
|
||||||
|
"bbox_area_deg2": area_deg2,
|
||||||
|
"max_bbox_deg2": float(settings.mdk_bathymetry_max_bbox_deg2),
|
||||||
|
},
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
return [min_x, min_y, max_x, max_y]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_coverage_url(settings: Settings, coverage_id: str, bbox: list[float]) -> str:
|
||||||
|
parsed = urlsplit(settings.mdk_bathymetry_wcs_url.strip())
|
||||||
|
if parsed.scheme.lower() != "https" or not parsed.hostname:
|
||||||
|
raise AppError(
|
||||||
|
code="MDK_BATHYMETRY_INVALID_CONFIGURATION",
|
||||||
|
message="MDK bathymetry acquisition requires an absolute HTTPS WCS URL.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
width, height = MdkBathymetryAcquisitionService._pixel_dimensions(bbox)
|
||||||
|
parameters = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||||
|
parameters.update(
|
||||||
|
{
|
||||||
|
"service": "WCS",
|
||||||
|
"request": "GetCoverage",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"coverage": coverage_id,
|
||||||
|
"crs": settings.mdk_bathymetry_request_crs,
|
||||||
|
"bbox": ",".join(f"{value:.8f}" for value in bbox),
|
||||||
|
"width": str(width),
|
||||||
|
"height": str(height),
|
||||||
|
"format": "GeoTIFF",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(parameters), ""))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pixel_dimensions(bbox: list[float]) -> tuple[int, int]:
|
||||||
|
min_x, min_y, max_x, max_y = bbox
|
||||||
|
# Approximate meters per degree near the Belgian North Sea (~51.5N).
|
||||||
|
meters_per_deg_lat = 111_320.0
|
||||||
|
meters_per_deg_lon = 69_400.0
|
||||||
|
width = int((max_x - min_x) * meters_per_deg_lon / MdkBathymetryAcquisitionService.NATIVE_RESOLUTION_M)
|
||||||
|
height = int((max_y - min_y) * meters_per_deg_lat / MdkBathymetryAcquisitionService.NATIVE_RESOLUTION_M)
|
||||||
|
width = max(1, min(width, MdkBathymetryAcquisitionService.MAX_PIXELS_PER_SIDE))
|
||||||
|
height = max(1, min(height, MdkBathymetryAcquisitionService.MAX_PIXELS_PER_SIDE))
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fetch(request_url: str, settings: Settings, opener: Callable[..., Any] | None = None) -> tuple[bytes, str]:
|
||||||
|
request = Request(
|
||||||
|
request_url,
|
||||||
|
headers={
|
||||||
|
"Accept": "image/tiff,*/*;q=0.1",
|
||||||
|
"User-Agent": "GeoIntel/1.0 MDK-bathymetry-bounded-acquisition",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
max_bytes = settings.mdk_bathymetry_acquisition_max_response_mb * 1024 * 1024
|
||||||
|
try:
|
||||||
|
with (opener or urlopen)(request, timeout=settings.mdk_bathymetry_acquisition_timeout_seconds) as response:
|
||||||
|
content_type = str(response.headers.get("Content-Type", "")) if hasattr(response, "headers") else ""
|
||||||
|
content = response.read(max_bytes + 1)
|
||||||
|
except HTTPError as exc:
|
||||||
|
preview = exc.read(300).decode("utf-8", errors="replace")
|
||||||
|
raise AppError(
|
||||||
|
code="MDK_BATHYMETRY_PROVIDER_UNAVAILABLE",
|
||||||
|
message="The MDK WCS could not complete the bounded GetCoverage request.",
|
||||||
|
details={"provider_status_code": int(exc.code), "response_preview": preview},
|
||||||
|
status_code=502,
|
||||||
|
) from exc
|
||||||
|
except (URLError, TimeoutError, OSError) as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="MDK_BATHYMETRY_PROVIDER_UNAVAILABLE",
|
||||||
|
message="The MDK WCS could not be reached for the bounded GetCoverage request.",
|
||||||
|
details={"reason": str(exc)},
|
||||||
|
status_code=502,
|
||||||
|
) from exc
|
||||||
|
if len(content) > max_bytes:
|
||||||
|
raise AppError(
|
||||||
|
code="MDK_BATHYMETRY_RESPONSE_TOO_LARGE",
|
||||||
|
message="The MDK coverage response exceeds the configured size limit.",
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
if not content.startswith((b"II*\x00", b"MM\x00*")):
|
||||||
|
preview = content[:300].decode("utf-8", errors="replace")
|
||||||
|
raise AppError(
|
||||||
|
code="MDK_BATHYMETRY_INVALID_RESPONSE",
|
||||||
|
message="The MDK WCS did not return a GeoTIFF coverage.",
|
||||||
|
details={"content_type": content_type, "response_preview": preview},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
return content, content_type
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_geotiff(content: bytes) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
from rasterio.io import MemoryFile
|
||||||
|
except ImportError as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="RASTER_PROCESSING_UNAVAILABLE",
|
||||||
|
message="Rasterio is required to validate the MDK bathymetry coverage before persistence.",
|
||||||
|
status_code=503,
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
with MemoryFile(content) as memory, memory.open() as source:
|
||||||
|
if source.count < 1:
|
||||||
|
raise AppError(
|
||||||
|
code="MDK_BATHYMETRY_INVALID_RESPONSE",
|
||||||
|
message="The MDK coverage contains no raster bands.",
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
band = source.read(1, masked=True)
|
||||||
|
valid = band.compressed()
|
||||||
|
if valid.size == 0:
|
||||||
|
raise AppError(
|
||||||
|
code="MDK_BATHYMETRY_NO_VALID_DATA",
|
||||||
|
message="The MDK coverage contains no valid depth cells in this selection.",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"crs": str(source.crs) if source.crs else None,
|
||||||
|
"width": int(source.width),
|
||||||
|
"height": int(source.height),
|
||||||
|
"nodata": None if source.nodata is None else float(source.nodata),
|
||||||
|
"valid_cell_count": int(valid.size),
|
||||||
|
"minimum_value": float(np.min(valid)),
|
||||||
|
"maximum_value": float(np.max(valid)),
|
||||||
|
}
|
||||||
|
except AppError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # rasterio raises many distinct errors for corrupt input
|
||||||
|
raise AppError(
|
||||||
|
code="MDK_BATHYMETRY_INVALID_RESPONSE",
|
||||||
|
message="The MDK coverage could not be opened as a valid GeoTIFF.",
|
||||||
|
details={"reason": str(exc)},
|
||||||
|
status_code=502,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cached_dataset(db, project_id: UUID, filename: str) -> Dataset | None:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
candidate = (
|
||||||
|
db.query(Dataset)
|
||||||
|
.filter(
|
||||||
|
Dataset.project_id == project_id,
|
||||||
|
Dataset.name == filename,
|
||||||
|
Dataset.source_name == MdkBathymetryAcquisitionService.PROVIDER,
|
||||||
|
Dataset.status == "ready",
|
||||||
|
)
|
||||||
|
.order_by(Dataset.imported_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return candidate if candidate and candidate.storage_path and Path(candidate.storage_path).is_file() else None
|
||||||
@@ -24,11 +24,18 @@ class ModelAssetCatalogService:
|
|||||||
if not model_directory.exists() or not model_directory.is_dir():
|
if not model_directory.exists() or not model_directory.is_dir():
|
||||||
return ModelAssetListResponse(items=[], total=0, model_directory=str(model_directory))
|
return ModelAssetListResponse(items=[], total=0, model_directory=str(model_directory))
|
||||||
|
|
||||||
items = [
|
candidate_paths = [
|
||||||
ModelAssetCatalogService._asset_from_file(path, active_model_path=active_model_path)
|
path
|
||||||
for path in sorted(model_directory.iterdir(), key=lambda item: item.name.lower())
|
for path in sorted(model_directory.iterdir(), key=lambda item: item.name.lower())
|
||||||
if path.is_file() and path.suffix.lower() in ModelAssetCatalogService.SUPPORTED_SUFFIXES
|
if path.is_file() and path.suffix.lower() in ModelAssetCatalogService.SUPPORTED_SUFFIXES
|
||||||
]
|
]
|
||||||
|
if active_model_path is not None:
|
||||||
|
candidate_paths = [path for path in candidate_paths if path.resolve() == active_model_path]
|
||||||
|
|
||||||
|
items = [
|
||||||
|
ModelAssetCatalogService._asset_from_file(path, active_model_path=active_model_path)
|
||||||
|
for path in candidate_paths
|
||||||
|
]
|
||||||
return ModelAssetListResponse(items=items, total=len(items), model_directory=str(model_directory))
|
return ModelAssetListResponse(items=items, total=len(items), model_directory=str(model_directory))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -72,8 +79,12 @@ class ModelAssetCatalogService:
|
|||||||
size_bytes=path.stat().st_size,
|
size_bytes=path.stat().st_size,
|
||||||
sha256=ModelAssetCatalogService._sha256(path),
|
sha256=ModelAssetCatalogService._sha256(path),
|
||||||
active=active_model_path == resolved_path,
|
active=active_model_path == resolved_path,
|
||||||
status="available",
|
status="approved" if active_model_path == resolved_path else "available",
|
||||||
limitation_message="Local runtime model asset. GeoIntel will not download or mutate model weights.",
|
limitation_message=(
|
||||||
|
"Approved local runtime model asset. GeoIntel will not download or mutate model weights."
|
||||||
|
if active_model_path == resolved_path
|
||||||
|
else "Local development model asset. Configure it explicitly before production use."
|
||||||
|
),
|
||||||
will_download_models=False,
|
will_download_models=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Type
|
|||||||
|
|
||||||
from app.core.config import Settings, get_settings
|
from app.core.config import Settings, get_settings
|
||||||
from app.schemas.detection import DetectionModelCapability
|
from app.schemas.detection import DetectionModelCapability
|
||||||
|
from app.services.segmentation_adapter import SamSegmentationAdapter, YoloSegmentationAdapter
|
||||||
from app.services.yolo_adapter import YoloDetectionAdapter
|
from app.services.yolo_adapter import YoloDetectionAdapter
|
||||||
|
|
||||||
|
|
||||||
@@ -14,10 +15,16 @@ class ModelRegistryService:
|
|||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
yolo_adapter_class: Type[YoloDetectionAdapter] = YoloDetectionAdapter,
|
yolo_adapter_class: Type[YoloDetectionAdapter] = YoloDetectionAdapter,
|
||||||
task_type: str = "object_detection",
|
task_type: str = "object_detection",
|
||||||
|
yolo_seg_adapter_class: Type[YoloSegmentationAdapter] = YoloSegmentationAdapter,
|
||||||
|
sam_adapter_class: Type[SamSegmentationAdapter] = SamSegmentationAdapter,
|
||||||
) -> list[DetectionModelCapability]:
|
) -> list[DetectionModelCapability]:
|
||||||
resolved_settings = settings or get_settings()
|
resolved_settings = settings or get_settings()
|
||||||
if task_type == "segmentation":
|
if task_type == "segmentation":
|
||||||
return ModelRegistryService.list_segmentation_model_capabilities()
|
return ModelRegistryService.list_segmentation_model_capabilities(
|
||||||
|
settings=resolved_settings,
|
||||||
|
yolo_seg_adapter_class=yolo_seg_adapter_class,
|
||||||
|
sam_adapter_class=sam_adapter_class,
|
||||||
|
)
|
||||||
if task_type != "object_detection":
|
if task_type != "object_detection":
|
||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
@@ -52,15 +59,28 @@ class ModelRegistryService:
|
|||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
yolo_adapter_class: Type[YoloDetectionAdapter] = YoloDetectionAdapter,
|
yolo_adapter_class: Type[YoloDetectionAdapter] = YoloDetectionAdapter,
|
||||||
task_type: str = "object_detection",
|
task_type: str = "object_detection",
|
||||||
|
yolo_seg_adapter_class: Type[YoloSegmentationAdapter] = YoloSegmentationAdapter,
|
||||||
|
sam_adapter_class: Type[SamSegmentationAdapter] = SamSegmentationAdapter,
|
||||||
) -> DetectionModelCapability | None:
|
) -> DetectionModelCapability | None:
|
||||||
normalized = model_id.strip()
|
normalized = model_id.strip()
|
||||||
for model in ModelRegistryService.list_model_capabilities(settings=settings, yolo_adapter_class=yolo_adapter_class, task_type=task_type):
|
for model in ModelRegistryService.list_model_capabilities(
|
||||||
|
settings=settings,
|
||||||
|
yolo_adapter_class=yolo_adapter_class,
|
||||||
|
task_type=task_type,
|
||||||
|
yolo_seg_adapter_class=yolo_seg_adapter_class,
|
||||||
|
sam_adapter_class=sam_adapter_class,
|
||||||
|
):
|
||||||
if model.model_id == normalized:
|
if model.model_id == normalized:
|
||||||
return model
|
return model
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_segmentation_model_capabilities() -> list[DetectionModelCapability]:
|
def list_segmentation_model_capabilities(
|
||||||
|
settings: Settings | None = None,
|
||||||
|
yolo_seg_adapter_class: Type[YoloSegmentationAdapter] = YoloSegmentationAdapter,
|
||||||
|
sam_adapter_class: Type[SamSegmentationAdapter] = SamSegmentationAdapter,
|
||||||
|
) -> list[DetectionModelCapability]:
|
||||||
|
resolved_settings = settings or get_settings()
|
||||||
return [
|
return [
|
||||||
DetectionModelCapability(
|
DetectionModelCapability(
|
||||||
model_id="segmentation-placeholder",
|
model_id="segmentation-placeholder",
|
||||||
@@ -70,7 +90,7 @@ class ModelRegistryService:
|
|||||||
supported_classes=["building", "vegetation", "water", "landuse"],
|
supported_classes=["building", "vegetation", "water", "landuse"],
|
||||||
configured=False,
|
configured=False,
|
||||||
status="not_configured",
|
status="not_configured",
|
||||||
limitation_message="Segmentation inference is not configured in Sprint 9; no SAM/YOLO-seg model is downloaded or executed.",
|
limitation_message="Segmentation inference is not configured for this placeholder; no model is downloaded or executed.",
|
||||||
version=None,
|
version=None,
|
||||||
),
|
),
|
||||||
DetectionModelCapability(
|
DetectionModelCapability(
|
||||||
@@ -84,30 +104,86 @@ class ModelRegistryService:
|
|||||||
limitation_message="Fixture segmenter is for explicit tests/demo fixtures only and is not production inference.",
|
limitation_message="Fixture segmenter is for explicit tests/demo fixtures only and is not production inference.",
|
||||||
version="fixture-v1",
|
version="fixture-v1",
|
||||||
),
|
),
|
||||||
DetectionModelCapability(
|
ModelRegistryService._configured_yolo_seg_capability(resolved_settings, yolo_seg_adapter_class),
|
||||||
model_id="yolo-seg-configured",
|
ModelRegistryService._configured_sam_capability(resolved_settings, sam_adapter_class),
|
||||||
display_name="Configured YOLO segmentation",
|
|
||||||
framework="ultralytics/pytorch",
|
|
||||||
task_type="segmentation",
|
|
||||||
supported_classes=["building", "vegetation", "water", "landuse"],
|
|
||||||
configured=False,
|
|
||||||
status="not_configured",
|
|
||||||
limitation_message="YOLO-seg is not configured in Sprint 9. GeoIntel will not download segmentation model weights automatically.",
|
|
||||||
version=None,
|
|
||||||
),
|
|
||||||
DetectionModelCapability(
|
|
||||||
model_id="sam-configured",
|
|
||||||
display_name="Configured SAM segmentation",
|
|
||||||
framework="sam",
|
|
||||||
task_type="segmentation",
|
|
||||||
supported_classes=["building", "vegetation", "water", "landuse"],
|
|
||||||
configured=False,
|
|
||||||
status="not_configured",
|
|
||||||
limitation_message="SAM is not configured in Sprint 9 and is not installed as a backend dependency.",
|
|
||||||
version=None,
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _configured_yolo_seg_capability(
|
||||||
|
settings: Settings,
|
||||||
|
adapter_class: Type[YoloSegmentationAdapter] = YoloSegmentationAdapter,
|
||||||
|
) -> DetectionModelCapability:
|
||||||
|
configured = False
|
||||||
|
status = "not_configured"
|
||||||
|
limitation = (
|
||||||
|
"YOLO segmentation is disabled. Set YOLO_SEG_ENABLED=true and YOLO_SEG_MODEL_PATH to a local "
|
||||||
|
"segmentation model file to enable inference. GeoIntel never downloads model weights automatically."
|
||||||
|
)
|
||||||
|
model_path = Path(settings.yolo_seg_model_path).expanduser() if settings.yolo_seg_model_path else None
|
||||||
|
|
||||||
|
if settings.yolo_seg_enabled:
|
||||||
|
if not adapter_class.dependencies_available():
|
||||||
|
status = "dependency_unavailable"
|
||||||
|
limitation = "Segmentation dependencies are not installed. Install backend optional extras with geointel-backend[ai]."
|
||||||
|
elif model_path is None:
|
||||||
|
limitation = "YOLO_SEG_MODEL_PATH is not set. GeoIntel will not download segmentation model weights automatically."
|
||||||
|
elif not model_path.exists() or not model_path.is_file():
|
||||||
|
limitation = "YOLO_SEG_MODEL_PATH does not point to an existing local model file. GeoIntel will not download segmentation model weights automatically."
|
||||||
|
else:
|
||||||
|
configured = True
|
||||||
|
status = "configured"
|
||||||
|
limitation = "Configured for local YOLO segmentation inference over an existing raster tile manifest."
|
||||||
|
|
||||||
|
return DetectionModelCapability(
|
||||||
|
model_id=settings.yolo_seg_model_id,
|
||||||
|
display_name=settings.yolo_seg_model_display_name,
|
||||||
|
framework="ultralytics/pytorch",
|
||||||
|
task_type="segmentation",
|
||||||
|
supported_classes=["building", "vegetation", "water", "landuse"],
|
||||||
|
configured=configured,
|
||||||
|
status=status,
|
||||||
|
limitation_message=limitation,
|
||||||
|
version=settings.yolo_seg_model_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _configured_sam_capability(
|
||||||
|
settings: Settings,
|
||||||
|
adapter_class: Type[SamSegmentationAdapter] = SamSegmentationAdapter,
|
||||||
|
) -> DetectionModelCapability:
|
||||||
|
configured = False
|
||||||
|
status = "not_configured"
|
||||||
|
limitation = (
|
||||||
|
"SAM is disabled. Set SAM_ENABLED=true and SAM_MODEL_PATH to a local SAM-compatible model file to "
|
||||||
|
"enable class-agnostic segmentation. GeoIntel never downloads model weights automatically."
|
||||||
|
)
|
||||||
|
model_path = Path(settings.sam_model_path).expanduser() if settings.sam_model_path else None
|
||||||
|
|
||||||
|
if settings.sam_enabled:
|
||||||
|
if not adapter_class.dependencies_available():
|
||||||
|
status = "dependency_unavailable"
|
||||||
|
limitation = "Segmentation dependencies are not installed. Install backend optional extras with geointel-backend[ai]."
|
||||||
|
elif model_path is None:
|
||||||
|
limitation = "SAM_MODEL_PATH is not set. GeoIntel will not download segmentation model weights automatically."
|
||||||
|
elif not model_path.exists() or not model_path.is_file():
|
||||||
|
limitation = "SAM_MODEL_PATH does not point to an existing local model file. GeoIntel will not download segmentation model weights automatically."
|
||||||
|
else:
|
||||||
|
configured = True
|
||||||
|
status = "configured"
|
||||||
|
limitation = "Configured for local class-agnostic SAM segmentation over an existing raster tile manifest."
|
||||||
|
|
||||||
|
return DetectionModelCapability(
|
||||||
|
model_id=settings.sam_model_id,
|
||||||
|
display_name=settings.sam_model_display_name,
|
||||||
|
framework="ultralytics/sam",
|
||||||
|
task_type="segmentation",
|
||||||
|
supported_classes=["segment"],
|
||||||
|
configured=configured,
|
||||||
|
status=status,
|
||||||
|
limitation_message=limitation,
|
||||||
|
version=settings.sam_model_version,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _configured_yolo_capability(
|
def _configured_yolo_capability(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ class OfficialVectorProduct:
|
|||||||
response_crs: str = "EPSG:4326"
|
response_crs: str = "EPSG:4326"
|
||||||
identity_field: str | None = None
|
identity_field: str | None = None
|
||||||
requires_coverage_area: bool = False
|
requires_coverage_area: bool = False
|
||||||
|
property_filter: dict[str, tuple[str, ...]] | None = None
|
||||||
|
|
||||||
|
|
||||||
class OfficialVectorAcquisitionService:
|
class OfficialVectorAcquisitionService:
|
||||||
@@ -509,6 +510,57 @@ class OfficialVectorAcquisitionService:
|
|||||||
identity_field="INSPIRE_ID",
|
identity_field="INSPIRE_ID",
|
||||||
requires_coverage_area=True,
|
requires_coverage_area=True,
|
||||||
),
|
),
|
||||||
|
OfficialVectorProduct(
|
||||||
|
key="urbis_street_axes",
|
||||||
|
display_name="UrbIS street axes",
|
||||||
|
theme="roads",
|
||||||
|
provider="Paradigm Brussels",
|
||||||
|
source_name="urbis",
|
||||||
|
reference_layer_name="roads",
|
||||||
|
service_type="WFS 2.0",
|
||||||
|
collection="urbisvector:StreetAxes",
|
||||||
|
source_crs="EPSG:31370",
|
||||||
|
source_version="2026-06-06",
|
||||||
|
observation_label="UrbIS revision 6 June 2026",
|
||||||
|
authority_level="authoritative",
|
||||||
|
catalog_url=(
|
||||||
|
"https://datastore.brussels/web/data/dataset/"
|
||||||
|
"2cf42541-1813-11ef-8a81-00090ffe0001"
|
||||||
|
),
|
||||||
|
attribution="Paradigm Brussels - UrbIS",
|
||||||
|
license_note="UrbIS topographic layers are published under CC0.",
|
||||||
|
limitation_message=(
|
||||||
|
"UrbIS street axes describe topographic road geometry for the Brussels-Capital "
|
||||||
|
"Region and are not a routing network or a traffic measurement."
|
||||||
|
),
|
||||||
|
source="UrbIS WFS",
|
||||||
|
observed_at=datetime(2026, 6, 6, tzinfo=UTC),
|
||||||
|
valid_from=None,
|
||||||
|
valid_to=None,
|
||||||
|
primary_metric={
|
||||||
|
"metric_key": "road_length",
|
||||||
|
"method": "intersection_length",
|
||||||
|
"label": "Wegaslengte",
|
||||||
|
"unit": "km",
|
||||||
|
"geometry_dimension": 1,
|
||||||
|
"is_estimate": False,
|
||||||
|
},
|
||||||
|
selection_metrics=(
|
||||||
|
{
|
||||||
|
"metric_key": "road_segment_count",
|
||||||
|
"method": "feature_count",
|
||||||
|
"label": "Wegsegmenten",
|
||||||
|
"unit": "objecten",
|
||||||
|
"geometry_dimension": 1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
geometry_types=("LineString", "MultiLineString"),
|
||||||
|
coverage_zones=("brussels",),
|
||||||
|
endpoint_kind="urbis_wfs",
|
||||||
|
response_crs="EPSG:31370",
|
||||||
|
identity_field="INSPIRE_ID",
|
||||||
|
requires_coverage_area=True,
|
||||||
|
),
|
||||||
OfficialVectorProduct(
|
OfficialVectorProduct(
|
||||||
key="urbis_cadastral_parcels",
|
key="urbis_cadastral_parcels",
|
||||||
display_name="UrbIS cadastral parcels",
|
display_name="UrbIS cadastral parcels",
|
||||||
@@ -561,6 +613,143 @@ class OfficialVectorAcquisitionService:
|
|||||||
identity_field="INSPIRE_ID",
|
identity_field="INSPIRE_ID",
|
||||||
requires_coverage_area=True,
|
requires_coverage_area=True,
|
||||||
),
|
),
|
||||||
|
OfficialVectorProduct(
|
||||||
|
key="urbis_land_cover_blocks",
|
||||||
|
display_name="UrbIS land cover blocks",
|
||||||
|
theme="space_occupation",
|
||||||
|
provider="Paradigm Brussels",
|
||||||
|
source_name="urbis",
|
||||||
|
reference_layer_name="space_occupation",
|
||||||
|
service_type="WFS 2.0",
|
||||||
|
collection="urbisvector:Blocks",
|
||||||
|
source_crs="EPSG:31370",
|
||||||
|
source_version="UrbIS Land Cover 1.0; live WFS checked 2026-07-22",
|
||||||
|
observation_label="Current UrbIS land-cover WFS",
|
||||||
|
authority_level="authoritative",
|
||||||
|
catalog_url="https://urbisdownload.datastore.brussels/UrbIS/TechSpec/LandCover_TechSpec_NL20240401.pdf",
|
||||||
|
attribution="Paradigm Brussels - UrbIS Land Cover",
|
||||||
|
license_note="UrbIS Land Cover is available through the official download and WFS service; retain source attribution.",
|
||||||
|
limitation_message=(
|
||||||
|
"UrbIS blocks describe physical and biological land cover. They are not zoning, ownership or legal land use. "
|
||||||
|
"The WFS does not expose a separate observation date per feature."
|
||||||
|
),
|
||||||
|
source="UrbIS WFS",
|
||||||
|
observed_at=None,
|
||||||
|
valid_from=None,
|
||||||
|
valid_to=None,
|
||||||
|
primary_metric={
|
||||||
|
"metric_key": "land_cover_area",
|
||||||
|
"method": "intersection_area",
|
||||||
|
"label": "Landbedekking",
|
||||||
|
"unit": "ha",
|
||||||
|
"geometry_dimension": 2,
|
||||||
|
"is_estimate": False,
|
||||||
|
},
|
||||||
|
selection_metrics=(
|
||||||
|
{
|
||||||
|
"metric_key": "land_cover_block_count",
|
||||||
|
"method": "feature_count",
|
||||||
|
"label": "Landbedekkingsblokken",
|
||||||
|
"unit": "objecten",
|
||||||
|
"geometry_dimension": 2,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
coverage_zones=("brussels",),
|
||||||
|
endpoint_kind="urbis_wfs",
|
||||||
|
response_crs="EPSG:31370",
|
||||||
|
identity_field="INSPIRE_ID",
|
||||||
|
requires_coverage_area=True,
|
||||||
|
),
|
||||||
|
OfficialVectorProduct(
|
||||||
|
key="urbis_forest_parks",
|
||||||
|
display_name="UrbIS forests and parks",
|
||||||
|
theme="forest",
|
||||||
|
provider="Paradigm Brussels",
|
||||||
|
source_name="urbis",
|
||||||
|
reference_layer_name="forest",
|
||||||
|
service_type="WFS 2.0",
|
||||||
|
collection="urbisvector:Blocks",
|
||||||
|
source_crs="EPSG:31370",
|
||||||
|
source_version="UrbIS Land Cover 1.0; live WFS checked 2026-07-22",
|
||||||
|
observation_label="Current UrbIS land-cover WFS",
|
||||||
|
authority_level="authoritative",
|
||||||
|
catalog_url="https://urbisdownload.datastore.brussels/UrbIS/TechSpec/LandCover_TechSpec_NL20240401.pdf",
|
||||||
|
attribution="Paradigm Brussels - UrbIS Land Cover",
|
||||||
|
license_note="UrbIS Land Cover is available through the official download and WFS service; retain source attribution.",
|
||||||
|
limitation_message="Includes only UrbIS block types FO (forest/woodland) and GB (parks); street trees and smaller green elements are not inferred.",
|
||||||
|
source="UrbIS WFS",
|
||||||
|
observed_at=None,
|
||||||
|
valid_from=None,
|
||||||
|
valid_to=None,
|
||||||
|
primary_metric={
|
||||||
|
"metric_key": "forest_park_area",
|
||||||
|
"method": "intersection_area",
|
||||||
|
"label": "Bos- en parkoppervlakte",
|
||||||
|
"unit": "ha",
|
||||||
|
"geometry_dimension": 2,
|
||||||
|
"is_estimate": False,
|
||||||
|
},
|
||||||
|
selection_metrics=(
|
||||||
|
{
|
||||||
|
"metric_key": "forest_park_count",
|
||||||
|
"method": "feature_count",
|
||||||
|
"label": "Bos- en parkzones",
|
||||||
|
"unit": "objecten",
|
||||||
|
"geometry_dimension": 2,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
coverage_zones=("brussels",),
|
||||||
|
endpoint_kind="urbis_wfs",
|
||||||
|
response_crs="EPSG:31370",
|
||||||
|
identity_field="INSPIRE_ID",
|
||||||
|
requires_coverage_area=True,
|
||||||
|
property_filter={"TYPE": ("FO", "GB")},
|
||||||
|
),
|
||||||
|
OfficialVectorProduct(
|
||||||
|
key="urbis_water_surfaces",
|
||||||
|
display_name="UrbIS permanent water surfaces",
|
||||||
|
theme="water",
|
||||||
|
provider="Paradigm Brussels",
|
||||||
|
source_name="urbis",
|
||||||
|
reference_layer_name="water",
|
||||||
|
service_type="WFS 2.0",
|
||||||
|
collection="urbisvector:Blocks",
|
||||||
|
source_crs="EPSG:31370",
|
||||||
|
source_version="UrbIS Land Cover 1.0; live WFS checked 2026-07-22",
|
||||||
|
observation_label="Current UrbIS land-cover WFS",
|
||||||
|
authority_level="authoritative",
|
||||||
|
catalog_url="https://urbisdownload.datastore.brussels/UrbIS/TechSpec/LandCover_TechSpec_NL20240401.pdf",
|
||||||
|
attribution="Paradigm Brussels - UrbIS Land Cover",
|
||||||
|
license_note="UrbIS Land Cover is available through the official download and WFS service; retain source attribution.",
|
||||||
|
limitation_message="Includes only UrbIS block type WB: canals, lakes and watercourses with predominantly permanent water.",
|
||||||
|
source="UrbIS WFS",
|
||||||
|
observed_at=None,
|
||||||
|
valid_from=None,
|
||||||
|
valid_to=None,
|
||||||
|
primary_metric={
|
||||||
|
"metric_key": "water_surface_area",
|
||||||
|
"method": "intersection_area",
|
||||||
|
"label": "Permanent wateroppervlak",
|
||||||
|
"unit": "ha",
|
||||||
|
"geometry_dimension": 2,
|
||||||
|
"is_estimate": False,
|
||||||
|
},
|
||||||
|
selection_metrics=(
|
||||||
|
{
|
||||||
|
"metric_key": "water_surface_count",
|
||||||
|
"method": "feature_count",
|
||||||
|
"label": "Waterzones",
|
||||||
|
"unit": "objecten",
|
||||||
|
"geometry_dimension": 2,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
coverage_zones=("brussels",),
|
||||||
|
endpoint_kind="urbis_wfs",
|
||||||
|
response_crs="EPSG:31370",
|
||||||
|
identity_field="INSPIRE_ID",
|
||||||
|
requires_coverage_area=True,
|
||||||
|
property_filter={"TYPE": ("WB",)},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return {product.key: product for product in products}
|
return {product.key: product for product in products}
|
||||||
|
|
||||||
@@ -1081,6 +1270,12 @@ class OfficialVectorAcquisitionService:
|
|||||||
scope_metric: Any,
|
scope_metric: Any,
|
||||||
coverage_scope: str,
|
coverage_scope: str,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
|
raw = dict(feature.get("properties") or {})
|
||||||
|
if product.property_filter and any(
|
||||||
|
str(raw.get(property_name) or "") not in allowed_values
|
||||||
|
for property_name, allowed_values in product.property_filter.items()
|
||||||
|
):
|
||||||
|
return None
|
||||||
dimension = 2 if any("Polygon" in item for item in product.geometry_types) else 1
|
dimension = 2 if any("Polygon" in item for item in product.geometry_types) else 1
|
||||||
try:
|
try:
|
||||||
source_geometry = shape(feature.get("geometry"))
|
source_geometry = shape(feature.get("geometry"))
|
||||||
@@ -1122,7 +1317,6 @@ class OfficialVectorAcquisitionService:
|
|||||||
)
|
)
|
||||||
if clipped_wgs84 is None:
|
if clipped_wgs84 is None:
|
||||||
return None
|
return None
|
||||||
raw = dict(feature.get("properties") or {})
|
|
||||||
identity = (
|
identity = (
|
||||||
raw.get(product.identity_field or "")
|
raw.get(product.identity_field or "")
|
||||||
or feature.get("id")
|
or feature.get("id")
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ class ProjectService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_project(db: Session, payload: ProjectCreate) -> ProjectRead:
|
def create_project(db: Session, payload: ProjectCreate) -> ProjectRead:
|
||||||
project = Project(name=payload.name.strip(), description=(payload.description or "").strip() or None, region=payload.region or "Kempen")
|
project = Project(
|
||||||
|
name=payload.name.strip(),
|
||||||
|
description=(payload.description or "").strip() or None,
|
||||||
|
region=payload.region or "Belgium and Belgian North Sea",
|
||||||
|
)
|
||||||
db.add(project)
|
db.add(project)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(project)
|
db.refresh(project)
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.services.yolo_adapter import _prediction_source, _to_list
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SegmentationAdapterResult:
|
class SegmentationAdapterResult:
|
||||||
@@ -23,6 +28,159 @@ class SegmentationAdapter(Protocol):
|
|||||||
"""Future segmentation adapters must local-import model dependencies inside execution paths."""
|
"""Future segmentation adapters must local-import model dependencies inside execution paths."""
|
||||||
|
|
||||||
|
|
||||||
|
class _UltralyticsSegmentationAdapterBase:
|
||||||
|
"""Shared local-inference plumbing for ultralytics-backed segmentation models.
|
||||||
|
|
||||||
|
Model weights are never downloaded automatically; a missing local file or
|
||||||
|
missing dependency fails closed with an explicit error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dependencies_available() -> bool:
|
||||||
|
try:
|
||||||
|
import torch # noqa: F401
|
||||||
|
import ultralytics # noqa: F401
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _require_model_file(self, model_path: Path) -> None:
|
||||||
|
if not model_path.exists() or not model_path.is_file():
|
||||||
|
raise AppError(
|
||||||
|
code="SEGMENTATION_MODEL_UNAVAILABLE",
|
||||||
|
message="Configured segmentation model file does not exist",
|
||||||
|
details={"model_path": str(model_path)},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
if not self.dependencies_available():
|
||||||
|
raise AppError(
|
||||||
|
code="SEGMENTATION_DEPENDENCY_UNAVAILABLE",
|
||||||
|
message="Segmentation dependencies are not installed. Install backend optional extras with geointel-backend[ai].",
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _predict(self, model, tile_path: Path, confidence_threshold: float) -> list[Any]:
|
||||||
|
if not tile_path.exists() or not tile_path.is_file():
|
||||||
|
raise AppError(
|
||||||
|
code="SEGMENTATION_TILE_NOT_FOUND",
|
||||||
|
message="Tile referenced by manifest does not exist",
|
||||||
|
details={"tile_path": str(tile_path)},
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with _prediction_source(tile_path) as prediction_source:
|
||||||
|
return model.predict(
|
||||||
|
source=prediction_source,
|
||||||
|
conf=float(confidence_threshold),
|
||||||
|
imgsz=int(self.settings.yolo_image_size),
|
||||||
|
device=self.settings.yolo_device,
|
||||||
|
verbose=False,
|
||||||
|
)
|
||||||
|
except AppError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="SEGMENTATION_INFERENCE_FAILED",
|
||||||
|
message="Configured segmentation inference failed for a raster tile",
|
||||||
|
details={"tile_path": str(tile_path), "error": str(exc)},
|
||||||
|
status_code=503,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def _extract_masks(self, results: list[Any], default_class_name: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
segmentations: list[dict[str, Any]] = []
|
||||||
|
max_masks = int(self.settings.segmentation_max_masks_per_tile)
|
||||||
|
for result in results:
|
||||||
|
names = getattr(result, "names", {}) or {}
|
||||||
|
masks = getattr(result, "masks", None)
|
||||||
|
if masks is None:
|
||||||
|
continue
|
||||||
|
polygons = getattr(masks, "xy", None) or []
|
||||||
|
boxes = getattr(result, "boxes", None)
|
||||||
|
confidence_values = _to_list(getattr(boxes, "conf", [])) if boxes is not None else []
|
||||||
|
class_values = _to_list(getattr(boxes, "cls", [])) if boxes is not None else []
|
||||||
|
bbox_values = _to_list(getattr(boxes, "xyxy", [])) if boxes is not None else []
|
||||||
|
for index, polygon in enumerate(polygons):
|
||||||
|
if len(segmentations) >= max_masks:
|
||||||
|
return segmentations
|
||||||
|
points = _to_list(polygon)
|
||||||
|
if not isinstance(points, list) or len(points) < 3:
|
||||||
|
continue
|
||||||
|
class_id = int(class_values[index]) if index < len(class_values) else -1
|
||||||
|
if default_class_name is not None:
|
||||||
|
class_name = default_class_name
|
||||||
|
else:
|
||||||
|
class_name = str(names.get(class_id, class_id))
|
||||||
|
confidence = float(confidence_values[index]) if index < len(confidence_values) else None
|
||||||
|
bbox = [float(value) for value in bbox_values[index]] if index < len(bbox_values) else None
|
||||||
|
segmentations.append(
|
||||||
|
{
|
||||||
|
"class_name": class_name,
|
||||||
|
"confidence": confidence,
|
||||||
|
"points": [[float(point[0]), float(point[1])] for point in points],
|
||||||
|
"bbox": bbox,
|
||||||
|
"properties": {"class_id": class_id},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return segmentations
|
||||||
|
|
||||||
|
|
||||||
|
class YoloSegmentationAdapter(_UltralyticsSegmentationAdapterBase):
|
||||||
|
def load_model(self, model_path: Path):
|
||||||
|
self._require_model_file(model_path)
|
||||||
|
try:
|
||||||
|
from ultralytics import YOLO
|
||||||
|
except ImportError as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="SEGMENTATION_DEPENDENCY_UNAVAILABLE",
|
||||||
|
message="YOLO segmentation dependencies are not importable. Install backend optional extras with geointel-backend[ai].",
|
||||||
|
status_code=503,
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
return YOLO(str(model_path))
|
||||||
|
except Exception as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="SEGMENTATION_MODEL_LOAD_FAILED",
|
||||||
|
message="Configured YOLO segmentation model could not be loaded",
|
||||||
|
details={"model_path": str(model_path)},
|
||||||
|
status_code=503,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict[str, Any]]:
|
||||||
|
results = self._predict(model, tile_path, confidence_threshold)
|
||||||
|
return self._extract_masks(results)
|
||||||
|
|
||||||
|
|
||||||
|
class SamSegmentationAdapter(_UltralyticsSegmentationAdapterBase):
|
||||||
|
"""Class-agnostic SAM segmentation through the ultralytics SAM interface."""
|
||||||
|
|
||||||
|
def load_model(self, model_path: Path):
|
||||||
|
self._require_model_file(model_path)
|
||||||
|
try:
|
||||||
|
from ultralytics import SAM
|
||||||
|
except ImportError as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="SEGMENTATION_DEPENDENCY_UNAVAILABLE",
|
||||||
|
message="SAM segmentation requires the ultralytics SAM interface. Install backend optional extras with geointel-backend[ai].",
|
||||||
|
status_code=503,
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
return SAM(str(model_path))
|
||||||
|
except Exception as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="SEGMENTATION_MODEL_LOAD_FAILED",
|
||||||
|
message="Configured SAM model could not be loaded",
|
||||||
|
details={"model_path": str(model_path)},
|
||||||
|
status_code=503,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict[str, Any]]:
|
||||||
|
results = self._predict(model, tile_path, confidence_threshold)
|
||||||
|
return self._extract_masks(results, default_class_name="segment")
|
||||||
|
|
||||||
|
|
||||||
class FixtureSegmentationAdapter:
|
class FixtureSegmentationAdapter:
|
||||||
def segment(self, raw_segmentations: Any) -> list[SegmentationAdapterResult]:
|
def segment(self, raw_segmentations: Any) -> list[SegmentationAdapterResult]:
|
||||||
if not isinstance(raw_segmentations, list):
|
if not isinstance(raw_segmentations, list):
|
||||||
|
|||||||
@@ -19,10 +19,16 @@ from app.schemas.segmentation import (
|
|||||||
SegmentationRunRead,
|
SegmentationRunRead,
|
||||||
SegmentationRunResponse,
|
SegmentationRunResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.detection_georeferencing import pixel_points_to_epsg4326_polygon
|
||||||
|
from app.services.detection_service import DetectionService
|
||||||
from app.services.model_registry_service import ModelRegistryService
|
from app.services.model_registry_service import ModelRegistryService
|
||||||
from app.services.qa_service import QaService
|
from app.services.qa_service import QaService
|
||||||
from app.services.quality_service import QualityService
|
from app.services.quality_service import QualityService
|
||||||
from app.services.segmentation_adapter import FixtureSegmentationAdapter
|
from app.services.segmentation_adapter import (
|
||||||
|
FixtureSegmentationAdapter,
|
||||||
|
SamSegmentationAdapter,
|
||||||
|
YoloSegmentationAdapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SegmentationService:
|
class SegmentationService:
|
||||||
@@ -41,6 +47,8 @@ class SegmentationService:
|
|||||||
tile_manifest_path: str | None = None,
|
tile_manifest_path: str | None = None,
|
||||||
parameters_json: dict[str, Any] | None = None,
|
parameters_json: dict[str, Any] | None = None,
|
||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
|
yolo_seg_adapter_class: type[YoloSegmentationAdapter] = YoloSegmentationAdapter,
|
||||||
|
sam_adapter_class: type[SamSegmentationAdapter] = SamSegmentationAdapter,
|
||||||
) -> SegmentationRunResponse:
|
) -> SegmentationRunResponse:
|
||||||
parameters = dict(parameters_json or {})
|
parameters = dict(parameters_json or {})
|
||||||
resolved_settings = settings or get_settings()
|
resolved_settings = settings or get_settings()
|
||||||
@@ -58,7 +66,13 @@ class SegmentationService:
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
model = ModelRegistryService.get_model_capability(model_id, task_type="segmentation")
|
model = ModelRegistryService.get_model_capability(
|
||||||
|
model_id,
|
||||||
|
settings=resolved_settings,
|
||||||
|
task_type="segmentation",
|
||||||
|
yolo_seg_adapter_class=yolo_seg_adapter_class,
|
||||||
|
sam_adapter_class=sam_adapter_class,
|
||||||
|
)
|
||||||
if model is None:
|
if model is None:
|
||||||
raise AppError(code="SEGMENTATION_MODEL_NOT_FOUND", message="Segmentation model not found", status_code=404)
|
raise AppError(code="SEGMENTATION_MODEL_NOT_FOUND", message="Segmentation model not found", status_code=404)
|
||||||
if model.model_id == "fixture-segmenter" and parameters.get("fixture_mode") is not True:
|
if model.model_id == "fixture-segmenter" and parameters.get("fixture_mode") is not True:
|
||||||
@@ -67,6 +81,13 @@ class SegmentationService:
|
|||||||
message="Fixture segmenter requires explicit fixture_mode=true",
|
message="Fixture segmenter requires explicit fixture_mode=true",
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
configured_model_ids = {resolved_settings.yolo_seg_model_id, resolved_settings.sam_model_id}
|
||||||
|
if model.model_id in configured_model_ids and model.configured and not tile_manifest_path:
|
||||||
|
raise AppError(
|
||||||
|
code="SEGMENTATION_TILE_MANIFEST_REQUIRED",
|
||||||
|
message="Configured segmentation inference requires an existing raster tile manifest path",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
run_parameters = {
|
run_parameters = {
|
||||||
"model_id": model.model_id,
|
"model_id": model.model_id,
|
||||||
@@ -100,19 +121,24 @@ class SegmentationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if model.model_id == "fixture-segmenter":
|
if model.model_id == "fixture-segmenter":
|
||||||
segmentations = SegmentationService._persist_fixture_segmentations(
|
try:
|
||||||
db=db,
|
segmentations = SegmentationService._persist_fixture_segmentations(
|
||||||
project_id=project_id,
|
db=db,
|
||||||
dataset_id=dataset_id,
|
project_id=project_id,
|
||||||
analysis_run=analysis_run,
|
dataset_id=dataset_id,
|
||||||
job=job,
|
analysis_run=analysis_run,
|
||||||
model_name=model.model_id,
|
job=job,
|
||||||
model_version=model.version,
|
model_name=model.model_id,
|
||||||
raw_segmentations=parameters.get("fixture_segmentations"),
|
model_version=model.version,
|
||||||
confidence_threshold=confidence_threshold,
|
raw_segmentations=parameters.get("fixture_segmentations"),
|
||||||
class_filter=class_filter or [],
|
confidence_threshold=confidence_threshold,
|
||||||
settings=resolved_settings,
|
class_filter=class_filter or [],
|
||||||
)
|
settings=resolved_settings,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# A rejected fixture payload must never leave the run stuck in "running".
|
||||||
|
SegmentationService._fail_run_after_exception(db, analysis_run, job, exc, fallback_code="SEGMENTATION_INTERNAL_ERROR")
|
||||||
|
raise
|
||||||
SegmentationService._mark_success(db, analysis_run, job, segmentation_count=len(segmentations))
|
SegmentationService._mark_success(db, analysis_run, job, segmentation_count=len(segmentations))
|
||||||
return SegmentationRunResponse(
|
return SegmentationRunResponse(
|
||||||
analysis_run_id=analysis_run.id,
|
analysis_run_id=analysis_run.id,
|
||||||
@@ -125,8 +151,80 @@ class SegmentationService:
|
|||||||
message="Fixture segmentations persisted.",
|
message="Fixture segmentations persisted.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if model.model_id in configured_model_ids:
|
||||||
|
try:
|
||||||
|
segmentations, postprocess_summary = SegmentationService._run_configured_segmentation(
|
||||||
|
db=db,
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
analysis_run=analysis_run,
|
||||||
|
job=job,
|
||||||
|
model_name=model.model_id,
|
||||||
|
model_version=model.version,
|
||||||
|
tile_manifest_path=tile_manifest_path,
|
||||||
|
confidence_threshold=confidence_threshold,
|
||||||
|
class_filter=class_filter or [],
|
||||||
|
settings=resolved_settings,
|
||||||
|
yolo_seg_adapter_class=yolo_seg_adapter_class,
|
||||||
|
sam_adapter_class=sam_adapter_class,
|
||||||
|
)
|
||||||
|
except AppError as exc:
|
||||||
|
SegmentationService._mark_failed(db, analysis_run, job, code=exc.code, message=exc.message)
|
||||||
|
return SegmentationRunResponse(
|
||||||
|
analysis_run_id=analysis_run.id,
|
||||||
|
job_id=job.id,
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
model_id=model.model_id,
|
||||||
|
status="failed",
|
||||||
|
segmentation_count=0,
|
||||||
|
error_code=exc.code,
|
||||||
|
message=exc.message,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# An unexpected inference error must never leave the run stuck in "running".
|
||||||
|
SegmentationService._fail_run_after_exception(db, analysis_run, job, exc, fallback_code="SEGMENTATION_INTERNAL_ERROR")
|
||||||
|
raise
|
||||||
|
SegmentationService._mark_success(
|
||||||
|
db,
|
||||||
|
analysis_run,
|
||||||
|
job,
|
||||||
|
segmentation_count=len(segmentations),
|
||||||
|
extra_result=postprocess_summary,
|
||||||
|
)
|
||||||
|
return SegmentationRunResponse(
|
||||||
|
analysis_run_id=analysis_run.id,
|
||||||
|
job_id=job.id,
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
model_id=model.model_id,
|
||||||
|
status="success",
|
||||||
|
segmentation_count=len(segmentations),
|
||||||
|
message="Configured segmentation inference persisted georeferenced masks.",
|
||||||
|
)
|
||||||
|
|
||||||
|
SegmentationService._mark_failed(
|
||||||
|
db,
|
||||||
|
analysis_run,
|
||||||
|
job,
|
||||||
|
code="SEGMENTATION_MODEL_UNAVAILABLE",
|
||||||
|
message="Segmentation model is unavailable",
|
||||||
|
)
|
||||||
raise AppError(code="SEGMENTATION_MODEL_UNAVAILABLE", message="Segmentation model is unavailable", status_code=503)
|
raise AppError(code="SEGMENTATION_MODEL_UNAVAILABLE", message="Segmentation model is unavailable", status_code=503)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fail_run_after_exception(db, analysis_run: AnalysisRun, job: Job, exc: Exception, fallback_code: str) -> None:
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
code = getattr(exc, "code", None) or fallback_code
|
||||||
|
message = getattr(exc, "message", None) or "Unexpected internal error during analysis run"
|
||||||
|
try:
|
||||||
|
SegmentationService._mark_failed(db, analysis_run, job, code=str(code), message=str(message))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_run(db, analysis_run_id: uuid.UUID) -> SegmentationRunRead:
|
def get_run(db, analysis_run_id: uuid.UUID) -> SegmentationRunRead:
|
||||||
run = db.get(AnalysisRun, analysis_run_id)
|
run = db.get(AnalysisRun, analysis_run_id)
|
||||||
@@ -378,8 +476,10 @@ class SegmentationService:
|
|||||||
db.refresh(job)
|
db.refresh(job)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _mark_success(db, analysis_run: AnalysisRun, job: Job, segmentation_count: int) -> None:
|
def _mark_success(db, analysis_run: AnalysisRun, job: Job, segmentation_count: int, extra_result: dict[str, Any] | None = None) -> None:
|
||||||
result = {"segmentation_count": segmentation_count}
|
result = {"segmentation_count": segmentation_count}
|
||||||
|
if extra_result:
|
||||||
|
result.update(extra_result)
|
||||||
analysis_run.status = "success"
|
analysis_run.status = "success"
|
||||||
analysis_run.finished_at = SegmentationService._now()
|
analysis_run.finished_at = SegmentationService._now()
|
||||||
analysis_run.result_json = result
|
analysis_run.result_json = result
|
||||||
@@ -392,6 +492,129 @@ class SegmentationService:
|
|||||||
db.refresh(analysis_run)
|
db.refresh(analysis_run)
|
||||||
db.refresh(job)
|
db.refresh(job)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_configured_segmentation(
|
||||||
|
db,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
dataset_id: uuid.UUID,
|
||||||
|
analysis_run: AnalysisRun,
|
||||||
|
job: Job,
|
||||||
|
model_name: str,
|
||||||
|
model_version: str | None,
|
||||||
|
tile_manifest_path: str | None,
|
||||||
|
confidence_threshold: float,
|
||||||
|
class_filter: list[str],
|
||||||
|
settings: Settings,
|
||||||
|
yolo_seg_adapter_class: type[YoloSegmentationAdapter],
|
||||||
|
sam_adapter_class: type[SamSegmentationAdapter],
|
||||||
|
) -> tuple[list[Segmentation], dict[str, Any]]:
|
||||||
|
manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles)
|
||||||
|
if model_name == settings.sam_model_id:
|
||||||
|
adapter = sam_adapter_class(settings)
|
||||||
|
model_path = Path(settings.sam_model_path or "").expanduser()
|
||||||
|
else:
|
||||||
|
adapter = yolo_seg_adapter_class(settings)
|
||||||
|
model_path = Path(settings.yolo_seg_model_path or "").expanduser()
|
||||||
|
model = adapter.load_model(model_path)
|
||||||
|
|
||||||
|
allowed_classes = {DetectionService._canonical_class_name(value) for value in class_filter if DetectionService._canonical_class_name(value)}
|
||||||
|
manifest_crs = manifest.get("crs") or manifest.get("source_crs") or manifest.get("dataset_crs") or "EPSG:4326"
|
||||||
|
candidates: list[dict[str, Any]] = []
|
||||||
|
for tile in manifest["tiles"]:
|
||||||
|
tile_path = DetectionService._resolve_tile_path(tile, Path(tile_manifest_path or "").expanduser())
|
||||||
|
for raw in adapter.predict_tile(model, tile_path, confidence_threshold):
|
||||||
|
model_class_name = str(raw.get("class_name") or "").strip()
|
||||||
|
class_name = DetectionService._canonical_class_name(model_class_name)
|
||||||
|
confidence = raw.get("confidence")
|
||||||
|
confidence = float(confidence) if confidence is not None else None
|
||||||
|
if allowed_classes and class_name not in allowed_classes:
|
||||||
|
continue
|
||||||
|
if confidence is not None and confidence < confidence_threshold:
|
||||||
|
continue
|
||||||
|
points = raw.get("points")
|
||||||
|
if not isinstance(points, list) or len(points) < 3:
|
||||||
|
continue
|
||||||
|
geometry = pixel_points_to_epsg4326_polygon(points=points, tile=tile, crs=tile.get("crs") or manifest_crs)
|
||||||
|
properties = dict(raw.get("properties") or {})
|
||||||
|
if model_class_name and model_class_name != class_name:
|
||||||
|
properties.setdefault("model_class_name", model_class_name)
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
"class_name": class_name,
|
||||||
|
"confidence": confidence if confidence is not None else 0.0,
|
||||||
|
"reported_confidence": confidence,
|
||||||
|
"geometry": geometry,
|
||||||
|
"bbox": raw.get("bbox"),
|
||||||
|
"source_tile_path": str(tile_path),
|
||||||
|
"tile_index": tile.get("index"),
|
||||||
|
"properties": {**properties, "tile_index": tile.get("index")},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
filtered_candidates = DetectionService._suppress_duplicate_candidates(
|
||||||
|
candidates,
|
||||||
|
iou_threshold=float(settings.segmentation_duplicate_iou_threshold),
|
||||||
|
)
|
||||||
|
persisted: list[Segmentation] = []
|
||||||
|
for candidate in filtered_candidates:
|
||||||
|
geometry = candidate["geometry"]
|
||||||
|
if isinstance(geometry, Polygon):
|
||||||
|
geometry = MultiPolygon([geometry])
|
||||||
|
bbox = candidate.get("bbox")
|
||||||
|
bbox_json = None
|
||||||
|
if isinstance(bbox, list) and len(bbox) == 4:
|
||||||
|
bbox_json = {
|
||||||
|
"x_min": float(bbox[0]),
|
||||||
|
"y_min": float(bbox[1]),
|
||||||
|
"x_max": float(bbox[2]),
|
||||||
|
"y_max": float(bbox[3]),
|
||||||
|
}
|
||||||
|
segmentation = Segmentation(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
analysis_run_id=analysis_run.id,
|
||||||
|
job_id=job.id,
|
||||||
|
model_name=model_name,
|
||||||
|
model_version=model_version,
|
||||||
|
class_name=candidate["class_name"],
|
||||||
|
confidence=candidate["reported_confidence"],
|
||||||
|
geometry=from_shape(geometry, srid=4326),
|
||||||
|
bbox_json=bbox_json,
|
||||||
|
area_m2=SegmentationService._geodesic_area_m2(geometry),
|
||||||
|
mask_path=None,
|
||||||
|
source_tile_path=candidate["source_tile_path"],
|
||||||
|
tile_index=candidate["tile_index"] if isinstance(candidate["tile_index"], int) else None,
|
||||||
|
properties_json=candidate["properties"],
|
||||||
|
provenance_json={
|
||||||
|
"inference": "local",
|
||||||
|
"model_id": model_name,
|
||||||
|
"tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()),
|
||||||
|
"tile_index": candidate["tile_index"],
|
||||||
|
"device": settings.yolo_device,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(segmentation)
|
||||||
|
persisted.append(segmentation)
|
||||||
|
db.commit()
|
||||||
|
for segmentation in persisted:
|
||||||
|
db.refresh(segmentation)
|
||||||
|
return persisted, {
|
||||||
|
"raw_segmentation_count": len(candidates),
|
||||||
|
"suppressed_segmentation_count": len(candidates) - len(filtered_candidates),
|
||||||
|
"duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold),
|
||||||
|
"tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _geodesic_area_m2(geometry: MultiPolygon | Polygon) -> float | None:
|
||||||
|
try:
|
||||||
|
from pyproj import Geod
|
||||||
|
|
||||||
|
area, _ = Geod(ellps="WGS84").geometry_area_perimeter(geometry)
|
||||||
|
return abs(float(area))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _persist_fixture_segmentations(
|
def _persist_fixture_segmentations(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -277,6 +277,48 @@ def test_regional_official_vector_sources_are_configurable_in_every_runtime() ->
|
|||||||
assert f'Target="{key}"' in template
|
assert f'Target="{key}"' in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_segmentation_and_mdk_acquisition_are_configurable_in_every_runtime() -> None:
|
||||||
|
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||||
|
unraid_compose = (ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8")
|
||||||
|
run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8")
|
||||||
|
env_example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||||
|
unraid_env = (ROOT / "deploy" / "unraid" / "geointel.env.example").read_text(encoding="utf-8")
|
||||||
|
template = (ROOT / "deploy" / "unraid" / "geointel-unraid-template.xml").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
"YOLO_SEG_ENABLED",
|
||||||
|
"YOLO_SEG_MODEL_PATH",
|
||||||
|
"SAM_ENABLED",
|
||||||
|
"SAM_MODEL_PATH",
|
||||||
|
"SEGMENTATION_MAX_MASKS_PER_TILE",
|
||||||
|
"SEGMENTATION_DUPLICATE_IOU_THRESHOLD",
|
||||||
|
"MDK_BATHYMETRY_ACQUISITION_ENABLED",
|
||||||
|
"MDK_BATHYMETRY_COVERAGE_ID",
|
||||||
|
"MDK_BATHYMETRY_MAX_BBOX_DEG2",
|
||||||
|
):
|
||||||
|
assert key in compose, key
|
||||||
|
assert key in unraid_compose, key
|
||||||
|
assert f'{key}="${{{key}:-' in run_script, key
|
||||||
|
assert f'-e {key}="${key}"' in run_script, key
|
||||||
|
assert f"{key}=" in env_example, key
|
||||||
|
assert f"{key}=" in unraid_env, key
|
||||||
|
assert f'Target="{key}"' in template, key
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_reconciles_interrupted_runs_after_restart_like_unraid_runtime() -> None:
|
||||||
|
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||||
|
start_script = (ROOT / "deploy" / "unraid" / "all-in-one-start.sh").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP: "
|
||||||
|
"${GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP:-true}"
|
||||||
|
) in compose
|
||||||
|
assert (
|
||||||
|
'GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP='
|
||||||
|
'"${GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP:-true}"'
|
||||||
|
) in start_script
|
||||||
|
|
||||||
|
|
||||||
def test_docker_build_contexts_exclude_vendor_build_and_cache_outputs() -> None:
|
def test_docker_build_contexts_exclude_vendor_build_and_cache_outputs() -> None:
|
||||||
required_patterns = {
|
required_patterns = {
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.schemas.bathymetry import MdkBathymetryAcquireRequest
|
||||||
|
from app.schemas.operations import VectorSelectionBBox
|
||||||
|
from app.services.mdk_bathymetry_acquisition_service import MdkBathymetryAcquisitionService
|
||||||
|
|
||||||
|
CAPABILITIES_XML = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<WCS_Capabilities version="1.0.0" xmlns="http://www.opengis.net/wcs">
|
||||||
|
<ContentMetadata>
|
||||||
|
<CoverageOfferingBrief>
|
||||||
|
<name>depth_model_20m_lat</name>
|
||||||
|
<label>Belgian Continental Shelf depth model</label>
|
||||||
|
</CoverageOfferingBrief>
|
||||||
|
</ContentMetadata>
|
||||||
|
</WCS_Capabilities>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, content: bytes, content_type: str = "application/xml") -> None:
|
||||||
|
self._stream = io.BytesIO(content)
|
||||||
|
self.headers = {"Content-Type": content_type}
|
||||||
|
|
||||||
|
def read(self, limit: int = -1) -> bytes:
|
||||||
|
return self._stream.read(limit)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(**overrides) -> MdkBathymetryAcquireRequest:
|
||||||
|
values = {
|
||||||
|
"bbox": VectorSelectionBBox(min_x=2.5, min_y=51.3, max_x=2.6, max_y=51.4),
|
||||||
|
"force_refresh": True,
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return MdkBathymetryAcquireRequest(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**overrides) -> Settings:
|
||||||
|
values = {
|
||||||
|
"mdk_bathymetry_acquisition_enabled": True,
|
||||||
|
"mdk_bathymetry_coverage_id": "depth_model_20m_lat",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return Settings(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_acquisition_fails_closed_when_disabled() -> None:
|
||||||
|
settings = _settings(mdk_bathymetry_acquisition_enabled=False)
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings)
|
||||||
|
|
||||||
|
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_ACQUISITION_DISABLED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_acquisition_fails_closed_without_coverage_id() -> None:
|
||||||
|
settings = _settings(mdk_bathymetry_coverage_id=None)
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings)
|
||||||
|
|
||||||
|
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_COVERAGE_NOT_CONFIGURED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_acquisition_rejects_oversized_bbox() -> None:
|
||||||
|
settings = _settings(mdk_bathymetry_max_bbox_deg2=0.001)
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings)
|
||||||
|
|
||||||
|
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_BBOX_TOO_LARGE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_acquisition_requires_reachable_probe() -> None:
|
||||||
|
settings = _settings()
|
||||||
|
|
||||||
|
def failing_opener(request, timeout=None):
|
||||||
|
raise OSError("connection refused")
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings, opener=failing_opener)
|
||||||
|
|
||||||
|
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_ENDPOINT_NOT_READY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_acquisition_requires_advertised_coverage_id() -> None:
|
||||||
|
settings = _settings(mdk_bathymetry_coverage_id="not_advertised_coverage")
|
||||||
|
|
||||||
|
def opener(request, timeout=None):
|
||||||
|
return FakeResponse(CAPABILITIES_XML)
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings, opener=opener)
|
||||||
|
|
||||||
|
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_COVERAGE_NOT_ADVERTISED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_acquisition_rejects_non_geotiff_coverage_response() -> None:
|
||||||
|
settings = _settings()
|
||||||
|
responses = []
|
||||||
|
|
||||||
|
def opener(request, timeout=None):
|
||||||
|
url = request.full_url if hasattr(request, "full_url") else str(request)
|
||||||
|
responses.append(url)
|
||||||
|
if "GetCapabilities" in url:
|
||||||
|
return FakeResponse(CAPABILITIES_XML)
|
||||||
|
return FakeResponse(b"<ServiceExceptionReport>boom</ServiceExceptionReport>", "application/xml")
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings, opener=opener)
|
||||||
|
|
||||||
|
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_INVALID_RESPONSE"
|
||||||
|
assert any("GetCoverage" in url for url in responses)
|
||||||
|
coverage_urls = [url for url in responses if "GetCoverage" in url]
|
||||||
|
assert "coverage=depth_model_20m_lat" in coverage_urls[0]
|
||||||
|
assert "format=GeoTIFF" in coverage_urls[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_coverage_url_is_bounded_and_pinned() -> None:
|
||||||
|
settings = _settings()
|
||||||
|
bbox = [2.5, 51.3, 2.6, 51.4]
|
||||||
|
|
||||||
|
url = MdkBathymetryAcquisitionService._get_coverage_url(settings, "depth_model_20m_lat", bbox)
|
||||||
|
|
||||||
|
assert url.startswith("https://")
|
||||||
|
assert "request=GetCoverage" in url
|
||||||
|
assert "version=1.0.0" in url
|
||||||
|
assert "crs=EPSG%3A4326" in url or "crs=EPSG:4326" in url
|
||||||
|
width, height = MdkBathymetryAcquisitionService._pixel_dimensions(bbox)
|
||||||
|
assert 1 <= width <= MdkBathymetryAcquisitionService.MAX_PIXELS_PER_SIDE
|
||||||
|
assert 1 <= height <= MdkBathymetryAcquisitionService.MAX_PIXELS_PER_SIDE
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_module_never_disables_tls_verification() -> None:
|
||||||
|
source = (
|
||||||
|
Path(__file__).resolve().parents[1] / "app" / "services" / "mdk_bathymetry_acquisition_service.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "_create_unverified_context" not in source
|
||||||
|
assert "CERT_NONE" not in source
|
||||||
|
assert "check_hostname = False" not in source
|
||||||
@@ -119,7 +119,7 @@ def test_model_asset_catalog_lists_supported_local_model_files(tmp_path: Path) -
|
|||||||
assert asset.size_bytes == len(b"local model")
|
assert asset.size_bytes == len(b"local model")
|
||||||
assert len(asset.sha256) == 64
|
assert len(asset.sha256) == 64
|
||||||
assert asset.active is True
|
assert asset.active is True
|
||||||
assert asset.status == "available"
|
assert asset.status == "approved"
|
||||||
assert asset.will_download_models is False
|
assert asset.will_download_models is False
|
||||||
|
|
||||||
|
|
||||||
@@ -134,6 +134,25 @@ def test_model_asset_catalog_resolves_known_asset(tmp_path: Path) -> None:
|
|||||||
assert asset.model_path == str(model_file)
|
assert asset.model_path == str(model_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_asset_catalog_only_exposes_explicit_active_asset_in_runtime(tmp_path: Path) -> None:
|
||||||
|
active_file = tmp_path / "approved-building-detector.pt"
|
||||||
|
active_file.write_bytes(b"approved")
|
||||||
|
(tmp_path / "training-smoke.pt").write_bytes(b"experiment")
|
||||||
|
(tmp_path / "partial-checkpoint.pt").write_bytes(b"partial")
|
||||||
|
settings = Settings(
|
||||||
|
yolo_models_dir=str(tmp_path),
|
||||||
|
yolo_model_path=str(active_file),
|
||||||
|
yolo_enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = ModelAssetCatalogService.list_assets(settings=settings)
|
||||||
|
|
||||||
|
assert response.total == 1
|
||||||
|
assert response.items[0].filename == active_file.name
|
||||||
|
assert response.items[0].active is True
|
||||||
|
assert response.items[0].status == "approved"
|
||||||
|
|
||||||
|
|
||||||
def test_model_asset_catalog_rejects_unknown_asset(tmp_path: Path) -> None:
|
def test_model_asset_catalog_rejects_unknown_asset(tmp_path: Path) -> None:
|
||||||
settings = Settings(yolo_models_dir=str(tmp_path), yolo_enabled=True)
|
settings = Settings(yolo_models_dir=str(tmp_path), yolo_enabled=True)
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,72 @@ def test_regional_product_registry_is_explicit_and_source_specific() -> None:
|
|||||||
assert products["urbis_buildings"]["coverage_zones"] == ["brussels"]
|
assert products["urbis_buildings"]["coverage_zones"] == ["brussels"]
|
||||||
assert products["urbis_buildings"]["license_note"] == "Buildings are published under CC0."
|
assert products["urbis_buildings"]["license_note"] == "Buildings are published under CC0."
|
||||||
assert "FPS Finance" in products["urbis_cadastral_parcels"]["license_note"]
|
assert "FPS Finance" in products["urbis_cadastral_parcels"]["license_note"]
|
||||||
|
# urbis_street_axes is live-validated against the UrbIS WFS capabilities:
|
||||||
|
# urbisvector:StreetAxes exposes INSPIRE_ID and LineString geometry. The
|
||||||
|
# same capabilities document advertises no hydrography feature type, so
|
||||||
|
# Brussels surface water intentionally stays not_configured.
|
||||||
|
assert products["urbis_street_axes"]["coverage_zones"] == ["brussels"]
|
||||||
|
assert products["urbis_street_axes"]["collection"] == "urbisvector:StreetAxes"
|
||||||
|
assert products["urbis_street_axes"]["geometry_types"] == [
|
||||||
|
"LineString",
|
||||||
|
"MultiLineString",
|
||||||
|
]
|
||||||
|
assert products["urbis_street_axes"]["theme"] == "roads"
|
||||||
|
assert products["urbis_land_cover_blocks"]["collection"] == "urbisvector:Blocks"
|
||||||
|
assert products["urbis_land_cover_blocks"]["theme"] == "space_occupation"
|
||||||
|
assert products["urbis_forest_parks"]["theme"] == "forest"
|
||||||
|
assert products["urbis_water_surfaces"]["theme"] == "water"
|
||||||
|
|
||||||
|
|
||||||
|
def test_urbis_land_cover_products_filter_only_documented_block_classes() -> None:
|
||||||
|
scope_wgs84 = Polygon(
|
||||||
|
[(4.35, 50.84), (4.36, 50.84), (4.36, 50.85), (4.35, 50.85), (4.35, 50.84)]
|
||||||
|
)
|
||||||
|
scope_metric = Polygon([_TO_LAMBERT72.transform(x, y) for x, y in scope_wgs84.exterior.coords])
|
||||||
|
min_x, min_y, max_x, max_y = scope_metric.bounds
|
||||||
|
|
||||||
|
def block(block_type: str):
|
||||||
|
return {
|
||||||
|
"type": "Feature",
|
||||||
|
"id": f"Blocks.{block_type}",
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[
|
||||||
|
[min_x + 10, min_y + 10],
|
||||||
|
[min_x + 100, min_y + 10],
|
||||||
|
[min_x + 100, min_y + 100],
|
||||||
|
[min_x + 10, min_y + 100],
|
||||||
|
[min_x + 10, min_y + 10],
|
||||||
|
]],
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"INSPIRE_ID": f"https://databrussels.be/id/block/{block_type}",
|
||||||
|
"TYPE": block_type,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
forest_product = OfficialVectorAcquisitionService._product("urbis_forest_parks")
|
||||||
|
water_product = OfficialVectorAcquisitionService._product("urbis_water_surfaces")
|
||||||
|
land_cover_product = OfficialVectorAcquisitionService._product("urbis_land_cover_blocks")
|
||||||
|
|
||||||
|
assert OfficialVectorAcquisitionService._normalize_regional_feature(
|
||||||
|
forest_product, block("FO"), scope_metric, "brussels"
|
||||||
|
) is not None
|
||||||
|
assert OfficialVectorAcquisitionService._normalize_regional_feature(
|
||||||
|
forest_product, block("CB"), scope_metric, "brussels"
|
||||||
|
) is None
|
||||||
|
assert OfficialVectorAcquisitionService._normalize_regional_feature(
|
||||||
|
water_product, block("WB"), scope_metric, "brussels"
|
||||||
|
) is not None
|
||||||
|
assert OfficialVectorAcquisitionService._normalize_regional_feature(
|
||||||
|
water_product, block("GB"), scope_metric, "brussels"
|
||||||
|
) is None
|
||||||
|
normalized = OfficialVectorAcquisitionService._normalize_regional_feature(
|
||||||
|
land_cover_product, block("CB"), scope_metric, "brussels"
|
||||||
|
)
|
||||||
|
assert normalized is not None
|
||||||
|
assert normalized["properties"]["TYPE"] == "CB"
|
||||||
|
assert normalized["properties"]["clipped_area_ha"] > 0
|
||||||
|
|
||||||
|
|
||||||
def test_spw_arcgis_paging_is_bounded_stable_and_clipped() -> None:
|
def test_spw_arcgis_paging_is_bounded_stable_and_clipped() -> None:
|
||||||
|
|||||||
@@ -404,8 +404,8 @@ def test_frontend_prefers_materialized_national_workspace_and_resolves_drawn_bbo
|
|||||||
|
|
||||||
assert "Belgium and North Sea Workbench" in focus
|
assert "Belgium and North Sea Workbench" in focus
|
||||||
assert "nationalProject" in workspace_hook
|
assert "nationalProject" in workspace_hook
|
||||||
assert "data.areas.length > 0" in workspace_hook
|
assert "return nationalProject.id" in workspace_hook
|
||||||
assert "dataset.status === 'ready'" in workspace_hook
|
assert "NATIONAL_WORKSPACE_REGION" in workspace_hook
|
||||||
assert "externalApi.resolveCoverage" in coverage_hook
|
assert "externalApi.resolveCoverage" in coverage_hook
|
||||||
assert "coverage.outside_supported_scope" in map_workspace
|
assert "coverage.outside_supported_scope" in map_workspace
|
||||||
assert "coverageStatusLabel" in map_workspace
|
assert "coverageStatusLabel" in map_workspace
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.models import AnalysisRun, Dataset, Job, Project
|
||||||
|
from app.services.detection_service import DetectionService
|
||||||
|
from app.services.job_service import JobService
|
||||||
|
from app.services.segmentation_service import SegmentationService
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
"""Minimal session double without rollback support, mirroring existing test doubles."""
|
||||||
|
|
||||||
|
def __init__(self, objects=None) -> None:
|
||||||
|
self.objects = objects or {}
|
||||||
|
self.added = []
|
||||||
|
self.commits = 0
|
||||||
|
|
||||||
|
def get(self, model, item_id):
|
||||||
|
return self.objects.get((model, item_id))
|
||||||
|
|
||||||
|
def add(self, item) -> None:
|
||||||
|
self.added.append(item)
|
||||||
|
if getattr(item, "id", None) is not None:
|
||||||
|
self.objects[(item.__class__, item.id)] = item
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.commits += 1
|
||||||
|
|
||||||
|
def refresh(self, item) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _project_and_dataset():
|
||||||
|
project_id = uuid4()
|
||||||
|
dataset_id = uuid4()
|
||||||
|
project = Project(id=project_id, name="Mol")
|
||||||
|
dataset = Dataset(
|
||||||
|
id=dataset_id,
|
||||||
|
project_id=project_id,
|
||||||
|
name="ortho.tif",
|
||||||
|
dataset_type="raster",
|
||||||
|
source="user_upload",
|
||||||
|
storage_path="storage/uploads/ortho.tif",
|
||||||
|
)
|
||||||
|
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
|
||||||
|
return db, project_id, dataset_id
|
||||||
|
|
||||||
|
|
||||||
|
def _statuses(db: FakeSession) -> tuple[list[str], list[str]]:
|
||||||
|
runs = [item.status for item in db.added if isinstance(item, AnalysisRun)]
|
||||||
|
jobs = [item.status for item in db.added if isinstance(item, Job)]
|
||||||
|
return runs, jobs
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_fixture_detections_mark_run_and_job_failed() -> None:
|
||||||
|
db, project_id, dataset_id = _project_and_dataset()
|
||||||
|
|
||||||
|
with pytest.raises(AppError) as exc_info:
|
||||||
|
DetectionService.run_detection(
|
||||||
|
db=db,
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
model_id="manual-fixture-detector",
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
parameters_json={"fixture_mode": True, "fixture_detections": "not-a-list"},
|
||||||
|
settings=Settings(_env_file=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.code == "INVALID_FIXTURE_DETECTIONS"
|
||||||
|
run_statuses, job_statuses = _statuses(db)
|
||||||
|
assert run_statuses and all(status == "failed" for status in run_statuses)
|
||||||
|
assert job_statuses and all(status == "failed" for status in job_statuses)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_fixture_segmentations_mark_run_and_job_failed() -> None:
|
||||||
|
db, project_id, dataset_id = _project_and_dataset()
|
||||||
|
|
||||||
|
with pytest.raises(AppError) as exc_info:
|
||||||
|
SegmentationService.run_segmentation(
|
||||||
|
db=db,
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
model_id="fixture-segmenter",
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
parameters_json={"fixture_mode": True, "fixture_segmentations": "not-a-list"},
|
||||||
|
settings=Settings(_env_file=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.code == "INVALID_FIXTURE_SEGMENTATIONS"
|
||||||
|
run_statuses, job_statuses = _statuses(db)
|
||||||
|
assert run_statuses and all(status == "failed" for status in run_statuses)
|
||||||
|
assert job_statuses and all(status == "failed" for status in job_statuses)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unexpected_error_in_sync_job_marks_job_failed() -> None:
|
||||||
|
project_id = uuid4()
|
||||||
|
db = FakeSession(objects={(Project, project_id): Project(id=project_id, name="Mol")})
|
||||||
|
|
||||||
|
def exploding_operation():
|
||||||
|
raise RuntimeError("unexpected internal failure")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
JobService.run_sync_job(
|
||||||
|
db=db,
|
||||||
|
project_id=project_id,
|
||||||
|
job_type="test.unexpected",
|
||||||
|
parameters={},
|
||||||
|
operation=exploding_operation,
|
||||||
|
)
|
||||||
|
|
||||||
|
jobs = [item for item in db.added if isinstance(item, Job)]
|
||||||
|
assert jobs
|
||||||
|
final_job = jobs[-1]
|
||||||
|
assert final_job.status == "failed"
|
||||||
|
assert "Unexpected internal error" in (final_job.error_message or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_error_in_sync_job_still_marks_job_failed() -> None:
|
||||||
|
project_id = uuid4()
|
||||||
|
db = FakeSession(objects={(Project, project_id): Project(id=project_id, name="Mol")})
|
||||||
|
|
||||||
|
def failing_operation():
|
||||||
|
raise AppError(code="SOME_DOMAIN_ERROR", message="Bounded failure", status_code=422)
|
||||||
|
|
||||||
|
with pytest.raises(AppError):
|
||||||
|
JobService.run_sync_job(
|
||||||
|
db=db,
|
||||||
|
project_id=project_id,
|
||||||
|
job_type="test.bounded",
|
||||||
|
parameters={},
|
||||||
|
operation=failing_operation,
|
||||||
|
)
|
||||||
|
|
||||||
|
jobs = [item for item in db.added if isinstance(item, Job)]
|
||||||
|
assert jobs
|
||||||
|
assert jobs[-1].status == "failed"
|
||||||
|
assert jobs[-1].error_message == "Bounded failure"
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from geoalchemy2.shape import to_shape
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.models import Dataset, Project, Segmentation
|
||||||
|
from app.services.detection_georeferencing import pixel_points_to_epsg4326_polygon
|
||||||
|
from app.services.model_registry_service import ModelRegistryService
|
||||||
|
from app.services.segmentation_service import SegmentationService
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
def __init__(self, objects=None) -> None:
|
||||||
|
self.objects = objects or {}
|
||||||
|
self.added = []
|
||||||
|
self.commits = 0
|
||||||
|
self.refreshes = []
|
||||||
|
|
||||||
|
def get(self, model, item_id):
|
||||||
|
return self.objects.get((model, item_id))
|
||||||
|
|
||||||
|
def add(self, item) -> None:
|
||||||
|
self.added.append(item)
|
||||||
|
if getattr(item, "id", None) is not None:
|
||||||
|
self.objects[(item.__class__, item.id)] = item
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.commits += 1
|
||||||
|
|
||||||
|
def refresh(self, item) -> None:
|
||||||
|
self.refreshes.append(item)
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableSegAdapter:
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dependencies_available() -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def load_model(self, model_path: Path):
|
||||||
|
return object()
|
||||||
|
|
||||||
|
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"class_name": "building",
|
||||||
|
"confidence": 0.91,
|
||||||
|
"points": [[10.0, 20.0], [30.0, 20.0], [30.0, 40.0], [10.0, 40.0]],
|
||||||
|
"bbox": [10.0, 20.0, 30.0, 40.0],
|
||||||
|
"properties": {"class_id": 0},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ClassAgnosticSamAdapter(AvailableSegAdapter):
|
||||||
|
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"class_name": "segment",
|
||||||
|
"confidence": None,
|
||||||
|
"points": [[5.0, 5.0], [25.0, 5.0], [25.0, 25.0], [5.0, 25.0]],
|
||||||
|
"bbox": [5.0, 5.0, 25.0, 25.0],
|
||||||
|
"properties": {"class_id": -1},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class MissingDependencySegAdapter(AvailableSegAdapter):
|
||||||
|
@staticmethod
|
||||||
|
def dependencies_available() -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _project_and_dataset(dataset_type: str = "raster"):
|
||||||
|
project_id = uuid4()
|
||||||
|
dataset_id = uuid4()
|
||||||
|
project = Project(id=project_id, name="Mol")
|
||||||
|
dataset = Dataset(
|
||||||
|
id=dataset_id,
|
||||||
|
project_id=project_id,
|
||||||
|
name="ortho.tif",
|
||||||
|
dataset_type=dataset_type,
|
||||||
|
source="user_upload",
|
||||||
|
storage_path="storage/uploads/ortho.tif",
|
||||||
|
)
|
||||||
|
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
|
||||||
|
return db, project_id, dataset_id
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(tmp_path: Path, **overrides) -> Settings:
|
||||||
|
values = {
|
||||||
|
"yolo_seg_enabled": True,
|
||||||
|
"yolo_seg_model_path": str(tmp_path / "seg.pt"),
|
||||||
|
"sam_enabled": True,
|
||||||
|
"sam_model_path": str(tmp_path / "sam.pt"),
|
||||||
|
"yolo_max_tiles": 4,
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return Settings(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(tmp_path: Path, tile_count: int = 1) -> Path:
|
||||||
|
tiles = []
|
||||||
|
for index in range(tile_count):
|
||||||
|
tile_path = tmp_path / f"tile_{index:04d}.tif"
|
||||||
|
tile_path.write_bytes(b"fixture")
|
||||||
|
tiles.append(
|
||||||
|
{
|
||||||
|
"path": str(tile_path),
|
||||||
|
"pixel_window": [0, 0, 100, 100],
|
||||||
|
"bounds": [4.0, 51.0, 5.0, 52.0],
|
||||||
|
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
|
||||||
|
"index": index,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
manifest_path = tmp_path / "manifest.json"
|
||||||
|
manifest_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"tile_set_id": "tiles-fixture",
|
||||||
|
"source_dataset_id": str(uuid4()),
|
||||||
|
"source_raster_id": str(uuid4()),
|
||||||
|
"tile_size": 100,
|
||||||
|
"overlap": 0,
|
||||||
|
"count": tile_count,
|
||||||
|
"tiles": tiles,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return manifest_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_segmentation_models_report_not_configured_when_disabled(tmp_path: Path) -> None:
|
||||||
|
settings = _settings(tmp_path, yolo_seg_enabled=False, sam_enabled=False)
|
||||||
|
|
||||||
|
models = {
|
||||||
|
model.model_id: model
|
||||||
|
for model in ModelRegistryService.list_segmentation_model_capabilities(settings=settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert models["yolo-seg-configured"].configured is False
|
||||||
|
assert models["yolo-seg-configured"].status == "not_configured"
|
||||||
|
assert models["sam-configured"].configured is False
|
||||||
|
assert models["sam-configured"].status == "not_configured"
|
||||||
|
|
||||||
|
|
||||||
|
def test_segmentation_models_report_dependency_unavailable(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "seg.pt").write_bytes(b"weights")
|
||||||
|
(tmp_path / "sam.pt").write_bytes(b"weights")
|
||||||
|
settings = _settings(tmp_path)
|
||||||
|
|
||||||
|
models = {
|
||||||
|
model.model_id: model
|
||||||
|
for model in ModelRegistryService.list_segmentation_model_capabilities(
|
||||||
|
settings=settings,
|
||||||
|
yolo_seg_adapter_class=MissingDependencySegAdapter,
|
||||||
|
sam_adapter_class=MissingDependencySegAdapter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert models["yolo-seg-configured"].status == "dependency_unavailable"
|
||||||
|
assert models["sam-configured"].status == "dependency_unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_segmentation_models_report_configured_with_local_weights(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "seg.pt").write_bytes(b"weights")
|
||||||
|
(tmp_path / "sam.pt").write_bytes(b"weights")
|
||||||
|
settings = _settings(tmp_path)
|
||||||
|
|
||||||
|
models = {
|
||||||
|
model.model_id: model
|
||||||
|
for model in ModelRegistryService.list_segmentation_model_capabilities(
|
||||||
|
settings=settings,
|
||||||
|
yolo_seg_adapter_class=AvailableSegAdapter,
|
||||||
|
sam_adapter_class=ClassAgnosticSamAdapter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert models["yolo-seg-configured"].configured is True
|
||||||
|
assert models["yolo-seg-configured"].status == "configured"
|
||||||
|
assert models["sam-configured"].configured is True
|
||||||
|
assert models["sam-configured"].status == "configured"
|
||||||
|
|
||||||
|
|
||||||
|
def test_segmentation_dependency_check_uses_real_imports_not_find_spec() -> None:
|
||||||
|
source = (ROOT / "backend" / "app" / "services" / "segmentation_adapter.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'find_spec("ultralytics")' not in source
|
||||||
|
assert "import ultralytics" in source
|
||||||
|
assert "import torch" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_configured_segmentation_requires_tile_manifest(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "seg.pt").write_bytes(b"weights")
|
||||||
|
db, project_id, dataset_id = _project_and_dataset()
|
||||||
|
settings = _settings(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
SegmentationService.run_segmentation(
|
||||||
|
db=db,
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
model_id="yolo-seg-configured",
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
settings=settings,
|
||||||
|
yolo_seg_adapter_class=AvailableSegAdapter,
|
||||||
|
sam_adapter_class=ClassAgnosticSamAdapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert getattr(exc_info.value, "code", None) == "SEGMENTATION_TILE_MANIFEST_REQUIRED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_configured_yolo_seg_run_persists_georeferenced_masks(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "seg.pt").write_bytes(b"weights")
|
||||||
|
db, project_id, dataset_id = _project_and_dataset()
|
||||||
|
settings = _settings(tmp_path)
|
||||||
|
manifest_path = _manifest(tmp_path)
|
||||||
|
|
||||||
|
response = SegmentationService.run_segmentation(
|
||||||
|
db=db,
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
model_id="yolo-seg-configured",
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
tile_manifest_path=str(manifest_path),
|
||||||
|
settings=settings,
|
||||||
|
yolo_seg_adapter_class=AvailableSegAdapter,
|
||||||
|
sam_adapter_class=ClassAgnosticSamAdapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == "success"
|
||||||
|
assert response.segmentation_count == 1
|
||||||
|
persisted = [item for item in db.added if isinstance(item, Segmentation)]
|
||||||
|
assert len(persisted) == 1
|
||||||
|
segmentation = persisted[0]
|
||||||
|
assert segmentation.class_name == "building"
|
||||||
|
assert segmentation.confidence == pytest.approx(0.91)
|
||||||
|
geometry = to_shape(segmentation.geometry)
|
||||||
|
assert geometry.geom_type == "MultiPolygon"
|
||||||
|
min_x, min_y, max_x, max_y = geometry.bounds
|
||||||
|
assert 4.0 <= min_x <= 5.0
|
||||||
|
assert 51.0 <= min_y <= 52.0
|
||||||
|
assert max_x <= 5.0
|
||||||
|
assert max_y <= 52.0
|
||||||
|
assert segmentation.area_m2 is not None and segmentation.area_m2 > 0
|
||||||
|
assert segmentation.provenance_json["inference"] == "local"
|
||||||
|
assert segmentation.provenance_json["model_id"] == "yolo-seg-configured"
|
||||||
|
|
||||||
|
|
||||||
|
def test_configured_sam_run_is_class_agnostic(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "sam.pt").write_bytes(b"weights")
|
||||||
|
db, project_id, dataset_id = _project_and_dataset()
|
||||||
|
settings = _settings(tmp_path)
|
||||||
|
manifest_path = _manifest(tmp_path)
|
||||||
|
|
||||||
|
response = SegmentationService.run_segmentation(
|
||||||
|
db=db,
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
model_id="sam-configured",
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
tile_manifest_path=str(manifest_path),
|
||||||
|
settings=settings,
|
||||||
|
yolo_seg_adapter_class=AvailableSegAdapter,
|
||||||
|
sam_adapter_class=ClassAgnosticSamAdapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == "success"
|
||||||
|
assert response.segmentation_count == 1
|
||||||
|
persisted = [item for item in db.added if isinstance(item, Segmentation)]
|
||||||
|
assert persisted[0].class_name == "segment"
|
||||||
|
assert persisted[0].confidence is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unconfigured_segmentation_run_fails_closed(tmp_path: Path) -> None:
|
||||||
|
db, project_id, dataset_id = _project_and_dataset()
|
||||||
|
settings = _settings(tmp_path, yolo_seg_enabled=False)
|
||||||
|
manifest_path = _manifest(tmp_path)
|
||||||
|
|
||||||
|
response = SegmentationService.run_segmentation(
|
||||||
|
db=db,
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
model_id="yolo-seg-configured",
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
tile_manifest_path=str(manifest_path),
|
||||||
|
settings=settings,
|
||||||
|
yolo_seg_adapter_class=AvailableSegAdapter,
|
||||||
|
sam_adapter_class=ClassAgnosticSamAdapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == "failed"
|
||||||
|
assert response.error_code == "SEGMENTATION_MODEL_UNAVAILABLE"
|
||||||
|
assert not [item for item in db.added if isinstance(item, Segmentation)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pixel_points_to_epsg4326_polygon_uses_tile_transform() -> None:
|
||||||
|
tile = {
|
||||||
|
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
|
||||||
|
"bounds": [4.0, 51.0, 5.0, 52.0],
|
||||||
|
"pixel_window": [0, 0, 100, 100],
|
||||||
|
}
|
||||||
|
|
||||||
|
polygon = pixel_points_to_epsg4326_polygon(
|
||||||
|
points=[[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]],
|
||||||
|
tile=tile,
|
||||||
|
crs="EPSG:4326",
|
||||||
|
)
|
||||||
|
|
||||||
|
min_x, min_y, max_x, max_y = polygon.bounds
|
||||||
|
assert min_x == pytest.approx(4.0)
|
||||||
|
assert max_x == pytest.approx(5.0)
|
||||||
|
assert min_y == pytest.approx(51.0)
|
||||||
|
assert max_y == pytest.approx(52.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pixel_points_to_epsg4326_polygon_rejects_degenerate_input() -> None:
|
||||||
|
tile = {"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01]}
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
pixel_points_to_epsg4326_polygon(points=[[0.0, 0.0], [1.0, 1.0]], tile=tile)
|
||||||
|
|
||||||
|
assert getattr(exc_info.value, "code", None) == "SEGMENTATION_INVALID_MASK"
|
||||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
|||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
def test_frontend_declares_mol_as_primary_operating_focus() -> None:
|
def test_frontend_declares_national_scope_as_primary_operating_focus() -> None:
|
||||||
focus = (ROOT / "frontend" / "src" / "config" / "primaryFocus.ts").read_text(
|
focus = (ROOT / "frontend" / "src" / "config" / "primaryFocus.ts").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
@@ -23,19 +23,17 @@ def test_frontend_declares_mol_as_primary_operating_focus() -> None:
|
|||||||
/ "WorkbenchNavigation.tsx"
|
/ "WorkbenchNavigation.tsx"
|
||||||
).read_text(encoding="utf-8")
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "PRIMARY_FOCUS_LABEL = 'Mol'" in focus
|
assert "NATIONAL_WORKSPACE_PROJECT_NAME = 'Belgium and North Sea Workbench'" in focus
|
||||||
assert "PRIMARY_FOCUS_REGION = 'Mol, Kempen'" in focus
|
assert "NATIONAL_WORKSPACE_REGION = 'Belgie en Belgische Noordzee'" in focus
|
||||||
assert "[5.1167, 51.1919]" in focus
|
assert "NATIONAL_MAP_CENTER" in focus
|
||||||
assert "isPrimaryFocusProjectData" in focus
|
assert "return nationalProject.id" in project_hook
|
||||||
assert "isPrimaryFocusProjectData(project, data.datasets)" in project_hook
|
|
||||||
assert "hasMappedAnalysisContext(data)" in project_hook
|
assert "hasMappedAnalysisContext(data)" in project_hook
|
||||||
assert "dataset.dataset_type === 'raster'" in project_hook
|
assert "dataset.dataset_type === 'raster'" in project_hook
|
||||||
assert "dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson'" in project_hook
|
assert "dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson'" in project_hook
|
||||||
assert "const primaryContext = inspectedCandidates.find" in project_hook
|
assert "PRIMARY_FOCUS_AREA_NAME" not in project_hook
|
||||||
assert "PRIMARY_FOCUS_AREA_NAME" in project_hook
|
assert "PRIMARY_FOCUS_AREA_GEOJSON" not in project_hook
|
||||||
assert "PRIMARY_FOCUS_AREA_GEOJSON" in project_hook
|
assert "center: NATIONAL_MAP_CENTER" in map_source
|
||||||
assert "4.35,51.28" not in project_hook
|
assert "zoom: NATIONAL_MAP_ZOOM" in map_source
|
||||||
assert "center: PRIMARY_FOCUS_CENTER" in map_source
|
|
||||||
assert "GeoIntel" in navigation
|
assert "GeoIntel" in navigation
|
||||||
assert "Atlas Workbench" in navigation
|
assert "Atlas Workbench" in navigation
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ def test_large_vector_persistence_flushes_once_without_per_feature_refresh() ->
|
|||||||
assert db.refreshes == 0
|
assert db.refreshes == 0
|
||||||
|
|
||||||
|
|
||||||
def test_municipality_workspace_is_wired_into_runtime_and_frontend_priority() -> None:
|
def test_municipality_workspace_remains_a_regression_fixture_without_frontend_priority() -> None:
|
||||||
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
||||||
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
|
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
|
||||||
focus = (ROOT / "frontend" / "src" / "config" / "primaryFocus.ts").read_text(encoding="utf-8")
|
focus = (ROOT / "frontend" / "src" / "config" / "primaryFocus.ts").read_text(encoding="utf-8")
|
||||||
@@ -154,7 +154,8 @@ def test_municipality_workspace_is_wired_into_runtime_and_frontend_priority() ->
|
|||||||
assert "py_compile scripts/provision_mol_municipality_workspace.py" in readiness
|
assert "py_compile scripts/provision_mol_municipality_workspace.py" in readiness
|
||||||
assert "COPY scripts/provision_mol_municipality_workspace.py" in dockerfile
|
assert "COPY scripts/provision_mol_municipality_workspace.py" in dockerfile
|
||||||
assert "PRIMARY_FOCUS_MUNICIPALITY_PROJECT_NAME = 'Mol Municipality Workbench'" in focus
|
assert "PRIMARY_FOCUS_MUNICIPALITY_PROJECT_NAME = 'Mol Municipality Workbench'" in focus
|
||||||
assert "items.find(isPrimaryFocusMunicipalityProject)" in project_hook
|
assert "items.find(isPrimaryFocusMunicipalityProject)" not in project_hook
|
||||||
|
assert "return nationalProject.id" in project_hook
|
||||||
assert "datasets.find(isPrimaryFocusMunicipalityBoundaryDataset)" in dataset_hook
|
assert "datasets.find(isPrimaryFocusMunicipalityBoundaryDataset)" in dataset_hook
|
||||||
assert "featureCollectionBounds(featureCollection)" in map_source
|
assert "featureCollectionBounds(featureCollection)" in map_source
|
||||||
assert "useMemo(() => getFeatureCollectionBBox(mapFeatureCollection)" in map_workspace
|
assert "useMemo(() => getFeatureCollectionBBox(mapFeatureCollection)" in map_workspace
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ def read(path: str) -> str:
|
|||||||
return (ROOT / path).read_text(encoding="utf-8")
|
return (ROOT / path).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_regional_workspace_is_automatic_and_map_has_one_scope_selector() -> None:
|
def test_national_workspace_is_automatic_and_map_has_one_scope_selector() -> None:
|
||||||
project_hook = read("frontend/src/hooks/useProjectWorkspace.ts")
|
project_hook = read("frontend/src/hooks/useProjectWorkspace.ts")
|
||||||
map_workspace = read("frontend/src/components/map/MapWorkspace.tsx")
|
map_workspace = read("frontend/src/components/map/MapWorkspace.tsx")
|
||||||
|
|
||||||
|
national_check = project_hook.index("const nationalProject")
|
||||||
regional_check = project_hook.index("const regionalProject")
|
regional_check = project_hook.index("const regionalProject")
|
||||||
municipality_check = project_hook.index("const municipalityProject")
|
assert national_check < regional_check
|
||||||
assert regional_check < municipality_check
|
assert "return nationalProject.id" in project_hook
|
||||||
|
assert "const municipalityProject" not in project_hook
|
||||||
assert 'aria-label="Regio"' not in map_workspace
|
assert 'aria-label="Regio"' not in map_workspace
|
||||||
assert 'aria-label="Ingeladen regiobereik"' in map_workspace
|
assert 'aria-label="Ingeladen regiobereik"' in map_workspace
|
||||||
assert "Snel naar een gemeente (optioneel)" in map_workspace
|
assert "Snel naar een gemeente (optioneel)" in map_workspace
|
||||||
@@ -56,7 +58,8 @@ def test_configured_yolo_and_active_asset_are_selected_without_hiding_limitation
|
|||||||
assert "asset.active" in hook
|
assert "asset.active" in hook
|
||||||
assert "getYoloPreflight" in hook
|
assert "getYoloPreflight" in hook
|
||||||
assert 'aria-label="Status gebouwdetectie"' in lab
|
assert 'aria-label="Status gebouwdetectie"' in lab
|
||||||
assert "resultaten blijven controleplichtig" in lab
|
assert "Nog niet nationaal gevalideerd" in lab
|
||||||
|
assert "vereisen lokale referentiedata en QA" in lab
|
||||||
assert "Modelkalibratie voor beheerders" in lab
|
assert "Modelkalibratie voor beheerders" in lab
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ def test_end_user_dataset_sources_are_human_readable() -> None:
|
|||||||
assert "department_omgeving_land_use: 'Departement Omgeving'" in display
|
assert "department_omgeving_land_use: 'Departement Omgeving'" in display
|
||||||
assert "statbel: 'Statbel'" in display
|
assert "statbel: 'Statbel'" in display
|
||||||
assert "getDatasetSourceDisplayName(activeThemeDataset)" in workspace
|
assert "getDatasetSourceDisplayName(activeThemeDataset)" in workspace
|
||||||
assert "resultDataset ? getDatasetSourceDisplayName(resultDataset)" in workspace
|
assert "getDatasetSourceDisplayName(resultDataset)" in workspace
|
||||||
assert "Snel naar een gemeente (optioneel)" in workspace
|
assert "Snel naar een gemeente (optioneel)" in workspace
|
||||||
assert "latestDatasetBySeries" in catalog
|
assert "latestDatasetBySeries" in catalog
|
||||||
assert "Historische meetmomenten" in catalog
|
assert "Historische meetmomenten" in catalog
|
||||||
|
|||||||
@@ -174,7 +174,10 @@ def test_bathymetry_source_registry_is_honest_and_nationally_extensible() -> Non
|
|||||||
assert by_key["vha_inland_profiles"]["integration_status"] == "operational"
|
assert by_key["vha_inland_profiles"]["integration_status"] == "operational"
|
||||||
assert by_key["vha_inland_profiles"]["acquisition_supported"] is True
|
assert by_key["vha_inland_profiles"]["acquisition_supported"] is True
|
||||||
assert by_key["mdk_bcp_bathymetry"]["vertical_reference"] == "LAT"
|
assert by_key["mdk_bcp_bathymetry"]["vertical_reference"] == "LAT"
|
||||||
assert by_key["mdk_bcp_bathymetry"]["acquisition_supported"] is False
|
# Bounded MDK acquisition now exists but stays fail-closed until the
|
||||||
|
# operator enables it explicitly with a live-validated coverage id.
|
||||||
|
assert by_key["mdk_bcp_bathymetry"]["acquisition_supported"] is True
|
||||||
|
assert by_key["mdk_bcp_bathymetry"]["configured"] is False
|
||||||
assert by_key["spw_walloon_waterway_bathymetry"]["vertical_reference"] == "mDNG"
|
assert by_key["spw_walloon_waterway_bathymetry"]["vertical_reference"] == "mDNG"
|
||||||
assert by_key["spw_walloon_waterway_bathymetry"]["license_note"].startswith("CC BY 4.0")
|
assert by_key["spw_walloon_waterway_bathymetry"]["license_note"].startswith("CC BY 4.0")
|
||||||
|
|
||||||
|
|||||||
@@ -411,7 +411,9 @@ def test_expansion_scripts_are_packaged_and_readiness_checked() -> None:
|
|||||||
for item in BathymetryProfileAcquisitionService.list_sources()
|
for item in BathymetryProfileAcquisitionService.list_sources()
|
||||||
}
|
}
|
||||||
assert sources["mdk_bcp_bathymetry"]["integration_status"] == "probe_only"
|
assert sources["mdk_bcp_bathymetry"]["integration_status"] == "probe_only"
|
||||||
assert sources["mdk_bcp_bathymetry"]["acquisition_supported"] is False
|
# Bounded acquisition is implemented but remains disabled by default.
|
||||||
|
assert sources["mdk_bcp_bathymetry"]["acquisition_supported"] is True
|
||||||
|
assert sources["mdk_bcp_bathymetry"]["configured"] is False
|
||||||
assert "EL_wcs" in sources["mdk_bcp_bathymetry"]["service_url"]
|
assert "EL_wcs" in sources["mdk_bcp_bathymetry"]["service_url"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ def test_product_registries_expose_honest_forest_agriculture_nature_and_soil() -
|
|||||||
"spw_picc_water_surfaces",
|
"spw_picc_water_surfaces",
|
||||||
"urbis_buildings",
|
"urbis_buildings",
|
||||||
"urbis_cadastral_parcels",
|
"urbis_cadastral_parcels",
|
||||||
|
"urbis_street_axes",
|
||||||
|
"urbis_land_cover_blocks",
|
||||||
|
"urbis_forest_parks",
|
||||||
|
"urbis_water_surfaces",
|
||||||
} == set(vector)
|
} == set(vector)
|
||||||
assert vector["bwk_natura2000_2025"]["authority_level"] == "authoritative"
|
assert vector["bwk_natura2000_2025"]["authority_level"] == "authoritative"
|
||||||
assert vector["dov_soil_types"]["authority_level"] == "authoritative_historical_baseline"
|
assert vector["dov_soil_types"]["authority_level"] == "authoritative_historical_baseline"
|
||||||
@@ -401,7 +405,7 @@ def test_official_vector_routes_and_frontend_use_canonical_backend_path(monkeypa
|
|||||||
|
|
||||||
assert products_response.status_code == 200
|
assert products_response.status_code == 200
|
||||||
assert set(products_response.json()) == {"data"}
|
assert set(products_response.json()) == {"data"}
|
||||||
assert products_response.json()["data"]["total"] == 8
|
assert products_response.json()["data"]["total"] == 12
|
||||||
assert acquire_response.status_code == 200
|
assert acquire_response.status_code == 200
|
||||||
assert set(acquire_response.json()) == {"data"}
|
assert set(acquire_response.json()) == {"data"}
|
||||||
assert acquire_response.json()["data"]["job_type"] == "vector.official.acquire"
|
assert acquire_response.json()["data"]["job_type"] == "vector.official.acquire"
|
||||||
|
|||||||
@@ -90,6 +90,12 @@
|
|||||||
<Config Name="MDK Bathymetry WCS URL" Target="MDK_BATHYMETRY_WCS_URL" Default="https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs" Mode="" Description="Official metadata WCS endpoint. TLS verification is mandatory and cannot be bypassed." Type="Variable" Display="advanced" Required="true" Mask="false">https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs</Config>
|
<Config Name="MDK Bathymetry WCS URL" Target="MDK_BATHYMETRY_WCS_URL" Default="https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs" Mode="" Description="Official metadata WCS endpoint. TLS verification is mandatory and cannot be bypassed." Type="Variable" Display="advanced" Required="true" Mask="false">https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs</Config>
|
||||||
<Config Name="MDK Probe Timeout Seconds" Target="MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS" Default="20" Mode="" Description="Maximum wait for one read-only MDK GetCapabilities request." Type="Variable" Display="advanced" Required="true" Mask="false">20</Config>
|
<Config Name="MDK Probe Timeout Seconds" Target="MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS" Default="20" Mode="" Description="Maximum wait for one read-only MDK GetCapabilities request." Type="Variable" Display="advanced" Required="true" Mask="false">20</Config>
|
||||||
<Config Name="MDK Probe Maximum Response (MiB)" Target="MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB" Default="4" Mode="" Description="Maximum accepted MDK capabilities response size." Type="Variable" Display="advanced" Required="true" Mask="false">4</Config>
|
<Config Name="MDK Probe Maximum Response (MiB)" Target="MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB" Default="4" Mode="" Description="Maximum accepted MDK capabilities response size." Type="Variable" Display="advanced" Required="true" Mask="false">4</Config>
|
||||||
|
<Config Name="MDK Bathymetry Acquisition" Target="MDK_BATHYMETRY_ACQUISITION_ENABLED" Default="false" Mode="" Description="Enable bounded strict-TLS North Sea depth raster acquisition. Requires a reachable readiness probe and an advertised coverage id." Type="Variable" Display="advanced" Required="true" Mask="false">false</Config>
|
||||||
|
<Config Name="MDK Coverage ID" Target="MDK_BATHYMETRY_COVERAGE_ID" Default="" Mode="" Description="WCS coverage identifier as advertised by the live MDK capabilities document. Acquisition fails closed without it." Type="Variable" Display="advanced" Required="false" Mask="false"></Config>
|
||||||
|
<Config Name="MDK Request CRS" Target="MDK_BATHYMETRY_REQUEST_CRS" Default="EPSG:4326" Mode="" Description="CRS used for bounded MDK GetCoverage requests." Type="Variable" Display="advanced" Required="true" Mask="false">EPSG:4326</Config>
|
||||||
|
<Config Name="MDK Maximum BBox (deg2)" Target="MDK_BATHYMETRY_MAX_BBOX_DEG2" Default="0.25" Mode="" Description="Hard EPSG:4326 area limit per bounded MDK acquisition." Type="Variable" Display="advanced" Required="true" Mask="false">0.25</Config>
|
||||||
|
<Config Name="MDK Acquisition Timeout Seconds" Target="MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS" Default="120" Mode="" Description="Maximum wait for one bounded MDK GetCoverage request." Type="Variable" Display="advanced" Required="true" Mask="false">120</Config>
|
||||||
|
<Config Name="MDK Acquisition Maximum Response (MiB)" Target="MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB" Default="160" Mode="" Description="Maximum accepted MDK coverage response size." Type="Variable" Display="advanced" Required="true" Mask="false">160</Config>
|
||||||
<Config Name="Official Thematic Raster Acquisition" Target="THEMATIC_RASTER_ENABLED" Default="true" Mode="" Description="Allow bounded official Departement Omgeving rasters for space, population, accessibility and services." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
|
<Config Name="Official Thematic Raster Acquisition" Target="THEMATIC_RASTER_ENABLED" Default="true" Mode="" Description="Allow bounded official Departement Omgeving rasters for space, population, accessibility and services." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
|
||||||
<Config Name="Thematic Raster WCS URL" Target="THEMATIC_RASTER_WCS_URL" Default="https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs" Mode="" Description="Official public MercatorNet WCS endpoint. Product identifiers remain server allowlisted." Type="Variable" Display="advanced" Required="true" Mask="false">https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs</Config>
|
<Config Name="Thematic Raster WCS URL" Target="THEMATIC_RASTER_WCS_URL" Default="https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs" Mode="" Description="Official public MercatorNet WCS endpoint. Product identifiers remain server allowlisted." Type="Variable" Display="advanced" Required="true" Mask="false">https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs</Config>
|
||||||
<Config Name="Thematic Raster Minimum Side (m)" Target="THEMATIC_RASTER_MIN_SIDE_M" Default="100" Mode="" Description="Minimum bounded thematic raster request side length." Type="Variable" Display="advanced" Required="true" Mask="false">100</Config>
|
<Config Name="Thematic Raster Minimum Side (m)" Target="THEMATIC_RASTER_MIN_SIDE_M" Default="100" Mode="" Description="Minimum bounded thematic raster request side length." Type="Variable" Display="advanced" Required="true" Mask="false">100</Config>
|
||||||
@@ -110,6 +116,18 @@
|
|||||||
<Config Name="YOLO Maximum Detections" Target="YOLO_MAX_DETECTIONS" Default="1000" Mode="" Description="Hard persisted detection limit per run." Type="Variable" Display="advanced" Required="true" Mask="false">1000</Config>
|
<Config Name="YOLO Maximum Detections" Target="YOLO_MAX_DETECTIONS" Default="1000" Mode="" Description="Hard persisted detection limit per run." Type="Variable" Display="advanced" Required="true" Mask="false">1000</Config>
|
||||||
<Config Name="YOLO Duplicate IoU" Target="YOLO_DUPLICATE_IOU_THRESHOLD" Default="0.5" Mode="" Description="Cross-tile duplicate suppression threshold." Type="Variable" Display="advanced" Required="true" Mask="false">0.5</Config>
|
<Config Name="YOLO Duplicate IoU" Target="YOLO_DUPLICATE_IOU_THRESHOLD" Default="0.5" Mode="" Description="Cross-tile duplicate suppression threshold." Type="Variable" Display="advanced" Required="true" Mask="false">0.5</Config>
|
||||||
<Config Name="YOLO Batch Size" Target="YOLO_BATCH_SIZE" Default="1" Mode="" Description="Bounded inference batch size." Type="Variable" Display="advanced" Required="true" Mask="false">1</Config>
|
<Config Name="YOLO Batch Size" Target="YOLO_BATCH_SIZE" Default="1" Mode="" Description="Bounded inference batch size." Type="Variable" Display="advanced" Required="true" Mask="false">1</Config>
|
||||||
|
<Config Name="Configured YOLO Segmentation" Target="YOLO_SEG_ENABLED" Default="false" Mode="" Description="Enable only a locally mounted and explicitly configured YOLO segmentation model. No weights are downloaded." Type="Variable" Display="advanced" Required="true" Mask="false">false</Config>
|
||||||
|
<Config Name="YOLO Segmentation Model Path" Target="YOLO_SEG_MODEL_PATH" Default="" Mode="" Description="Absolute in-container path to a local segmentation model asset; no download occurs." Type="Variable" Display="advanced" Required="false" Mask="false"></Config>
|
||||||
|
<Config Name="YOLO Segmentation Model ID" Target="YOLO_SEG_MODEL_ID" Default="yolo-seg-configured" Mode="" Description="Stable segmentation model identifier shown in GeoIntel." Type="Variable" Display="advanced" Required="true" Mask="false">yolo-seg-configured</Config>
|
||||||
|
<Config Name="YOLO Segmentation Display Name" Target="YOLO_SEG_MODEL_DISPLAY_NAME" Default="Configured YOLO segmentation" Mode="" Description="Operator-facing segmentation model name." Type="Variable" Display="advanced" Required="true" Mask="false">Configured YOLO segmentation</Config>
|
||||||
|
<Config Name="YOLO Segmentation Model Version" Target="YOLO_SEG_MODEL_VERSION" Default="" Mode="" Description="Operator-supplied local segmentation model version." Type="Variable" Display="advanced" Required="false" Mask="false"></Config>
|
||||||
|
<Config Name="Configured SAM" Target="SAM_ENABLED" Default="false" Mode="" Description="Enable only a locally mounted SAM-compatible model through the ultralytics interface. No weights are downloaded." Type="Variable" Display="advanced" Required="true" Mask="false">false</Config>
|
||||||
|
<Config Name="SAM Model Path" Target="SAM_MODEL_PATH" Default="" Mode="" Description="Absolute in-container path to a local SAM model asset; no download occurs." Type="Variable" Display="advanced" Required="false" Mask="false"></Config>
|
||||||
|
<Config Name="SAM Model ID" Target="SAM_MODEL_ID" Default="sam-configured" Mode="" Description="Stable SAM model identifier shown in GeoIntel." Type="Variable" Display="advanced" Required="true" Mask="false">sam-configured</Config>
|
||||||
|
<Config Name="SAM Display Name" Target="SAM_MODEL_DISPLAY_NAME" Default="Configured SAM segmentation" Mode="" Description="Operator-facing SAM model name." Type="Variable" Display="advanced" Required="true" Mask="false">Configured SAM segmentation</Config>
|
||||||
|
<Config Name="SAM Model Version" Target="SAM_MODEL_VERSION" Default="" Mode="" Description="Operator-supplied local SAM model version." Type="Variable" Display="advanced" Required="false" Mask="false"></Config>
|
||||||
|
<Config Name="Segmentation Maximum Masks Per Tile" Target="SEGMENTATION_MAX_MASKS_PER_TILE" Default="300" Mode="" Description="Hard per-tile mask limit for segmentation inference." Type="Variable" Display="advanced" Required="true" Mask="false">300</Config>
|
||||||
|
<Config Name="Segmentation Duplicate IoU" Target="SEGMENTATION_DUPLICATE_IOU_THRESHOLD" Default="0.5" Mode="" Description="Cross-tile duplicate mask suppression threshold." Type="Variable" Display="advanced" Required="true" Mask="false">0.5</Config>
|
||||||
<Config Name="Local Ollama Assistant" Target="OLLAMA_ENABLED" Default="true" Mode="" Description="Enable the source-grounded GeoIntel assistant backed by Ollama on the Unraid host." Type="Variable" Display="always" Required="true" Mask="false">true</Config>
|
<Config Name="Local Ollama Assistant" Target="OLLAMA_ENABLED" Default="true" Mode="" Description="Enable the source-grounded GeoIntel assistant backed by Ollama on the Unraid host." Type="Variable" Display="always" Required="true" Mask="false">true</Config>
|
||||||
<Config Name="Ollama Base URL" Target="OLLAMA_BASE_URL" Default="http://host.docker.internal:11434" Mode="" Description="Ollama API reachable from the container. The deployment maps host.docker.internal to the Unraid host gateway." Type="Variable" Display="always" Required="true" Mask="false">http://host.docker.internal:11434</Config>
|
<Config Name="Ollama Base URL" Target="OLLAMA_BASE_URL" Default="http://host.docker.internal:11434" Mode="" Description="Ollama API reachable from the container. The deployment maps host.docker.internal to the Unraid host gateway." Type="Variable" Display="always" Required="true" Mask="false">http://host.docker.internal:11434</Config>
|
||||||
<Config Name="Default Ollama Model" Target="OLLAMA_DEFAULT_MODEL" Default="qwen3.5:9b" Mode="" Description="Preferred locally installed Ollama model. Users can select another installed model in GeoIntel." Type="Variable" Display="always" Required="true" Mask="false">qwen3.5:9b</Config>
|
<Config Name="Default Ollama Model" Target="OLLAMA_DEFAULT_MODEL" Default="qwen3.5:9b" Mode="" Description="Preferred locally installed Ollama model. Users can select another installed model in GeoIntel." Type="Variable" Display="always" Required="true" Mask="false">qwen3.5:9b</Config>
|
||||||
|
|||||||
@@ -94,6 +94,15 @@ MDK_BATHYMETRY_WCS_URL=https://bathy.agentschapmdk.be/spatialfusionserver/servic
|
|||||||
MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS=20
|
MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS=20
|
||||||
MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB=4
|
MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB=4
|
||||||
|
|
||||||
|
# Bounded MDK acquisition stays fail-closed until the readiness probe reports
|
||||||
|
# "reachable" and an advertised coverage id is configured explicitly.
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_ENABLED=false
|
||||||
|
MDK_BATHYMETRY_COVERAGE_ID=
|
||||||
|
MDK_BATHYMETRY_REQUEST_CRS=EPSG:4326
|
||||||
|
MDK_BATHYMETRY_MAX_BBOX_DEG2=0.25
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS=120
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB=160
|
||||||
|
|
||||||
# Allowlisted Departement Omgeving policy rasters. Regional requests are
|
# Allowlisted Departement Omgeving policy rasters. Regional requests are
|
||||||
# transferred as fixed 10 km WCS tiles before exact Area clipping.
|
# transferred as fixed 10 km WCS tiles before exact Area clipping.
|
||||||
THEMATIC_RASTER_ENABLED=true
|
THEMATIC_RASTER_ENABLED=true
|
||||||
@@ -120,6 +129,21 @@ YOLO_MAX_DETECTIONS=1000
|
|||||||
YOLO_DUPLICATE_IOU_THRESHOLD=0.5
|
YOLO_DUPLICATE_IOU_THRESHOLD=0.5
|
||||||
YOLO_BATCH_SIZE=1
|
YOLO_BATCH_SIZE=1
|
||||||
|
|
||||||
|
# Local segmentation models. GeoIntel never downloads model weights
|
||||||
|
# automatically; point these to existing local files to enable inference.
|
||||||
|
YOLO_SEG_ENABLED=false
|
||||||
|
YOLO_SEG_MODEL_PATH=
|
||||||
|
YOLO_SEG_MODEL_ID=yolo-seg-configured
|
||||||
|
YOLO_SEG_MODEL_DISPLAY_NAME=Configured YOLO segmentation
|
||||||
|
YOLO_SEG_MODEL_VERSION=
|
||||||
|
SAM_ENABLED=false
|
||||||
|
SAM_MODEL_PATH=
|
||||||
|
SAM_MODEL_ID=sam-configured
|
||||||
|
SAM_MODEL_DISPLAY_NAME=Configured SAM segmentation
|
||||||
|
SAM_MODEL_VERSION=
|
||||||
|
SEGMENTATION_MAX_MASKS_PER_TILE=300
|
||||||
|
SEGMENTATION_DUPLICATE_IOU_THRESHOLD=0.5
|
||||||
|
|
||||||
# Local Ollama assistant. The all-in-one container reaches the Unraid host
|
# Local Ollama assistant. The all-in-one container reaches the Unraid host
|
||||||
# through Docker's host-gateway mapping; no Ollama port is exposed by GeoIntel.
|
# through Docker's host-gateway mapping; no Ollama port is exposed by GeoIntel.
|
||||||
OLLAMA_ENABLED=true
|
OLLAMA_ENABLED=true
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ MDK_BATHYMETRY_PROBE_ENABLED="${MDK_BATHYMETRY_PROBE_ENABLED:-true}"
|
|||||||
MDK_BATHYMETRY_WCS_URL="${MDK_BATHYMETRY_WCS_URL:-https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs}"
|
MDK_BATHYMETRY_WCS_URL="${MDK_BATHYMETRY_WCS_URL:-https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs}"
|
||||||
MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS="${MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS:-20}"
|
MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS="${MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS:-20}"
|
||||||
MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB="${MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB:-4}"
|
MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB="${MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB:-4}"
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_ENABLED="${MDK_BATHYMETRY_ACQUISITION_ENABLED:-false}"
|
||||||
|
MDK_BATHYMETRY_COVERAGE_ID="${MDK_BATHYMETRY_COVERAGE_ID:-}"
|
||||||
|
MDK_BATHYMETRY_REQUEST_CRS="${MDK_BATHYMETRY_REQUEST_CRS:-EPSG:4326}"
|
||||||
|
MDK_BATHYMETRY_MAX_BBOX_DEG2="${MDK_BATHYMETRY_MAX_BBOX_DEG2:-0.25}"
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS="${MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS:-120}"
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB="${MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB:-160}"
|
||||||
THEMATIC_RASTER_ENABLED="${THEMATIC_RASTER_ENABLED:-true}"
|
THEMATIC_RASTER_ENABLED="${THEMATIC_RASTER_ENABLED:-true}"
|
||||||
THEMATIC_RASTER_WCS_URL="${THEMATIC_RASTER_WCS_URL:-https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs}"
|
THEMATIC_RASTER_WCS_URL="${THEMATIC_RASTER_WCS_URL:-https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs}"
|
||||||
THEMATIC_RASTER_MIN_SIDE_M="${THEMATIC_RASTER_MIN_SIDE_M:-100}"
|
THEMATIC_RASTER_MIN_SIDE_M="${THEMATIC_RASTER_MIN_SIDE_M:-100}"
|
||||||
@@ -100,6 +106,18 @@ YOLO_MAX_TILES="${YOLO_MAX_TILES:-100}"
|
|||||||
YOLO_MAX_DETECTIONS="${YOLO_MAX_DETECTIONS:-1000}"
|
YOLO_MAX_DETECTIONS="${YOLO_MAX_DETECTIONS:-1000}"
|
||||||
YOLO_DUPLICATE_IOU_THRESHOLD="${YOLO_DUPLICATE_IOU_THRESHOLD:-0.5}"
|
YOLO_DUPLICATE_IOU_THRESHOLD="${YOLO_DUPLICATE_IOU_THRESHOLD:-0.5}"
|
||||||
YOLO_BATCH_SIZE="${YOLO_BATCH_SIZE:-1}"
|
YOLO_BATCH_SIZE="${YOLO_BATCH_SIZE:-1}"
|
||||||
|
YOLO_SEG_ENABLED="${YOLO_SEG_ENABLED:-false}"
|
||||||
|
YOLO_SEG_MODEL_PATH="${YOLO_SEG_MODEL_PATH:-}"
|
||||||
|
YOLO_SEG_MODEL_ID="${YOLO_SEG_MODEL_ID:-yolo-seg-configured}"
|
||||||
|
YOLO_SEG_MODEL_DISPLAY_NAME="${YOLO_SEG_MODEL_DISPLAY_NAME:-Configured YOLO segmentation}"
|
||||||
|
YOLO_SEG_MODEL_VERSION="${YOLO_SEG_MODEL_VERSION:-}"
|
||||||
|
SAM_ENABLED="${SAM_ENABLED:-false}"
|
||||||
|
SAM_MODEL_PATH="${SAM_MODEL_PATH:-}"
|
||||||
|
SAM_MODEL_ID="${SAM_MODEL_ID:-sam-configured}"
|
||||||
|
SAM_MODEL_DISPLAY_NAME="${SAM_MODEL_DISPLAY_NAME:-Configured SAM segmentation}"
|
||||||
|
SAM_MODEL_VERSION="${SAM_MODEL_VERSION:-}"
|
||||||
|
SEGMENTATION_MAX_MASKS_PER_TILE="${SEGMENTATION_MAX_MASKS_PER_TILE:-300}"
|
||||||
|
SEGMENTATION_DUPLICATE_IOU_THRESHOLD="${SEGMENTATION_DUPLICATE_IOU_THRESHOLD:-0.5}"
|
||||||
OLLAMA_ENABLED="${OLLAMA_ENABLED:-true}"
|
OLLAMA_ENABLED="${OLLAMA_ENABLED:-true}"
|
||||||
OLLAMA_BASE_URL="${OLLAMA_BASE_URL:-http://host.docker.internal:11434}"
|
OLLAMA_BASE_URL="${OLLAMA_BASE_URL:-http://host.docker.internal:11434}"
|
||||||
OLLAMA_DEFAULT_MODEL="${OLLAMA_DEFAULT_MODEL:-qwen3.5:9b}"
|
OLLAMA_DEFAULT_MODEL="${OLLAMA_DEFAULT_MODEL:-qwen3.5:9b}"
|
||||||
@@ -248,6 +266,12 @@ docker run -d \
|
|||||||
-e MDK_BATHYMETRY_WCS_URL="$MDK_BATHYMETRY_WCS_URL" \
|
-e MDK_BATHYMETRY_WCS_URL="$MDK_BATHYMETRY_WCS_URL" \
|
||||||
-e MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS="$MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS" \
|
-e MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS="$MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS" \
|
||||||
-e MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB="$MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB" \
|
-e MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB="$MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB" \
|
||||||
|
-e MDK_BATHYMETRY_ACQUISITION_ENABLED="$MDK_BATHYMETRY_ACQUISITION_ENABLED" \
|
||||||
|
-e MDK_BATHYMETRY_COVERAGE_ID="$MDK_BATHYMETRY_COVERAGE_ID" \
|
||||||
|
-e MDK_BATHYMETRY_REQUEST_CRS="$MDK_BATHYMETRY_REQUEST_CRS" \
|
||||||
|
-e MDK_BATHYMETRY_MAX_BBOX_DEG2="$MDK_BATHYMETRY_MAX_BBOX_DEG2" \
|
||||||
|
-e MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS="$MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS" \
|
||||||
|
-e MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB="$MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB" \
|
||||||
-e THEMATIC_RASTER_ENABLED="$THEMATIC_RASTER_ENABLED" \
|
-e THEMATIC_RASTER_ENABLED="$THEMATIC_RASTER_ENABLED" \
|
||||||
-e THEMATIC_RASTER_WCS_URL="$THEMATIC_RASTER_WCS_URL" \
|
-e THEMATIC_RASTER_WCS_URL="$THEMATIC_RASTER_WCS_URL" \
|
||||||
-e THEMATIC_RASTER_MIN_SIDE_M="$THEMATIC_RASTER_MIN_SIDE_M" \
|
-e THEMATIC_RASTER_MIN_SIDE_M="$THEMATIC_RASTER_MIN_SIDE_M" \
|
||||||
@@ -268,6 +292,18 @@ docker run -d \
|
|||||||
-e YOLO_MAX_DETECTIONS="$YOLO_MAX_DETECTIONS" \
|
-e YOLO_MAX_DETECTIONS="$YOLO_MAX_DETECTIONS" \
|
||||||
-e YOLO_DUPLICATE_IOU_THRESHOLD="$YOLO_DUPLICATE_IOU_THRESHOLD" \
|
-e YOLO_DUPLICATE_IOU_THRESHOLD="$YOLO_DUPLICATE_IOU_THRESHOLD" \
|
||||||
-e YOLO_BATCH_SIZE="$YOLO_BATCH_SIZE" \
|
-e YOLO_BATCH_SIZE="$YOLO_BATCH_SIZE" \
|
||||||
|
-e YOLO_SEG_ENABLED="$YOLO_SEG_ENABLED" \
|
||||||
|
-e YOLO_SEG_MODEL_PATH="$YOLO_SEG_MODEL_PATH" \
|
||||||
|
-e YOLO_SEG_MODEL_ID="$YOLO_SEG_MODEL_ID" \
|
||||||
|
-e YOLO_SEG_MODEL_DISPLAY_NAME="$YOLO_SEG_MODEL_DISPLAY_NAME" \
|
||||||
|
-e YOLO_SEG_MODEL_VERSION="$YOLO_SEG_MODEL_VERSION" \
|
||||||
|
-e SAM_ENABLED="$SAM_ENABLED" \
|
||||||
|
-e SAM_MODEL_PATH="$SAM_MODEL_PATH" \
|
||||||
|
-e SAM_MODEL_ID="$SAM_MODEL_ID" \
|
||||||
|
-e SAM_MODEL_DISPLAY_NAME="$SAM_MODEL_DISPLAY_NAME" \
|
||||||
|
-e SAM_MODEL_VERSION="$SAM_MODEL_VERSION" \
|
||||||
|
-e SEGMENTATION_MAX_MASKS_PER_TILE="$SEGMENTATION_MAX_MASKS_PER_TILE" \
|
||||||
|
-e SEGMENTATION_DUPLICATE_IOU_THRESHOLD="$SEGMENTATION_DUPLICATE_IOU_THRESHOLD" \
|
||||||
-e OLLAMA_ENABLED="$OLLAMA_ENABLED" \
|
-e OLLAMA_ENABLED="$OLLAMA_ENABLED" \
|
||||||
-e OLLAMA_BASE_URL="$OLLAMA_BASE_URL" \
|
-e OLLAMA_BASE_URL="$OLLAMA_BASE_URL" \
|
||||||
-e OLLAMA_DEFAULT_MODEL="$OLLAMA_DEFAULT_MODEL" \
|
-e OLLAMA_DEFAULT_MODEL="$OLLAMA_DEFAULT_MODEL" \
|
||||||
|
|||||||
@@ -93,6 +93,24 @@ services:
|
|||||||
YOLO_MAX_DETECTIONS: ${YOLO_MAX_DETECTIONS:-1000}
|
YOLO_MAX_DETECTIONS: ${YOLO_MAX_DETECTIONS:-1000}
|
||||||
YOLO_DUPLICATE_IOU_THRESHOLD: ${YOLO_DUPLICATE_IOU_THRESHOLD:-0.5}
|
YOLO_DUPLICATE_IOU_THRESHOLD: ${YOLO_DUPLICATE_IOU_THRESHOLD:-0.5}
|
||||||
YOLO_BATCH_SIZE: ${YOLO_BATCH_SIZE:-1}
|
YOLO_BATCH_SIZE: ${YOLO_BATCH_SIZE:-1}
|
||||||
|
YOLO_SEG_ENABLED: ${YOLO_SEG_ENABLED:-false}
|
||||||
|
YOLO_SEG_MODEL_PATH: ${YOLO_SEG_MODEL_PATH:-}
|
||||||
|
YOLO_SEG_MODEL_ID: ${YOLO_SEG_MODEL_ID:-yolo-seg-configured}
|
||||||
|
YOLO_SEG_MODEL_DISPLAY_NAME: ${YOLO_SEG_MODEL_DISPLAY_NAME:-Configured YOLO segmentation}
|
||||||
|
YOLO_SEG_MODEL_VERSION: ${YOLO_SEG_MODEL_VERSION:-}
|
||||||
|
SAM_ENABLED: ${SAM_ENABLED:-false}
|
||||||
|
SAM_MODEL_PATH: ${SAM_MODEL_PATH:-}
|
||||||
|
SAM_MODEL_ID: ${SAM_MODEL_ID:-sam-configured}
|
||||||
|
SAM_MODEL_DISPLAY_NAME: ${SAM_MODEL_DISPLAY_NAME:-Configured SAM segmentation}
|
||||||
|
SAM_MODEL_VERSION: ${SAM_MODEL_VERSION:-}
|
||||||
|
SEGMENTATION_MAX_MASKS_PER_TILE: ${SEGMENTATION_MAX_MASKS_PER_TILE:-300}
|
||||||
|
SEGMENTATION_DUPLICATE_IOU_THRESHOLD: ${SEGMENTATION_DUPLICATE_IOU_THRESHOLD:-0.5}
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_ENABLED: ${MDK_BATHYMETRY_ACQUISITION_ENABLED:-false}
|
||||||
|
MDK_BATHYMETRY_COVERAGE_ID: ${MDK_BATHYMETRY_COVERAGE_ID:-}
|
||||||
|
MDK_BATHYMETRY_REQUEST_CRS: ${MDK_BATHYMETRY_REQUEST_CRS:-EPSG:4326}
|
||||||
|
MDK_BATHYMETRY_MAX_BBOX_DEG2: ${MDK_BATHYMETRY_MAX_BBOX_DEG2:-0.25}
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS: ${MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS:-120}
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB: ${MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB:-160}
|
||||||
OLLAMA_ENABLED: ${OLLAMA_ENABLED:-true}
|
OLLAMA_ENABLED: ${OLLAMA_ENABLED:-true}
|
||||||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
|
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
|
||||||
OLLAMA_DEFAULT_MODEL: ${OLLAMA_DEFAULT_MODEL:-qwen3.5:9b}
|
OLLAMA_DEFAULT_MODEL: ${OLLAMA_DEFAULT_MODEL:-qwen3.5:9b}
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ services:
|
|||||||
MDK_BATHYMETRY_WCS_URL: ${MDK_BATHYMETRY_WCS_URL:-https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs}
|
MDK_BATHYMETRY_WCS_URL: ${MDK_BATHYMETRY_WCS_URL:-https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs}
|
||||||
MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS: ${MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS:-20}
|
MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS: ${MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS:-20}
|
||||||
MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB: ${MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB:-4}
|
MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB: ${MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB:-4}
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_ENABLED: ${MDK_BATHYMETRY_ACQUISITION_ENABLED:-false}
|
||||||
|
MDK_BATHYMETRY_COVERAGE_ID: ${MDK_BATHYMETRY_COVERAGE_ID:-}
|
||||||
|
MDK_BATHYMETRY_REQUEST_CRS: ${MDK_BATHYMETRY_REQUEST_CRS:-EPSG:4326}
|
||||||
|
MDK_BATHYMETRY_MAX_BBOX_DEG2: ${MDK_BATHYMETRY_MAX_BBOX_DEG2:-0.25}
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS: ${MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS:-120}
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB: ${MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB:-160}
|
||||||
THEMATIC_RASTER_ENABLED: ${THEMATIC_RASTER_ENABLED:-true}
|
THEMATIC_RASTER_ENABLED: ${THEMATIC_RASTER_ENABLED:-true}
|
||||||
THEMATIC_RASTER_WCS_URL: ${THEMATIC_RASTER_WCS_URL:-https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs}
|
THEMATIC_RASTER_WCS_URL: ${THEMATIC_RASTER_WCS_URL:-https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs}
|
||||||
THEMATIC_RASTER_MIN_SIDE_M: ${THEMATIC_RASTER_MIN_SIDE_M:-100}
|
THEMATIC_RASTER_MIN_SIDE_M: ${THEMATIC_RASTER_MIN_SIDE_M:-100}
|
||||||
@@ -112,6 +118,19 @@ services:
|
|||||||
YOLO_MAX_DETECTIONS: ${YOLO_MAX_DETECTIONS:-1000}
|
YOLO_MAX_DETECTIONS: ${YOLO_MAX_DETECTIONS:-1000}
|
||||||
YOLO_DUPLICATE_IOU_THRESHOLD: ${YOLO_DUPLICATE_IOU_THRESHOLD:-0.5}
|
YOLO_DUPLICATE_IOU_THRESHOLD: ${YOLO_DUPLICATE_IOU_THRESHOLD:-0.5}
|
||||||
YOLO_BATCH_SIZE: ${YOLO_BATCH_SIZE:-1}
|
YOLO_BATCH_SIZE: ${YOLO_BATCH_SIZE:-1}
|
||||||
|
YOLO_SEG_ENABLED: ${YOLO_SEG_ENABLED:-false}
|
||||||
|
YOLO_SEG_MODEL_PATH: ${YOLO_SEG_MODEL_PATH:-}
|
||||||
|
YOLO_SEG_MODEL_ID: ${YOLO_SEG_MODEL_ID:-yolo-seg-configured}
|
||||||
|
YOLO_SEG_MODEL_DISPLAY_NAME: ${YOLO_SEG_MODEL_DISPLAY_NAME:-Configured YOLO segmentation}
|
||||||
|
YOLO_SEG_MODEL_VERSION: ${YOLO_SEG_MODEL_VERSION:-}
|
||||||
|
SAM_ENABLED: ${SAM_ENABLED:-false}
|
||||||
|
SAM_MODEL_PATH: ${SAM_MODEL_PATH:-}
|
||||||
|
SAM_MODEL_ID: ${SAM_MODEL_ID:-sam-configured}
|
||||||
|
SAM_MODEL_DISPLAY_NAME: ${SAM_MODEL_DISPLAY_NAME:-Configured SAM segmentation}
|
||||||
|
SAM_MODEL_VERSION: ${SAM_MODEL_VERSION:-}
|
||||||
|
SEGMENTATION_MAX_MASKS_PER_TILE: ${SEGMENTATION_MAX_MASKS_PER_TILE:-300}
|
||||||
|
SEGMENTATION_DUPLICATE_IOU_THRESHOLD: ${SEGMENTATION_DUPLICATE_IOU_THRESHOLD:-0.5}
|
||||||
|
GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP: ${GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP:-true}
|
||||||
OLLAMA_ENABLED: ${OLLAMA_ENABLED:-false}
|
OLLAMA_ENABLED: ${OLLAMA_ENABLED:-false}
|
||||||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
|
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
|
||||||
OLLAMA_DEFAULT_MODEL: ${OLLAMA_DEFAULT_MODEL:-qwen3.5:9b}
|
OLLAMA_DEFAULT_MODEL: ${OLLAMA_DEFAULT_MODEL:-qwen3.5:9b}
|
||||||
|
|||||||
+35
-16
@@ -123,7 +123,7 @@ a canonical operational workspace without depending on its position among
|
|||||||
newer operator or benchmark projects:
|
newer operator or benchmark projects:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
GET /api/v1/projects?name=Kempen%20Regional%20Workbench&limit=1
|
GET /api/v1/projects?name=Belgium%20and%20North%20Sea%20Workbench&limit=1
|
||||||
GET /api/v1/projects?status=archived&limit=50
|
GET /api/v1/projects?status=archived&limit=50
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ Request:
|
|||||||
{
|
{
|
||||||
"name": "Geel building detection demo",
|
"name": "Geel building detection demo",
|
||||||
"description": "Detect buildings and validate against GRB",
|
"description": "Detect buildings and validate against GRB",
|
||||||
"region": "Kempen"
|
"region": "Belgium and Belgian North Sea"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1202,13 +1202,15 @@ Returns object-detection model capability descriptors.
|
|||||||
|
|
||||||
### GET `/api/v1/detection/model-assets`
|
### GET `/api/v1/detection/model-assets`
|
||||||
|
|
||||||
Returns local runtime model files discovered in the configured model directory.
|
Returns governed local runtime model files. This is a read-only catalog.
|
||||||
This is a read-only catalog. GeoIntel never downloads, creates, mutates or
|
GeoIntel never downloads, creates, mutates or deletes model weights from this
|
||||||
deletes model weights from this endpoint.
|
endpoint.
|
||||||
|
|
||||||
The backend scans `YOLO_MODELS_DIR` (default `/app/models`) and reports
|
The backend scans `YOLO_MODELS_DIR` (default `/app/models`). When
|
||||||
supported local model files such as `.pt`, `.onnx` and `.engine`. The active
|
`YOLO_MODEL_PATH` resolves to an existing file, production catalog output is
|
||||||
model is the file matching `YOLO_MODEL_PATH`.
|
restricted to that explicitly approved active model. When no active model is
|
||||||
|
configured, supported `.pt`, `.onnx` and `.engine` files remain visible for
|
||||||
|
development/operator discovery but cannot make the configured detector ready.
|
||||||
|
|
||||||
Response data:
|
Response data:
|
||||||
|
|
||||||
@@ -1226,8 +1228,8 @@ Response data:
|
|||||||
"size_bytes": 123456,
|
"size_bytes": 123456,
|
||||||
"sha256": "sha256hex",
|
"sha256": "sha256hex",
|
||||||
"active": true,
|
"active": true,
|
||||||
"status": "available",
|
"status": "approved",
|
||||||
"limitation_message": "Local runtime model asset. GeoIntel will not download or mutate model weights.",
|
"limitation_message": "Approved local runtime model asset. GeoIntel will not download or mutate model weights.",
|
||||||
"will_download_models": false
|
"will_download_models": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -2217,9 +2219,11 @@ sets an explicit Ollama context window and returns
|
|||||||
|
|
||||||
Returns the governed bathymetry source registry in the canonical envelope.
|
Returns the governed bathymetry source registry in the canonical envelope.
|
||||||
VHA inland profiles are `operational`. MDK Belgian Continental Shelf is
|
VHA inland profiles are `operational`. MDK Belgian Continental Shelf is
|
||||||
`probe_only`. The pinned SPW Walloon bathymetry archive is `operational`
|
`not_configured` by default and becomes `operational` only after the operator
|
||||||
through a bounded, explicit operator import. There is no browser-side source
|
explicitly enables bounded acquisition and pins a coverage identifier. The
|
||||||
fetch and no arbitrary source URL.
|
pinned SPW Walloon bathymetry archive is `operational` through a bounded,
|
||||||
|
explicit operator import. There is no browser-side source fetch and no
|
||||||
|
arbitrary source URL.
|
||||||
|
|
||||||
### GET `/api/v1/projects/{project_id}/datasets/bathymetry/sources/mdk_bcp_bathymetry/readiness`
|
### GET `/api/v1/projects/{project_id}/datasets/bathymetry/sources/mdk_bcp_bathymetry/readiness`
|
||||||
|
|
||||||
@@ -2227,9 +2231,24 @@ Runs one bounded, read-only WCS 1.0.0 `GetCapabilities` request with mandatory
|
|||||||
system TLS verification and a configured response-size limit. Status is one
|
system TLS verification and a configured response-size limit. Status is one
|
||||||
of `disabled`, `invalid_configuration`, `tls_error`,
|
of `disabled`, `invalid_configuration`, `tls_error`,
|
||||||
`endpoint_unavailable`, `invalid_capabilities` or `reachable`. A reachable
|
`endpoint_unavailable`, `invalid_capabilities` or `reachable`. A reachable
|
||||||
response lists coverage identifiers, advertised formats and CRS values, but
|
response lists coverage identifiers, advertised formats and CRS values. There
|
||||||
always returns `acquisition_supported=false`. There is no insecure TLS
|
is no insecure TLS fallback and this readiness endpoint never performs a
|
||||||
fallback and no `GetCoverage` request.
|
`GetCoverage` request.
|
||||||
|
|
||||||
|
### POST `/api/v1/projects/{project_id}/datasets/bathymetry/mdk/acquire`
|
||||||
|
|
||||||
|
Runs one explicit, bounded WCS 1.0.0 `GetCoverage` acquisition as a synchronous
|
||||||
|
Job. The request contains an EPSG:4326 `bbox`, optional persisted `area_id` and
|
||||||
|
`force_refresh`. Acquisition is fail-closed unless
|
||||||
|
`MDK_BATHYMETRY_ACQUISITION_ENABLED=true`, a coverage identifier is explicitly
|
||||||
|
configured, the strict-TLS readiness probe is reachable and that identifier is
|
||||||
|
advertised by the live capabilities document.
|
||||||
|
|
||||||
|
The configured bbox-area, response-size, timeout and pixel-dimension limits are
|
||||||
|
always enforced. A successful GeoTIFF is validated and imported through the
|
||||||
|
existing Dataset raster flow with request hash, response hash, acquisition
|
||||||
|
time, MDK attribution and the `LAT` vertical reference in provenance. No depth
|
||||||
|
values are synthesized and no water volume is inferred.
|
||||||
|
|
||||||
### POST `/api/v1/projects/{project_id}/datasets/bathymetry/profiles/acquire`
|
### POST `/api/v1/projects/{project_id}/datasets/bathymetry/profiles/acquire`
|
||||||
|
|
||||||
|
|||||||
@@ -11011,3 +11011,31 @@ Validation:
|
|||||||
passed (12 tests);
|
passed (12 tests);
|
||||||
- live redeployment and a repeated Belgium-scale rectangle follow on the
|
- live redeployment and a repeated Belgium-scale rectangle follow on the
|
||||||
immutable patch revision.
|
immutable patch revision.
|
||||||
|
## 2026-07-22 - National scope and governed Brussels land cover
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
|
||||||
|
- made the persisted Belgium/North Sea project the unconditional startup
|
||||||
|
workspace and changed the initial basemap extent and form defaults from Mol
|
||||||
|
to Belgium plus its legally labelled maritime scope;
|
||||||
|
- retained Mol/Kempen provisioning and tests strictly as golden regression
|
||||||
|
evidence instead of product routing;
|
||||||
|
- live-validated the UrbIS WFS `Blocks` contract and added bounded land-cover,
|
||||||
|
FO/GB forest/park and WB permanent-water products with clipped PostGIS area
|
||||||
|
metrics and source-class provenance;
|
||||||
|
- restricted the production model picker to the explicit active model asset
|
||||||
|
and surfaced that the current building model is locally, not nationally,
|
||||||
|
validated.
|
||||||
|
|
||||||
|
Validated during implementation:
|
||||||
|
|
||||||
|
- backend compile and frontend typecheck passed;
|
||||||
|
- 31 frontend unit tests passed;
|
||||||
|
- focused national coverage, model catalog, project lifecycle and Mol golden
|
||||||
|
regression tests passed;
|
||||||
|
- live resolver checks covered Brussels, Wallonia and all three Belgian
|
||||||
|
maritime legal zones. WALOUS, Walloon flood analytics and multi-epoch marine
|
||||||
|
bathymetry remain real open source-integration work and were not simulated.
|
||||||
|
- the final repository readiness gate passed with 1,084 backend tests, 31
|
||||||
|
frontend unit tests, compile, typecheck, production build, Alembic head
|
||||||
|
`202607160001` and all script syntax/contract checks.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# GeoIntel data coverage status
|
# GeoIntel data coverage status
|
||||||
|
|
||||||
Status date: 2026-07-21
|
Status date: 2026-07-22
|
||||||
|
|
||||||
This document is the operational interpretation of the source registry. It
|
This document is the operational interpretation of the source registry. It
|
||||||
does not replace the legal/source provenance stored with each Dataset.
|
does not replace the legal/source provenance stored with each Dataset.
|
||||||
@@ -27,7 +27,7 @@ coverage or historical dates.
|
|||||||
| Belgium | NGI administrative boundaries; Statbel population/statistical sectors | Statbel population 2021-2025 |
|
| Belgium | NGI administrative boundaries; Statbel population/statistical sectors | Statbel population 2021-2025 |
|
||||||
| Flanders | GRB buildings, roads, water and parcels; DHMV terrain/surface; VMM flood scenarios; BWK/Natura 2000; DOV soil; policy rasters for space, open space, accessibility and services; agriculture and orthophoto where governed | Population 2021-2025; land-use/land-cover series where retained; agriculture editions; historical maps/orthophotos where the selected product has a real observation date |
|
| Flanders | GRB buildings, roads, water and parcels; DHMV terrain/surface; VMM flood scenarios; BWK/Natura 2000; DOV soil; policy rasters for space, open space, accessibility and services; agriculture and orthophoto where governed | Population 2021-2025; land-use/land-cover series where retained; agriculture editions; historical maps/orthophotos where the selected product has a real observation date |
|
||||||
| Wallonia | Bounded PICC buildings, roads and hydrography; governed SPW bed-elevation/bathymetry products | No general cross-theme regional history yet |
|
| Wallonia | Bounded PICC buildings, roads and hydrography; governed SPW bed-elevation/bathymetry products | No general cross-theme regional history yet |
|
||||||
| Brussels | Bounded UrbIS buildings, street axes and cadastral parcels | No general cross-theme regional history yet |
|
| Brussels | Bounded UrbIS buildings, street axes, cadastral parcels and Land Cover blocks; official FO/GB blocks provide forest/park area and WB blocks provide permanent water area | No general cross-theme regional history yet; the live WFS has no per-feature observation date |
|
||||||
| Belgian North Sea | RBINS reporting units; Marine Spatial Plan 2026-2034; governed MDK bathymetry only when runtime acquisition is explicitly configured | No multi-epoch bathymetry or marine-plan trend yet |
|
| Belgian North Sea | RBINS reporting units; Marine Spatial Plan 2026-2034; governed MDK bathymetry only when runtime acquisition is explicitly configured | No multi-epoch bathymetry or marine-plan trend yet |
|
||||||
|
|
||||||
Mol and the Kempen are golden regression areas. Their persisted partitions are
|
Mol and the Kempen are golden regression areas. Their persisted partitions are
|
||||||
@@ -46,22 +46,18 @@ applicable bounded official source or reports the theme as unsupported.
|
|||||||
retain their model scenario semantics separately from observed floods.
|
retain their model scenario semantics separately from observed floods.
|
||||||
Official record:
|
Official record:
|
||||||
`https://geoportail.wallonie.be/catalogue/14084108-2c7b-4091-b62d-ff0fc235213a.html`.
|
`https://geoportail.wallonie.be/catalogue/14084108-2c7b-4091-b62d-ff0fc235213a.html`.
|
||||||
3. Add the public UrbIS Land Cover product (regional situation 2024) for
|
3. Add a common Belgium-wide topographic baseline with normalized theme
|
||||||
Brussels through its official WFS/download contract. Keep it separate from
|
|
||||||
cadastral parcels and buildings. Product specification:
|
|
||||||
`https://urbisdownload.datastore.brussels/UrbIS/TechSpec/LandCover_TechSpec_FR20240401.pdf`.
|
|
||||||
4. Add a common Belgium-wide topographic baseline with normalized theme
|
|
||||||
semantics across NGI, Flanders, Wallonia and Brussels.
|
semantics across NGI, Flanders, Wallonia and Brussels.
|
||||||
5. Govern comparable Walloon and Brussels historical editions before exposing
|
4. Govern comparable Walloon and Brussels historical editions before exposing
|
||||||
evolution for buildings, roads, land cover, soil, elevation or flood risk.
|
evolution for buildings, roads, land cover, soil, elevation or flood risk.
|
||||||
6. Add nationally comparable land-cover history with explicit class crosswalks
|
5. Add nationally comparable land-cover history with explicit class crosswalks
|
||||||
and uncertainty; never compare incompatible legends silently.
|
and uncertainty; never compare incompatible legends silently.
|
||||||
7. Add multi-epoch marine bathymetry and survey-footprint metadata before
|
6. Add multi-epoch marine bathymetry and survey-footprint metadata before
|
||||||
presenting seabed evolution.
|
presenting seabed evolution.
|
||||||
8. Expand persisted raster partition manifests beyond the regression regions
|
7. Expand persisted raster partition manifests beyond the regression regions
|
||||||
only where repeated use justifies caching; bounded acquisition remains the
|
only where repeated use justifies caching; bounded acquisition remains the
|
||||||
default for one-off selections.
|
default for one-off selections.
|
||||||
9. Add source freshness probes only for publishers with stable official edition
|
8. Add source freshness probes only for publishers with stable official edition
|
||||||
contracts. Do not infer a new observation from an import or HTTP date.
|
contracts. Do not infer a new observation from an import or HTTP date.
|
||||||
|
|
||||||
## Acceptance rules for a new source
|
## Acceptance rules for a new source
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ runtime source of truth.
|
|||||||
- Buildings, population, terrain, imagery, nature, agriculture, soil and flood
|
- Buildings, population, terrain, imagery, nature, agriculture, soil and flood
|
||||||
themes may report `partial`, `not_configured` or `unsupported` outside the
|
themes may report `partial`, `not_configured` or `unsupported` outside the
|
||||||
materialized source partitions. The UI and exports retain that state.
|
materialized source partitions. The UI and exports retain that state.
|
||||||
- Belgian North Sea planning/reporting boundaries are materialized. Continuous
|
- Belgian North Sea planning/reporting boundaries are materialized. Bounded
|
||||||
authoritative bathymetry acquisition remains `not_configured`; VHA profile
|
strict-TLS MDK WCS acquisition is implemented but stays disabled until the
|
||||||
observations are not presented as a seabed model or water volume.
|
operator enables `MDK_BATHYMETRY_ACQUISITION_ENABLED` with a coverage id that
|
||||||
|
the live readiness probe advertises. Until then the theme reports
|
||||||
|
`not_configured`; VHA profile observations are not presented as a seabed
|
||||||
|
model or water volume.
|
||||||
- Official endpoints can be temporarily unavailable. Bounded acquisition fails
|
- Official endpoints can be temporarily unavailable. Bounded acquisition fails
|
||||||
closed and never substitutes fixture or fabricated production data.
|
closed and never substitutes fixture or fabricated production data.
|
||||||
|
|
||||||
@@ -47,8 +50,11 @@ runtime source of truth.
|
|||||||
trained general model for all Belgian objects or themes.
|
trained general model for all Belgian objects or themes.
|
||||||
- PyTorch and Ultralytics are present only in the AI image. No model weights
|
- PyTorch and Ultralytics are present only in the AI image. No model weights
|
||||||
auto-download. A missing local model reports unavailable.
|
auto-download. A missing local model reports unavailable.
|
||||||
- Real segmentation models remain placeholders; fixture segmentation is
|
- Local YOLO-seg and SAM segmentation are implemented through the ultralytics
|
||||||
explicit-only. No SAM or YOLO-seg dependency is installed.
|
interface but stay `not_configured` until the operator points
|
||||||
|
`YOLO_SEG_MODEL_PATH`/`SAM_MODEL_PATH` to existing local weights and enables
|
||||||
|
them explicitly. GeoIntel never downloads segmentation weights automatically;
|
||||||
|
fixture segmentation remains explicit-only.
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
|
|||||||
+29
-3
@@ -57,9 +57,20 @@ Dit is het enige actuele afwerkingsbord. De lange sprint- en
|
|||||||
voorbereidingslijsten verderop blijven bewaard als historisch bewijs, maar zijn
|
voorbereidingslijsten verderop blijven bewaard als historisch bewijs, maar zijn
|
||||||
geen open productroadmap meer.
|
geen open productroadmap meer.
|
||||||
|
|
||||||
- [x] Open automatisch de volledige Kempen-werkruimte met Mol als snel
|
- [x] Open onvoorwaardelijk de nationale `Belgium and North Sea Workbench`
|
||||||
selecteerbaar werkgebied; een technische project- of regioselectie is niet
|
wanneer die bestaat en start de kaart op Belgische schaal. Mol en de Kempen
|
||||||
vereist.
|
blijven alleen snel selecteerbare regressiegebieden.
|
||||||
|
- [x] Maak UrbIS Land Cover begrensd operationeel voor Brussel: alle Blocks als
|
||||||
|
landbedekking, FO/GB als bos en park en WB als permanent water, met echte
|
||||||
|
PostGIS-oppervlaktemetrics en broncodes.
|
||||||
|
- [ ] Implementeer begrensde WALOUS 2018/2020/2023 rasteracquisitie in
|
||||||
|
EPSG:3812 met officiële klassen, vergelijkbaarheidscontract en pixelbudget.
|
||||||
|
- [ ] Implementeer de actuele Waalse overstromingsgevaarkaart als afzonderlijk
|
||||||
|
scenario-/juridisch contract; gebruik WMS alleen als context tenzij
|
||||||
|
analytische pixels of vectorgeometrie officieel beschikbaar zijn.
|
||||||
|
- [ ] Bouw een Belgische AI-validatiematrix met gelabelde golden AOIs in elk
|
||||||
|
gewest en aan de kust. Tot die matrix slaagt blijft de huidige YOLO-status
|
||||||
|
expliciet lokaal gevalideerd en controleplichtig.
|
||||||
- [x] Kies een begrijpbaar datathema, teken een rechthoek of gebruik het
|
- [x] Kies een begrijpbaar datathema, teken een rechthoek of gebruik het
|
||||||
volledige werkgebied en analyseer alle relevante thema's uit PostGIS.
|
volledige werkgebied en analyseer alle relevante thema's uit PostGIS.
|
||||||
Een getekende selectie mag ontbrekende operationele bronproducten begrensd
|
Een getekende selectie mag ontbrekende operationele bronproducten begrensd
|
||||||
@@ -109,6 +120,21 @@ geen open productroadmap meer.
|
|||||||
Workbench met een compacte navigatierail, vaste contextbalk, taakgerichte
|
Workbench met een compacte navigatierail, vaste contextbalk, taakgerichte
|
||||||
schermen en gevalideerde desktop-, ultrawide- en mobiele layouts.
|
schermen en gevalideerde desktop-, ultrawide- en mobiele layouts.
|
||||||
|
|
||||||
|
Post-V1 afwerkingspass (2026-07-19):
|
||||||
|
|
||||||
|
- [x] Lokale YOLO-seg- en SAM-segmentatie via de ultralytics-interface: echte
|
||||||
|
adapters, dynamisch modelregister, tegelmanifest-inferentie, georeferentie
|
||||||
|
van maskpolygonen, dedupe, geodetische oppervlakte en persistentie. Blijft
|
||||||
|
fail-closed zonder lokale modelgewichten (`YOLO_SEG_*`/`SAM_*` env).
|
||||||
|
- [x] Begrensde MDK-bathymetrie-acquisitie (WCS GetCoverage) achter de
|
||||||
|
bestaande strict-TLS readiness-probe: expliciete opt-in, geadverteerd
|
||||||
|
coverage-id verplicht, bbox-limiet, GeoTIFF-validatie, LAT-provenance.
|
||||||
|
Endpoint: `POST /datasets/bathymetry/mdk/acquire`.
|
||||||
|
- [x] UrbIS-wegassen (`urbisvector:StreetAxes`, live gevalideerd tegen de
|
||||||
|
UrbIS WFS-capabilities) als begrensd Brussels roads-product met
|
||||||
|
lengte-metrics; de WFS adverteert geen hydrografielaag, dus Brussels
|
||||||
|
oppervlaktewater blijft eerlijk `not_configured`.
|
||||||
|
|
||||||
Bewuste, niet-blokkerende grenzen:
|
Bewuste, niet-blokkerende grenzen:
|
||||||
|
|
||||||
- De begrensde SPW-rasterflow maakt Waalse waterbodemhoogte in mDNG
|
- De begrensde SPW-rasterflow maakt Waalse waterbodemhoogte in mDNG
|
||||||
|
|||||||
+13
-3
@@ -1,8 +1,12 @@
|
|||||||
# GeoIntel Frontend (Sprint 4)
|
# GeoIntel Frontend (Sprint 4)
|
||||||
|
|
||||||
React + TypeScript + MapLibre workbench for regional geographic analysis.
|
React + TypeScript + MapLibre workbench for Belgium and the Belgian North Sea.
|
||||||
|
|
||||||
The persisted `Kempen Regional Workbench` is the automatic operational data context. Its datasets are loaded once for the complete official 28-municipality Vlaamse vervoerregio; the operator chooses Mol, another municipality or the complete region as a spatial work-area filter. The primary map no longer asks the user to choose a technical project or region before data becomes usable. Regional population and modern forest snapshots use the same current/evolution flow as Mol, while explicit project selection stays available under advanced management.
|
The persisted `Belgium and North Sea Workbench` is the unconditional primary
|
||||||
|
data context whenever it exists. The map opens at national extent and supports
|
||||||
|
bounded selections across Flanders, Wallonia, Brussels and the legally labelled
|
||||||
|
Belgian maritime zones. Mol and the Kempen remain selectable golden regression
|
||||||
|
areas, but are never used as an implicit product boundary or startup fallback.
|
||||||
|
|
||||||
The Status workspace includes one compact `Actualiteit en versiecontrole`
|
The Status workspace includes one compact `Actualiteit en versiecontrole`
|
||||||
surface. It separates sources that are current, due for a catalogue review,
|
surface. It separates sources that are current, due for a catalogue review,
|
||||||
@@ -29,7 +33,13 @@ as historical observations.
|
|||||||
|
|
||||||
The user-facing shell is task based: `Kaart`, `Bronnen`, `Kwaliteit`, `Beeldanalyse`, `Downloads`, `Status` and `Beheer`. Internal benchmark projects, raw dataset metadata, provider capabilities, model registry details and QA evidence remain accessible through labelled advanced disclosures instead of competing with the normal workflow.
|
The user-facing shell is task based: `Kaart`, `Bronnen`, `Kwaliteit`, `Beeldanalyse`, `Downloads`, `Status` and `Beheer`. Internal benchmark projects, raw dataset metadata, provider capabilities, model registry details and QA evidence remain accessible through labelled advanced disclosures instead of competing with the normal workflow.
|
||||||
|
|
||||||
Detection defaults to the configured local YOLO asset and automatically selects an available raster and active model asset where possible. The model registry and preflight remain honest when PyTorch, Ultralytics, a local model file or a tile manifest is unavailable. The active building profile is operational but remains review-required: its current coverage-aligned benchmark is approximately precision 0.614, recall 0.606 and F1 0.607 over seven positive AOIs, with zero detections in all three pure-empty controls. A reviewed challenger remains inactive because it produced two detections in empty Postel forest.
|
Detection defaults to the explicitly configured local YOLO asset and
|
||||||
|
automatically selects an available raster where possible. In production the
|
||||||
|
asset catalog exposes only the file matching `YOLO_MODEL_PATH`; training,
|
||||||
|
partial and smoke checkpoints remain on disk but do not become end-user model
|
||||||
|
choices. The current building benchmark covers seven Mol/Kempen AOIs and is
|
||||||
|
shown as local validation, not as proof of national model quality. Every other
|
||||||
|
Belgian or maritime context requires local reference QA before release.
|
||||||
|
|
||||||
Map-driven building analysis uses the documented footprint-IoU `0.25` and
|
Map-driven building analysis uses the documented footprint-IoU `0.25` and
|
||||||
distinguishes model candidates from verified buildings. It shows persisted
|
distinguishes model candidates from verified buildings. It shows persisted
|
||||||
|
|||||||
@@ -842,7 +842,7 @@ function App(): JSX.Element {
|
|||||||
>
|
>
|
||||||
{activeWorkspace !== 'map' ? <div className="workspace-heading">
|
{activeWorkspace !== 'map' ? <div className="workspace-heading">
|
||||||
<div className="workspace-heading-copy">
|
<div className="workspace-heading-copy">
|
||||||
<p className="eyebrow">{selectedProject?.region ?? 'Mol, Kempen'}</p>
|
<p className="eyebrow">{selectedProject?.region ?? 'Belgie en Belgische Noordzee'}</p>
|
||||||
<h2>{activeWorkspaceItem.label}</h2>
|
<h2>{activeWorkspaceItem.label}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="workspace-heading-actions">
|
<div className="workspace-heading-actions">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import maplibregl from 'maplibre-gl'
|
import maplibregl from 'maplibre-gl'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { PRIMARY_FOCUS_CENTER } from '../config/primaryFocus'
|
import { NATIONAL_MAP_CENTER, NATIONAL_MAP_ZOOM } from '../config/primaryFocus'
|
||||||
import { featureCollectionBounds } from '../lib/geojsonBounds'
|
import { featureCollectionBounds } from '../lib/geojsonBounds'
|
||||||
import type { MapImageOverlay, MapViewportState, VectorSelectionBBox } from '../types'
|
import type { MapImageOverlay, MapViewportState, VectorSelectionBBox } from '../types'
|
||||||
|
|
||||||
@@ -221,8 +221,8 @@ function GeoMap({
|
|||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: defaultMapStyle(),
|
style: defaultMapStyle(),
|
||||||
center: PRIMARY_FOCUS_CENTER,
|
center: NATIONAL_MAP_CENTER,
|
||||||
zoom: 11,
|
zoom: NATIONAL_MAP_ZOOM,
|
||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
})
|
})
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ export function SourceCatalogPanel({
|
|||||||
{Number(latestBuildingsRegister.source_metadata?.['building_unit_count'] ?? 0).toLocaleString('nl-BE')} eenheden · {' '}
|
{Number(latestBuildingsRegister.source_metadata?.['building_unit_count'] ?? 0).toLocaleString('nl-BE')} eenheden · {' '}
|
||||||
{Number(latestBuildingsRegister.source_metadata?.['linked_address_count'] ?? 0).toLocaleString('nl-BE')} gekoppelde adressen
|
{Number(latestBuildingsRegister.source_metadata?.['linked_address_count'] ?? 0).toLocaleString('nl-BE')} gekoppelde adressen
|
||||||
</span>
|
</span>
|
||||||
<p>Registerstatus en geaggregeerde koppelingen voor Mol; adreslabels en persoonsgegevens worden niet in de kaartlaag getoond.</p>
|
<p>Registerstatus en geaggregeerde koppelingen binnen de werkelijk ingeladen dekking; adreslabels en persoonsgegevens worden niet in de kaartlaag getoond.</p>
|
||||||
</article>
|
</article>
|
||||||
) : null}
|
) : null}
|
||||||
{dhmvDatasets.length > 0 ? (
|
{dhmvDatasets.length > 0 ? (
|
||||||
|
|||||||
@@ -297,9 +297,9 @@ export function DetectionLab({
|
|||||||
<p>{yoloRuntimeReady ? `${yoloPreflight?.runtime.cuda_available ? 'GPU' : 'CPU'} · lokaal model gevonden` : 'Controleer de modelconfiguratie onder beheer.'}</p>
|
<p>{yoloRuntimeReady ? `${yoloPreflight?.runtime.cuda_available ? 'GPU' : 'CPU'} · lokaal model gevonden` : 'Controleer de modelconfiguratie onder beheer.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ai-user-summary-card">
|
<div className="ai-user-summary-card">
|
||||||
<span>Gevalideerde kwaliteit</span>
|
<span>Validatiescope</span>
|
||||||
<strong>{selectedOperatorProfile ? `F1 ${selectedOperatorProfile.f1.toFixed(3)}` : 'Nog niet gekoppeld'}</strong>
|
<strong>{selectedOperatorProfile ? `F1 ${selectedOperatorProfile.f1.toFixed(3)}` : 'Nog niet gekoppeld'}</strong>
|
||||||
<p>{selectedOperatorProfile ? `${selectedOperatorProfile.positiveSampleCount} testgebieden · resultaten blijven controleplichtig` : 'Kies het goedgekeurde lokale profiel.'}</p>
|
<p>{selectedOperatorProfile ? selectedOperatorProfile.validationScope : 'Kies een modelprofiel met gedocumenteerd evaluatiebewijs.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={rasterDatasets.length > 0 ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}>
|
<div className={rasterDatasets.length > 0 ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}>
|
||||||
<span>Beschikbare luchtbeelden</span>
|
<span>Beschikbare luchtbeelden</span>
|
||||||
@@ -310,6 +310,15 @@ export function DetectionLab({
|
|||||||
<p className="ai-quality-guidance">
|
<p className="ai-quality-guidance">
|
||||||
{detectionQualityInterpretation(selectedOperatorProfile?.f1)}
|
{detectionQualityInterpretation(selectedOperatorProfile?.f1)}
|
||||||
</p>
|
</p>
|
||||||
|
{selectedOperatorProfile && !selectedOperatorProfile.nationallyValidated ? (
|
||||||
|
<div className="result-state result-state-warning" role="status">
|
||||||
|
<strong>Nog niet nationaal gevalideerd</strong>
|
||||||
|
<p>
|
||||||
|
Dit model is operationeel voor gecontroleerde beeldanalyse, maar de gemeten kwaliteit geldt alleen voor {selectedOperatorProfile.validationScope}.
|
||||||
|
Resultaten elders in Belgie of op zee vereisen lokale referentiedata en QA voordat ze als betrouwbaar kunnen worden vrijgegeven.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<DetectionModelManagement
|
<DetectionModelManagement
|
||||||
detectionModels={detectionModels}
|
detectionModels={detectionModels}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface DetectionOperatorProfile {
|
|||||||
f1: number
|
f1: number
|
||||||
positiveSampleCount: number
|
positiveSampleCount: number
|
||||||
maxBackgroundDetections: number
|
maxBackgroundDetections: number
|
||||||
|
validationScope: string
|
||||||
|
nationallyValidated: boolean
|
||||||
description: string
|
description: string
|
||||||
limitationMessage: string
|
limitationMessage: string
|
||||||
}
|
}
|
||||||
@@ -27,6 +29,8 @@ export const DETECTION_OPERATOR_PROFILES: DetectionOperatorProfile[] = [
|
|||||||
f1: 0.6068607646002744,
|
f1: 0.6068607646002744,
|
||||||
positiveSampleCount: 7,
|
positiveSampleCount: 7,
|
||||||
maxBackgroundDetections: 0,
|
maxBackgroundDetections: 0,
|
||||||
|
validationScope: '7 onafhankelijke testgebieden in Mol en de Kempen',
|
||||||
|
nationallyValidated: false,
|
||||||
description:
|
description:
|
||||||
'Aanbevolen profiel met een evenwicht tussen gevonden en gemiste kleine gebouwen, opnieuw gemeten over zeven onafhankelijke testgebieden in Mol en de Kempen.',
|
'Aanbevolen profiel met een evenwicht tussen gevonden en gemiste kleine gebouwen, opnieuw gemeten over zeven onafhankelijke testgebieden in Mol en de Kempen.',
|
||||||
limitationMessage:
|
limitationMessage:
|
||||||
@@ -44,6 +48,8 @@ export const DETECTION_OPERATOR_PROFILES: DetectionOperatorProfile[] = [
|
|||||||
f1: 0.5432865390636915,
|
f1: 0.5432865390636915,
|
||||||
positiveSampleCount: 7,
|
positiveSampleCount: 7,
|
||||||
maxBackgroundDetections: 0,
|
maxBackgroundDetections: 0,
|
||||||
|
validationScope: '7 onafhankelijke testgebieden in Mol en de Kempen',
|
||||||
|
nationallyValidated: false,
|
||||||
description: 'Voorgaand profiel voor controles waarbij minder foutieve vondsten belangrijker zijn dan maximale dekking.',
|
description: 'Voorgaand profiel voor controles waarbij minder foutieve vondsten belangrijker zijn dan maximale dekking.',
|
||||||
limitationMessage:
|
limitationMessage:
|
||||||
'De lege-achtergrondtest is geslaagd. Dit profiel vindt minder onterechte objecten, maar mist meer kleine gebouwen dan het aanbevolen profiel.',
|
'De lege-achtergrondtest is geslaagd. Dit profiel vindt minder onterechte objecten, maar mist meer kleine gebouwen dan het aanbevolen profiel.',
|
||||||
@@ -60,6 +66,8 @@ export const DETECTION_OPERATOR_PROFILES: DetectionOperatorProfile[] = [
|
|||||||
f1: 0.32086574003576274,
|
f1: 0.32086574003576274,
|
||||||
positiveSampleCount: 7,
|
positiveSampleCount: 7,
|
||||||
maxBackgroundDetections: 0,
|
maxBackgroundDetections: 0,
|
||||||
|
validationScope: '7 onafhankelijke testgebieden in Mol en de Kempen',
|
||||||
|
nationallyValidated: false,
|
||||||
description: 'Profiel met hoge precisie voor controles waarbij zo weinig mogelijk foutieve vondsten zwaarder wegen dan volledige dekking.',
|
description: 'Profiel met hoge precisie voor controles waarbij zo weinig mogelijk foutieve vondsten zwaarder wegen dan volledige dekking.',
|
||||||
limitationMessage:
|
limitationMessage:
|
||||||
'Goedgekeurd na de lege-achtergrondtest. Resultaten in dun bebouwde context blijven altijd controlebewijs en geen automatische waarheid.',
|
'Goedgekeurd na de lege-achtergrondtest. Resultaten in dun bebouwde context blijven altijd controlebewijs en geen automatische waarheid.',
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ const DATA_THEMES: DataTheme[] = [
|
|||||||
id: 'soil',
|
id: 'soil',
|
||||||
label: 'Bodem',
|
label: 'Bodem',
|
||||||
shortLabel: 'Bodemkaart',
|
shortLabel: 'Bodemkaart',
|
||||||
description: 'Historische DOV-bodemkartering met bodemtype, textuur en drainageklasse voor Mol.',
|
description: 'Officiele bodemkartering met bodemtype, textuur en drainageklasse waar de geselecteerde zone door een gekoppelde bron wordt gedekt.',
|
||||||
tokens: ['soil', 'bodem', 'bodemkaart', 'bodemtype', 'dov_soil_map'],
|
tokens: ['soil', 'bodem', 'bodemkaart', 'bodemtype', 'dov_soil_map'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import type { AreaRead, ProjectRead } from '../../types'
|
|||||||
const AREA_CATALOG_PAGE_SIZE = 12
|
const AREA_CATALOG_PAGE_SIZE = 12
|
||||||
|
|
||||||
function projectDisplayName(project: ProjectRead | null): string {
|
function projectDisplayName(project: ProjectRead | null): string {
|
||||||
|
if (project?.name === 'Belgium and North Sea Workbench') {
|
||||||
|
return 'Belgie en Belgische Noordzee'
|
||||||
|
}
|
||||||
if (project?.name === 'Kempen Regional Workbench') {
|
if (project?.name === 'Kempen Regional Workbench') {
|
||||||
return 'Kempen · volledige regio'
|
return 'Kempen · volledige regio'
|
||||||
}
|
}
|
||||||
@@ -81,7 +84,7 @@ export function AreaPanel({
|
|||||||
<div className="data-selection-summary data-selection-summary-area">
|
<div className="data-selection-summary data-selection-summary-area">
|
||||||
<span>Getoond op de kaart</span>
|
<span>Getoond op de kaart</span>
|
||||||
<strong>{selectedArea?.name ?? 'Geen gebied geselecteerd'}</strong>
|
<strong>{selectedArea?.name ?? 'Geen gebied geselecteerd'}</strong>
|
||||||
<small>{selectedArea?.area_m2 ? `${(selectedArea.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Kies een gemeente of de volledige regio op de kaart.'}</small>
|
<small>{selectedArea?.area_m2 ? `${(selectedArea.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Kies een gemeente, gewest, zeezone of teken een eigen selectie.'}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details className="data-panel-form-block">
|
<details className="data-panel-form-block">
|
||||||
@@ -136,7 +139,7 @@ export function AreaPanel({
|
|||||||
setSearchQuery(event.target.value)
|
setSearchQuery(event.target.value)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}}
|
}}
|
||||||
placeholder="Bijvoorbeeld Mol"
|
placeholder="Bijvoorbeeld Brussel, Namen of Noordzee"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="catalog-pagination" aria-label="Paginering van gebieden">
|
<div className="catalog-pagination" aria-label="Paginering van gebieden">
|
||||||
|
|||||||
@@ -2,19 +2,23 @@ import type { FormEvent } from 'react'
|
|||||||
import type { ProjectCreate, ProjectRead } from '../../types'
|
import type { ProjectCreate, ProjectRead } from '../../types'
|
||||||
|
|
||||||
const TECHNICAL_PROJECT_PATTERN = /^(GeoIntel Detection Quality Matrix|GeoIntel hard-negative|GeoIntel training|Mol Building QA)/i
|
const TECHNICAL_PROJECT_PATTERN = /^(GeoIntel Detection Quality Matrix|GeoIntel hard-negative|GeoIntel training|Mol Building QA)/i
|
||||||
|
const NATIONAL_PROJECT_NAME = 'Belgium and North Sea Workbench'
|
||||||
const REGIONAL_PROJECT_NAME = 'Kempen Regional Workbench'
|
const REGIONAL_PROJECT_NAME = 'Kempen Regional Workbench'
|
||||||
const LEGACY_MOL_PROJECT_NAME = 'Mol Municipality Workbench'
|
const LEGACY_MOL_PROJECT_NAME = 'Mol Municipality Workbench'
|
||||||
const PROTECTED_PROJECT_NAMES = new Set([REGIONAL_PROJECT_NAME, LEGACY_MOL_PROJECT_NAME])
|
const PROTECTED_PROJECT_NAMES = new Set([NATIONAL_PROJECT_NAME, REGIONAL_PROJECT_NAME, LEGACY_MOL_PROJECT_NAME])
|
||||||
|
|
||||||
function isTechnicalProject(project: ProjectRead): boolean {
|
function isTechnicalProject(project: ProjectRead): boolean {
|
||||||
return TECHNICAL_PROJECT_PATTERN.test(project.name)
|
return TECHNICAL_PROJECT_PATTERN.test(project.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAdvancedProject(project: ProjectRead): boolean {
|
function isAdvancedProject(project: ProjectRead): boolean {
|
||||||
return isTechnicalProject(project) || project.name === LEGACY_MOL_PROJECT_NAME
|
return isTechnicalProject(project) || project.name === LEGACY_MOL_PROJECT_NAME || project.name === REGIONAL_PROJECT_NAME
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectDisplayName(project: ProjectRead): string {
|
function projectDisplayName(project: ProjectRead): string {
|
||||||
|
if (project.name === NATIONAL_PROJECT_NAME) {
|
||||||
|
return 'Belgie en Belgische Noordzee'
|
||||||
|
}
|
||||||
if (project.name === REGIONAL_PROJECT_NAME) {
|
if (project.name === REGIONAL_PROJECT_NAME) {
|
||||||
return 'Kempen · volledige regionale werkruimte'
|
return 'Kempen · volledige regionale werkruimte'
|
||||||
}
|
}
|
||||||
@@ -126,7 +130,7 @@ export function ProjectPanel({
|
|||||||
<label>
|
<label>
|
||||||
Regio
|
Regio
|
||||||
<input
|
<input
|
||||||
value={projectForm.region ?? 'Kempen'}
|
value={projectForm.region ?? 'Belgie en Belgische Noordzee'}
|
||||||
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
|
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
|
||||||
placeholder="Regio"
|
placeholder="Regio"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ function persistedSegmentationModelLabel(modelName: string | null | undefined):
|
|||||||
if (modelName === 'fixture-segmenter') return 'Testsegmentatie'
|
if (modelName === 'fixture-segmenter') return 'Testsegmentatie'
|
||||||
if (modelName === 'sam-placeholder') return 'SAM-model niet geconfigureerd'
|
if (modelName === 'sam-placeholder') return 'SAM-model niet geconfigureerd'
|
||||||
if (modelName === 'yolo-seg-placeholder') return 'YOLO-segmentatie niet geconfigureerd'
|
if (modelName === 'yolo-seg-placeholder') return 'YOLO-segmentatie niet geconfigureerd'
|
||||||
|
if (modelName === 'segmentation-placeholder') return 'Segmentatiemodel niet geconfigureerd'
|
||||||
|
if (modelName === 'yolo-seg-configured') return 'Lokaal YOLO-segmentatiemodel'
|
||||||
|
if (modelName === 'sam-configured') return 'Lokaal SAM-model'
|
||||||
return modelName
|
return modelName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export const PRIMARY_FOCUS_LABEL = 'Mol'
|
|||||||
export const PRIMARY_FOCUS_REGION = 'Mol, Kempen'
|
export const PRIMARY_FOCUS_REGION = 'Mol, Kempen'
|
||||||
export const NATIONAL_WORKSPACE_PROJECT_NAME = 'Belgium and North Sea Workbench'
|
export const NATIONAL_WORKSPACE_PROJECT_NAME = 'Belgium and North Sea Workbench'
|
||||||
export const NATIONAL_WORKSPACE_LABEL = 'Belgie en Belgische Noordzee'
|
export const NATIONAL_WORKSPACE_LABEL = 'Belgie en Belgische Noordzee'
|
||||||
|
export const NATIONAL_WORKSPACE_REGION = 'Belgie en Belgische Noordzee'
|
||||||
|
export const NATIONAL_MAP_CENTER: [number, number] = [4.62, 50.72]
|
||||||
|
export const NATIONAL_MAP_ZOOM = 7.25
|
||||||
export const REGIONAL_WORKSPACE_PROJECT_NAME = 'Kempen Regional Workbench'
|
export const REGIONAL_WORKSPACE_PROJECT_NAME = 'Kempen Regional Workbench'
|
||||||
export const REGIONAL_WORKSPACE_LABEL = 'Kempen (28 gemeenten)'
|
export const REGIONAL_WORKSPACE_LABEL = 'Kempen (28 gemeenten)'
|
||||||
export const FLANDERS_WORKSPACE_PROJECT_NAME = 'Flanders Regional Workbench'
|
export const FLANDERS_WORKSPACE_PROJECT_NAME = 'Flanders Regional Workbench'
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ function formatOrthophotoError(caught: unknown): string {
|
|||||||
return 'Maak de rechthoek maximaal 1.024 bij 1.024 meter groot.'
|
return 'Maak de rechthoek maximaal 1.024 bij 1.024 meter groot.'
|
||||||
}
|
}
|
||||||
if (code === 'ORTHOPHOTO_SELECTION_OUTSIDE_AREA') {
|
if (code === 'ORTHOPHOTO_SELECTION_OUTSIDE_AREA') {
|
||||||
return 'Teken de rechthoek binnen de ingeladen regio Kempen.'
|
return 'Teken de rechthoek binnen het ingeladen Belgische land- of zeegebied.'
|
||||||
}
|
}
|
||||||
if (code === 'ORTHOPHOTO_PROVIDER_UNAVAILABLE' || code === 'ORTHOPHOTO_PROVIDER_INVALID_RESPONSE') {
|
if (code === 'ORTHOPHOTO_PROVIDER_UNAVAILABLE' || code === 'ORTHOPHOTO_PROVIDER_INVALID_RESPONSE') {
|
||||||
return 'De officiële luchtbeeldbron is tijdelijk niet bereikbaar. Probeer later opnieuw.'
|
return 'De officiële luchtbeeldbron is tijdelijk niet bereikbaar. Probeer later opnieuw.'
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { FormEvent, useMemo, useRef, useState } from 'react'
|
import { FormEvent, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
PRIMARY_FOCUS_AREA_GEOJSON,
|
|
||||||
PRIMARY_FOCUS_AREA_NAME,
|
|
||||||
PRIMARY_FOCUS_REGION,
|
|
||||||
NATIONAL_WORKSPACE_PROJECT_NAME,
|
NATIONAL_WORKSPACE_PROJECT_NAME,
|
||||||
|
NATIONAL_WORKSPACE_REGION,
|
||||||
REGIONAL_WORKSPACE_PROJECT_NAME,
|
REGIONAL_WORKSPACE_PROJECT_NAME,
|
||||||
isPrimaryFocusMunicipalityBoundaryDataset,
|
|
||||||
isPrimaryFocusMunicipalityProject,
|
|
||||||
isPrimaryFocusProject,
|
|
||||||
isPrimaryFocusProjectData,
|
|
||||||
} from '../config/primaryFocus'
|
} from '../config/primaryFocus'
|
||||||
import { areasApi } from '../services/api/areas'
|
import { areasApi } from '../services/api/areas'
|
||||||
import { datasetsApi } from '../services/api/datasets'
|
import { datasetsApi } from '../services/api/datasets'
|
||||||
@@ -54,11 +48,11 @@ export function useProjectWorkspace() {
|
|||||||
const [projectForm, setProjectForm] = useState<ProjectCreate>({
|
const [projectForm, setProjectForm] = useState<ProjectCreate>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
region: PRIMARY_FOCUS_REGION,
|
region: NATIONAL_WORKSPACE_REGION,
|
||||||
})
|
})
|
||||||
const [areaForm, setAreaForm] = useState({
|
const [areaForm, setAreaForm] = useState({
|
||||||
name: PRIMARY_FOCUS_AREA_NAME,
|
name: '',
|
||||||
geometry: PRIMARY_FOCUS_AREA_GEOJSON,
|
geometry: '',
|
||||||
crs: 'EPSG:4326',
|
crs: 'EPSG:4326',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -84,14 +78,7 @@ export function useProjectWorkspace() {
|
|||||||
}
|
}
|
||||||
const nationalProject = items.find((project) => project.name === NATIONAL_WORKSPACE_PROJECT_NAME)
|
const nationalProject = items.find((project) => project.name === NATIONAL_WORKSPACE_PROJECT_NAME)
|
||||||
if (nationalProject) {
|
if (nationalProject) {
|
||||||
try {
|
return nationalProject.id
|
||||||
const data = await fetchProjectData(nationalProject.id)
|
|
||||||
if (data.areas.length > 0 && data.datasets.some((dataset) => dataset.status === 'ready')) {
|
|
||||||
return nationalProject.id
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Continue with regional and municipality fallbacks while national data is unavailable.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const regionalProject = items.find((project) => project.name === REGIONAL_WORKSPACE_PROJECT_NAME)
|
const regionalProject = items.find((project) => project.name === REGIONAL_WORKSPACE_PROJECT_NAME)
|
||||||
if (regionalProject) {
|
if (regionalProject) {
|
||||||
@@ -104,22 +91,10 @@ export function useProjectWorkspace() {
|
|||||||
// Continue with the municipality and completeness fallbacks when regional data is unavailable.
|
// Continue with the municipality and completeness fallbacks when regional data is unavailable.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const municipalityProject = items.find(isPrimaryFocusMunicipalityProject)
|
|
||||||
if (municipalityProject) {
|
|
||||||
try {
|
|
||||||
const data = await fetchProjectData(municipalityProject.id)
|
|
||||||
if (data.areas.length > 0 && data.datasets.some(isPrimaryFocusMunicipalityBoundaryDataset)) {
|
|
||||||
return municipalityProject.id
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Continue with the normal completeness ranking if the canonical workspace is temporarily unavailable.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const primaryProjects = items.filter(isPrimaryFocusProject)
|
|
||||||
const demoProjects = items.filter(isDemoProject)
|
const demoProjects = items.filter(isDemoProject)
|
||||||
const candidates = Array.from(
|
const candidates = Array.from(
|
||||||
new Map(
|
new Map(
|
||||||
[...primaryProjects, ...demoProjects, ...items].map((project) => [project.id, project]),
|
[...items, ...demoProjects].map((project) => [project.id, project]),
|
||||||
).values(),
|
).values(),
|
||||||
).slice(0, 12)
|
).slice(0, 12)
|
||||||
const inspectedCandidates: Array<{
|
const inspectedCandidates: Array<{
|
||||||
@@ -130,27 +105,17 @@ export function useProjectWorkspace() {
|
|||||||
try {
|
try {
|
||||||
const data = await fetchProjectData(project.id)
|
const data = await fetchProjectData(project.id)
|
||||||
inspectedCandidates.push({ project, data })
|
inspectedCandidates.push({ project, data })
|
||||||
if (
|
if (hasMappedAnalysisContext(data)) {
|
||||||
isPrimaryFocusProjectData(project, data.datasets) &&
|
|
||||||
hasMappedAnalysisContext(data)
|
|
||||||
) {
|
|
||||||
return project.id
|
return project.id
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Project list should still render if a candidate's detail endpoints are temporarily unavailable.
|
// Project list should still render if a candidate's detail endpoints are temporarily unavailable.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const primaryContext = inspectedCandidates.find(
|
|
||||||
({ project, data }) =>
|
|
||||||
data.datasets.length > 0 && isPrimaryFocusProjectData(project, data.datasets),
|
|
||||||
)
|
|
||||||
if (primaryContext) {
|
|
||||||
return primaryContext.project.id
|
|
||||||
}
|
|
||||||
const completeContext = inspectedCandidates.find(
|
const completeContext = inspectedCandidates.find(
|
||||||
({ data }) => data.areas.length > 0 && data.datasets.length > 0,
|
({ data }) => data.areas.length > 0 && data.datasets.length > 0,
|
||||||
)
|
)
|
||||||
return completeContext?.project.id ?? primaryProjects[0]?.id ?? demoProjects[0]?.id ?? items[0]?.id ?? null
|
return completeContext?.project.id ?? demoProjects[0]?.id ?? items[0]?.id ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadProjects = async (preferredProjectId?: string | null) => {
|
const loadProjects = async (preferredProjectId?: string | null) => {
|
||||||
@@ -216,7 +181,7 @@ export function useProjectWorkspace() {
|
|||||||
const createdProject = await projectsApi.create({
|
const createdProject = await projectsApi.create({
|
||||||
name: projectForm.name.trim(),
|
name: projectForm.name.trim(),
|
||||||
description: projectForm.description?.trim() || undefined,
|
description: projectForm.description?.trim() || undefined,
|
||||||
region: projectForm.region?.trim() || PRIMARY_FOCUS_REGION,
|
region: projectForm.region?.trim() || NATIONAL_WORKSPACE_REGION,
|
||||||
})
|
})
|
||||||
setProjectForm((previous) => ({ ...previous, name: '', description: '' }))
|
setProjectForm((previous) => ({ ...previous, name: '', description: '' }))
|
||||||
setSelectedProjectId(createdProject.id)
|
setSelectedProjectId(createdProject.id)
|
||||||
|
|||||||
@@ -59,8 +59,17 @@ export function useSegmentationWorkflow({
|
|||||||
try {
|
try {
|
||||||
const response = await segmentationApi.listModels()
|
const response = await segmentationApi.listModels()
|
||||||
setSegmentationModels(response.models)
|
setSegmentationModels(response.models)
|
||||||
if (!response.models.some((model) => model.model_id === selectedSegmentationModelId) && response.models.length > 0) {
|
const selectionStillAvailable = response.models.some((model) => model.model_id === selectedSegmentationModelId)
|
||||||
setSelectedSegmentationModelId(response.models[0].model_id)
|
const selectionConfigured = response.models.some(
|
||||||
|
(model) => model.model_id === selectedSegmentationModelId && model.configured,
|
||||||
|
)
|
||||||
|
if ((!selectionStillAvailable || !selectionConfigured) && response.models.length > 0) {
|
||||||
|
const configuredModel = response.models.find(
|
||||||
|
(model) => model.configured && model.model_id !== 'fixture-segmenter',
|
||||||
|
)
|
||||||
|
setSelectedSegmentationModelId(
|
||||||
|
configuredModel?.model_id ?? (selectionStillAvailable ? selectedSegmentationModelId : response.models[0].model_id),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSegmentationModelError(formatError(error, 'Failed to load segmentation models'))
|
setSegmentationModelError(formatError(error, 'Failed to load segmentation models'))
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ export const OFFICIAL_SOURCE_PORTFOLIO: OfficialSourceDefinition[] = [
|
|||||||
name: 'Gemeente in cijfers',
|
name: 'Gemeente in cijfers',
|
||||||
owner: 'Vlaamse Milieumaatschappij',
|
owner: 'Vlaamse Milieumaatschappij',
|
||||||
coverage: 'Gemeentelijke klimaat- en leefomgevingsindicatoren',
|
coverage: 'Gemeentelijke klimaat- en leefomgevingsindicatoren',
|
||||||
value: 'Contextcijfers voor Mol en vergelijking met andere gemeenten.',
|
value: 'Contextcijfers en vergelijking tussen gemeenten binnen de beschikbare brondekking.',
|
||||||
metricExamples: 'hittegolfdagen, temperatuur en luchtkwaliteitsindex',
|
metricExamples: 'hittegolfdagen, temperatuur en luchtkwaliteitsindex',
|
||||||
priority: 'later',
|
priority: 'later',
|
||||||
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/gemeente-in-cijfers',
|
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/gemeente-in-cijfers',
|
||||||
|
|||||||
Reference in New Issue
Block a user