diff --git a/.env.example b/.env.example index 0d1b2f9b..72058506 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,8 @@ MAX_UPLOAD_MB=500 CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202 ORTHOPHOTO_ENABLED=true ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms +SPW_ORTHOPHOTO_WMS_URL=https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer +BRUSSELS_ORTHOPHOTO_WMS_URL=https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows ORTHOPHOTO_WMS_LAYER=Ortho ORTHOPHOTO_RESOLUTION_M=1.0 ORTHOPHOTO_MIN_SIDE_M=128 diff --git a/backend/README.md b/backend/README.md index 1d3e6660..cf2ca2ef 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1336,7 +1336,10 @@ not copied into queryable properties. `GET /api/v1/projects/{project_id}/datasets/orthophoto/products` lists the governed product allowlist. `POST .../datasets/orthophoto/acquire` accepts an explicit EPSG:4326 map rectangle plus `product_key` and stores the official -Digitaal Vlaanderen WMS response as a canonical EPSG:31370 raster Dataset. The +regional WMS response as a canonical EPSG:31370 raster Dataset. Digitaal +Vlaanderen, SPW (`wallonia_latest`) and Paradigm UrbIS (`brussels_latest`) are +allowlisted. The two regional products are bound to persisted Wallonia and +Brussels-Capital Region Areas. The default safety envelope is 128-1,024 m per side, 1 m/pixel, 32 MiB and a 24-hour exact-request cache. It runs synchronously behind the existing Job abstraction and never during startup. @@ -1348,6 +1351,7 @@ from configured-YOLO/current-GRB QA. `GET .../datasets/{dataset_id}/raster/image is the constrained binary PNG endpoint used by the MapLibre image overlay. Settings: `ORTHOPHOTO_ENABLED`, `ORTHOPHOTO_WMS_URL`, +`SPW_ORTHOPHOTO_WMS_URL`, `BRUSSELS_ORTHOPHOTO_WMS_URL`, `ORTHOPHOTO_WMS_LAYER`, `ORTHOPHOTO_RESOLUTION_M`, `ORTHOPHOTO_MIN_SIDE_M`, `ORTHOPHOTO_MAX_SIDE_M`, `ORTHOPHOTO_TIMEOUT_SECONDS`, `ORTHOPHOTO_MAX_RESPONSE_MB` and diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 3ce6494e..677ee247 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -40,6 +40,14 @@ class Settings(BaseSettings): validation_alias="ORTHOPHOTO_WMS_URL", ) orthophoto_wms_layer: str = Field(default="Ortho", validation_alias="ORTHOPHOTO_WMS_LAYER") + spw_orthophoto_wms_url: str = Field( + default="https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer", + validation_alias="SPW_ORTHOPHOTO_WMS_URL", + ) + brussels_orthophoto_wms_url: str = Field( + default="https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows", + validation_alias="BRUSSELS_ORTHOPHOTO_WMS_URL", + ) orthophoto_resolution_m: float = Field(default=1.0, gt=0, validation_alias="ORTHOPHOTO_RESOLUTION_M") orthophoto_min_side_m: float = Field(default=128.0, gt=0, validation_alias="ORTHOPHOTO_MIN_SIDE_M") orthophoto_max_side_m: float = Field(default=1024.0, gt=0, validation_alias="ORTHOPHOTO_MAX_SIDE_M") diff --git a/backend/app/schemas/orthophoto.py b/backend/app/schemas/orthophoto.py index 8043e18b..c1479425 100644 --- a/backend/app/schemas/orthophoto.py +++ b/backend/app/schemas/orthophoto.py @@ -24,6 +24,10 @@ class OrthophotoProductRead(BaseModel): color_mode: str catalog_url: str limitation_message: str + provider: str + coverage_zone: str + attribution: str + license_note: str class OrthophotoAcquisitionResult(BaseModel): diff --git a/backend/app/services/coverage_registry_service.py b/backend/app/services/coverage_registry_service.py index 629cdb36..404c0f4e 100644 --- a/backend/app/services/coverage_registry_service.py +++ b/backend/app/services/coverage_registry_service.py @@ -379,6 +379,7 @@ REGIONAL_THEME_DATASETS: dict[str, dict[str, dict[str, tuple[str, ...]]]] = { "elevation": {"spw_terrain": ()}, "flood_climate": {"spw_flood_hazard": ("flood_hazard",)}, "bathymetry": {"spw_bathymetry": ()}, + "orthophoto": {"spw_orthophoto": ()}, }, "urbis": { "buildings": {"urbis": ("buildings",)}, @@ -386,6 +387,7 @@ REGIONAL_THEME_DATASETS: dict[str, dict[str, dict[str, tuple[str, ...]]]] = { "roads": {"urbis": ("roads",)}, "surface_water": {"urbis": ("water",)}, "land_cover_use": {"urbis": ("space_occupation", "forest")}, + "orthophoto": {"urbis_orthophoto": ()}, }, } diff --git a/backend/app/services/orthophoto_acquisition_service.py b/backend/app/services/orthophoto_acquisition_service.py index d6108097..f523060e 100644 --- a/backend/app/services/orthophoto_acquisition_service.py +++ b/backend/app/services/orthophoto_acquisition_service.py @@ -37,6 +37,12 @@ class OrthophotoProduct: layer: str catalog_url: str limitation_message: str + provider: str = "digitaal_vlaanderen_orthophoto" + source_label: str = "Digitaal Vlaanderen WMS" + attribution: str = "Bron: Orthofotomozaiek Vlaanderen, Digitaal Vlaanderen" + license_note: str = "Gebruik volgens het gebruiksrecht geografische webdiensten van Digitaal Vlaanderen." + series_namespace: str = "digitaal-vlaanderen" + coverage_zone: str = "flanders" supports_detection: bool = False color_mode: str = "rgb" observed_at: datetime | None = None @@ -70,6 +76,46 @@ class OrthophotoAcquisitionService: supports_detection=True, ) ] + products.extend( + [ + OrthophotoProduct( + key="wallonia_latest", + display_name="Meest recente orthofoto Wallonië", + observation_label="Laatste volledige SPW-campagne", + temporal_granularity="snapshot", + native_resolution_m=0.25, + wms_url=settings.spw_orthophoto_wms_url, + layer="0", + catalog_url="https://geoportail.wallonie.be/catalogue/e2a615fe-7a2c-4eb3-9dc3-63f466538dda.html", + limitation_message="Laatste volledige SPW-orthofotocampagne; de actuele service kan van editie wisselen en de exacte opnamedatum kan per tegel verschillen.", + provider="spw_orthophoto", + source_label="SPW ORTHO_LAST WMS", + attribution="Bron: Service public de Wallonie (SPW), Orthophotos - dernière campagne disponible", + license_note="CC BY 4.0; citeer SPW en vermeld wijzigingen.", + series_namespace="spw", + coverage_zone="wallonia", + supports_detection=True, + ), + OrthophotoProduct( + key="brussels_latest", + display_name="Meest recente orthofoto Brussel", + observation_label="Meest recent beschikbaar via UrbIS", + temporal_granularity="snapshot", + native_resolution_m=0.15, + wms_url=settings.brussels_orthophoto_wms_url, + layer="Ortho", + catalog_url="https://data.mobility.brussels/info/Ortho", + limitation_message="Samengestelde meest recente UrbIS-orthofoto; de actuele service kan van editie wisselen en de exacte opnamedatum kan per tegel verschillen.", + provider="urbis_orthophoto", + source_label="Paradigm UrbIS WMS", + attribution="Bron: Paradigm, UrbIS Orthophoto", + license_note="CC0 volgens de officiële Brusselse datasetfiche; bronvermelding blijft in GeoIntel behouden.", + series_namespace="urbis", + coverage_zone="brussels", + supports_detection=True, + ), + ] + ) for year in range(2025, 2011, -1): products.append( OrthophotoProduct( @@ -163,6 +209,10 @@ class OrthophotoAcquisitionService: color_mode=product.color_mode, catalog_url=product.catalog_url, limitation_message=product.limitation_message, + provider=product.provider, + coverage_zone=product.coverage_zone, + attribution=product.attribution, + license_note=product.license_note, ).model_dump() for product in OrthophotoAcquisitionService._products(resolved_settings).values() ] @@ -217,7 +267,7 @@ class OrthophotoAcquisitionService: bbox_4326 = [min_x, min_y, max_x, max_y] bbox_31370 = [float(value) for value in lambert_bounds] request_identity = { - "provider": OrthophotoAcquisitionService.PROVIDER, + "provider": product.provider, "product_key": product.key, "wms_url": product.wms_url, "layer": product.layer, @@ -259,9 +309,45 @@ class OrthophotoAcquisitionService: } @staticmethod - def _validate_area_scope(db, project_id: UUID, area_id: UUID | None, bbox_epsg4326: list[float]) -> None: + def _validate_area_scope( + db, + project_id: UUID, + area_id: UUID | None, + bbox_epsg4326: list[float], + product: OrthophotoProduct, + ) -> None: if not db.get(Project, project_id): raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) + selection = box(*bbox_epsg4326) + transformer = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True) + selection_metric = shapely_transform(transformer.transform, selection) + + def require_contains(candidate: Area, *, code: str, message: str) -> None: + candidate_metric = shapely_transform(transformer.transform, to_shape(candidate.geometry)) + overlap_ratio = candidate_metric.intersection(selection_metric).area / selection_metric.area + if overlap_ratio < 0.99: + raise AppError( + code=code, + message=message, + details={"coverage_ratio": overlap_ratio, "coverage_zone": product.coverage_zone}, + status_code=422, + ) + + if product.coverage_zone in {"wallonia", "brussels"}: + scope_name = "Wallonia" if product.coverage_zone == "wallonia" else "Brussels-Capital Region" + scope = db.query(Area).filter(Area.project_id == project_id, Area.name == scope_name).first() + if scope is None: + raise AppError( + code="ORTHOPHOTO_COVERAGE_ZONE_NOT_MATERIALIZED", + message="Persist the governed regional coverage geometry before acquiring this orthophoto product", + details={"coverage_zone": product.coverage_zone}, + status_code=409, + ) + require_contains( + scope, + code="ORTHOPHOTO_SELECTION_OUTSIDE_COVERAGE_ZONE", + message="Keep the orthophoto rectangle inside the official provider coverage zone", + ) if area_id is None: return area = db.get(Area, area_id) @@ -269,19 +355,11 @@ class OrthophotoAcquisitionService: raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) if area.project_id != project_id: raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400) - selection = box(*bbox_epsg4326) - area_geometry = to_shape(area.geometry) - transformer = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True) - selection_metric = shapely_transform(transformer.transform, selection) - area_metric = shapely_transform(transformer.transform, area_geometry) - overlap_ratio = area_metric.intersection(selection_metric).area / selection_metric.area - if overlap_ratio < 0.99: - raise AppError( - code="ORTHOPHOTO_SELECTION_OUTSIDE_AREA", - message="Keep the orthophoto rectangle inside the selected work area", - details={"coverage_ratio": overlap_ratio}, - status_code=422, - ) + require_contains( + area, + code="ORTHOPHOTO_SELECTION_OUTSIDE_AREA", + message="Keep the orthophoto rectangle inside the selected work area", + ) @staticmethod def _cached_dataset( @@ -291,14 +369,15 @@ class OrthophotoAcquisitionService: settings: Settings, product: OrthophotoProduct, ) -> Dataset | None: - if product.key == "most_recent" and settings.orthophoto_cache_ttl_hours <= 0: + is_live_product = product.key == "most_recent" or product.key.endswith("_latest") + if is_live_product and settings.orthophoto_cache_ttl_hours <= 0: return None candidate = ( db.query(Dataset) .filter( Dataset.project_id == project_id, Dataset.name == filename, - Dataset.source_name == OrthophotoAcquisitionService.PROVIDER, + Dataset.source_name == product.provider, Dataset.status == "ready", ) .order_by(Dataset.imported_at.desc()) @@ -311,7 +390,7 @@ class OrthophotoAcquisitionService: return None if imported_at.tzinfo is None: imported_at = imported_at.replace(tzinfo=UTC) - if product.key == "most_recent" and datetime.now(UTC) - imported_at > timedelta(hours=settings.orthophoto_cache_ttl_hours): + if is_live_product and datetime.now(UTC) - imported_at > timedelta(hours=settings.orthophoto_cache_ttl_hours): return None return candidate @@ -384,9 +463,9 @@ class OrthophotoAcquisitionService: with output_memory.open(**profile) as output: output.write(image) output.update_tags( - source=f"Digitaal Vlaanderen WMS {product.layer}", + source=f"{product.source_label} {product.layer}", source_url=prepared["request_url"], - attribution=OrthophotoAcquisitionService.ATTRIBUTION, + attribution=product.attribution, acquisition="explicit_bounded_map_selection", ) return output_memory.read() @@ -414,7 +493,13 @@ class OrthophotoAcquisitionService: raise AppError(code="ORTHOPHOTO_NOT_CONFIGURED", message="Official orthophoto acquisition is disabled", status_code=503) prepared = OrthophotoAcquisitionService._prepared_request(payload, resolved_settings) product: OrthophotoProduct = prepared["product"] - OrthophotoAcquisitionService._validate_area_scope(db, project_id, payload.area_id, prepared["bbox_epsg4326"]) + OrthophotoAcquisitionService._validate_area_scope( + db, + project_id, + payload.area_id, + prepared["bbox_epsg4326"], + product, + ) filename = f"orthofoto_{product.key}_{prepared['request_hash'][:12]}.tif" cached = None if payload.force_refresh else OrthophotoAcquisitionService._cached_dataset( @@ -428,7 +513,7 @@ class OrthophotoAcquisitionService: return OrthophotoAcquisitionResult( output_dataset_id=cached.id, reused=True, - provider=OrthophotoAcquisitionService.PROVIDER, + provider=product.provider, product_key=product.key, display_name=product.display_name, observation_label=product.observation_label, @@ -440,7 +525,7 @@ class OrthophotoAcquisitionService: resolution_m=resolved_settings.orthophoto_resolution_m, bbox_epsg4326=prepared["bbox_epsg4326"], bbox_epsg31370=prepared["bbox_epsg31370"], - attribution=OrthophotoAcquisitionService.ATTRIBUTION, + attribution=product.attribution, limitation_message=product.limitation_message, ).model_dump(mode="json") @@ -454,21 +539,21 @@ class OrthophotoAcquisitionService: area_id=payload.area_id, filename=filename, content=geotiff_content, - source=f"Digitaal Vlaanderen WMS {product.layer}", - source_name=OrthophotoAcquisitionService.PROVIDER, - temporal_series_key=f"digitaal-vlaanderen:orthophoto:{prepared['spatial_hash'][:24]}", + source=f"{product.source_label} {product.layer}", + source_name=product.provider, + temporal_series_key=f"{product.series_namespace}:orthophoto:{prepared['spatial_hash'][:24]}", observed_at=observed_at, valid_from=product.valid_from or observed_at, valid_to=product.valid_to, temporal_granularity=product.temporal_granularity, source_version=( - f"most_recent_at_{acquired_at.date().isoformat()}" - if product.key == "most_recent" + f"{product.key}_at_{acquired_at.date().isoformat()}" + if product.key == "most_recent" or product.key.endswith("_latest") else product.key ), content_type="image/tiff", source_metadata={ - "provider": OrthophotoAcquisitionService.PROVIDER, + "provider": product.provider, "service": "WMS", "service_version": "1.3.0", "product_key": product.key, @@ -481,8 +566,9 @@ class OrthophotoAcquisitionService: "supports_detection": product.supports_detection, "layer": product.layer, "catalog_url": product.catalog_url, - "attribution": OrthophotoAcquisitionService.ATTRIBUTION, - "license_note": "Gebruik volgens het gebruiksrecht geografische webdiensten van Digitaal Vlaanderen.", + "attribution": product.attribution, + "license_note": product.license_note, + "coverage_zone": product.coverage_zone, }, provenance_metadata={ "acquisition": "explicit_bounded_map_selection", @@ -502,7 +588,7 @@ class OrthophotoAcquisitionService: return OrthophotoAcquisitionResult( output_dataset_id=dataset.id, reused=False, - provider=OrthophotoAcquisitionService.PROVIDER, + provider=product.provider, product_key=product.key, display_name=product.display_name, observation_label=product.observation_label, @@ -514,7 +600,7 @@ class OrthophotoAcquisitionService: resolution_m=resolved_settings.orthophoto_resolution_m, bbox_epsg4326=prepared["bbox_epsg4326"], bbox_epsg31370=prepared["bbox_epsg31370"], - attribution=OrthophotoAcquisitionService.ATTRIBUTION, + attribution=product.attribution, limitation_message=product.limitation_message, ).model_dump(mode="json") @@ -524,7 +610,7 @@ class OrthophotoAcquisitionService: if ( dataset is None or dataset.project_id != project_id - or dataset.source_name != OrthophotoAcquisitionService.PROVIDER + or dataset.source_name not in {"digitaal_vlaanderen_orthophoto", "spw_orthophoto", "urbis_orthophoto"} or dataset.status != "ready" or not dataset.storage_path or not Path(dataset.storage_path).is_file() diff --git a/backend/app/services/source_freshness_service.py b/backend/app/services/source_freshness_service.py index 554dded6..0defd6c7 100644 --- a/backend/app/services/source_freshness_service.py +++ b/backend/app/services/source_freshness_service.py @@ -37,6 +37,8 @@ SOURCE_POLICIES: dict[str, SourcePolicy] = { "Gebouwen- en adressenregister", "rolling_snapshot", 90 ), "digitaal_vlaanderen_orthophoto": SourcePolicy("Orthofoto Vlaanderen", "rolling_snapshot", 180), + "spw_orthophoto": SourcePolicy("Orthofoto Wallonië", "rolling_snapshot", 365), + "urbis_orthophoto": SourcePolicy("Orthofoto Brussel", "rolling_snapshot", 365), "agentschap_landbouw_zeevisserij_agricultural_parcels": SourcePolicy( "Landbouwgebruikspercelen", "annual_release" ), diff --git a/backend/tests/test_sprint196_map_orthophoto_analysis.py b/backend/tests/test_sprint196_map_orthophoto_analysis.py index 1f23285c..3576608d 100644 --- a/backend/tests/test_sprint196_map_orthophoto_analysis.py +++ b/backend/tests/test_sprint196_map_orthophoto_analysis.py @@ -151,7 +151,66 @@ def test_orthophoto_product_registry_exposes_only_governed_official_layers() -> assert keys[0] == "most_recent" assert {"2025", "2012", "2008_2011", "2000_2003", "1979_1990", "1971"}.issubset(keys) assert next(item for item in products if item["key"] == "most_recent")["supports_detection"] is True - assert all(item["supports_detection"] is False for item in products if item["key"] != "most_recent") + detection_keys = {item["key"] for item in products if item["supports_detection"]} + assert detection_keys == {"most_recent", "wallonia_latest", "brussels_latest"} + by_key = {item["key"]: item for item in products} + assert by_key["wallonia_latest"]["provider"] == "spw_orthophoto" + assert by_key["wallonia_latest"]["coverage_zone"] == "wallonia" + assert by_key["brussels_latest"]["provider"] == "urbis_orthophoto" + assert by_key["brussels_latest"]["coverage_zone"] == "brussels" + + +@pytest.mark.parametrize( + ("product_key", "provider", "layer", "coverage_zone"), + [ + ("wallonia_latest", "spw_orthophoto", "0", "wallonia"), + ("brussels_latest", "urbis_orthophoto", "Ortho", "brussels"), + ], +) +def test_regional_orthophoto_products_bind_provider_and_governed_scope( + tmp_path, product_key: str, provider: str, layer: str, coverage_zone: str +) -> None: + project_id = uuid4() + payload = _selection_payload(product_key=product_key) + scope = Area( + id=uuid4(), + project_id=project_id, + name="Wallonia" if coverage_zone == "wallonia" else "Brussels-Capital Region", + geometry=from_shape( + MultiPolygon( + [ + box( + payload.bbox.min_x - 0.01, + payload.bbox.min_y - 0.01, + payload.bbox.max_x + 0.01, + payload.bbox.max_y + 0.01, + ) + ] + ), + srid=4326, + ), + ) + db = FakeSession({(Project, project_id): Project(id=project_id, name="Belgium")}, query_result=scope) + settings = Settings(_env_file=None, storage_root=str(tmp_path), orthophoto_resolution_m=1.0) + prepared = OrthophotoAcquisitionService._prepared_request(payload, settings) + + result = OrthophotoAcquisitionService.acquire( + db, + project_id, + payload, + settings=settings, + opener=lambda *_args, **_kwargs: FakeImageResponse( + _source_tiff(prepared["width"], prepared["height"]) + ), + ) + + dataset = next(row for row in db.added if isinstance(row, Dataset)) + assert result["provider"] == provider + assert result["layer"] == layer + assert dataset.source_name == provider + assert dataset.source_metadata["coverage_zone"] == coverage_zone + assert dataset.source_metadata["license_note"] + assert dataset.provenance_metadata["request_url"].startswith(prepared["product"].wms_url) prepared = OrthophotoAcquisitionService._prepared_request(_selection_payload(product_key="1971"), settings) assert prepared["params"]["LAYERS"] == "OKZPAN71VL" diff --git a/deploy/unraid/geointel-unraid-template.xml b/deploy/unraid/geointel-unraid-template.xml index ec74a820..be84bccd 100644 --- a/deploy/unraid/geointel-unraid-template.xml +++ b/deploy/unraid/geointel-unraid-template.xml @@ -39,6 +39,8 @@ 43200 true https://geo.api.vlaanderen.be/OMWRGBMRVL/wms + https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer + https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows Ortho 1.0 128 diff --git a/deploy/unraid/geointel.env.example b/deploy/unraid/geointel.env.example index c3cfaeef..ded6c51b 100644 --- a/deploy/unraid/geointel.env.example +++ b/deploy/unraid/geointel.env.example @@ -43,6 +43,8 @@ GEOINTEL_AUTH_SESSION_TTL_SECONDS=43200 # Explicit, bounded acquisition from the official Digitaal Vlaanderen WMS. ORTHOPHOTO_ENABLED=true ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms +SPW_ORTHOPHOTO_WMS_URL=https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer +BRUSSELS_ORTHOPHOTO_WMS_URL=https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows ORTHOPHOTO_WMS_LAYER=Ortho ORTHOPHOTO_RESOLUTION_M=1.0 ORTHOPHOTO_MIN_SIDE_M=128 diff --git a/deploy/unraid/run-dockerman-container.sh b/deploy/unraid/run-dockerman-container.sh index 8bf45e3c..93ce323f 100644 --- a/deploy/unraid/run-dockerman-container.sh +++ b/deploy/unraid/run-dockerman-container.sh @@ -42,6 +42,8 @@ GEOINTEL_AUTH_SESSION_SECRET="${GEOINTEL_AUTH_SESSION_SECRET:-}" GEOINTEL_AUTH_SESSION_TTL_SECONDS="${GEOINTEL_AUTH_SESSION_TTL_SECONDS:-43200}" ORTHOPHOTO_ENABLED="${ORTHOPHOTO_ENABLED:-true}" ORTHOPHOTO_WMS_URL="${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms}" +SPW_ORTHOPHOTO_WMS_URL="${SPW_ORTHOPHOTO_WMS_URL:-https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer}" +BRUSSELS_ORTHOPHOTO_WMS_URL="${BRUSSELS_ORTHOPHOTO_WMS_URL:-https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows}" ORTHOPHOTO_WMS_LAYER="${ORTHOPHOTO_WMS_LAYER:-Ortho}" ORTHOPHOTO_RESOLUTION_M="${ORTHOPHOTO_RESOLUTION_M:-1.0}" ORTHOPHOTO_MIN_SIDE_M="${ORTHOPHOTO_MIN_SIDE_M:-128}" @@ -285,6 +287,8 @@ docker run -d \ -e GEOINTEL_AUTH_SESSION_TTL_SECONDS="$GEOINTEL_AUTH_SESSION_TTL_SECONDS" \ -e ORTHOPHOTO_ENABLED="$ORTHOPHOTO_ENABLED" \ -e ORTHOPHOTO_WMS_URL="$ORTHOPHOTO_WMS_URL" \ + -e SPW_ORTHOPHOTO_WMS_URL="$SPW_ORTHOPHOTO_WMS_URL" \ + -e BRUSSELS_ORTHOPHOTO_WMS_URL="$BRUSSELS_ORTHOPHOTO_WMS_URL" \ -e ORTHOPHOTO_WMS_LAYER="$ORTHOPHOTO_WMS_LAYER" \ -e ORTHOPHOTO_RESOLUTION_M="$ORTHOPHOTO_RESOLUTION_M" \ -e ORTHOPHOTO_MIN_SIDE_M="$ORTHOPHOTO_MIN_SIDE_M" \ diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index 32182619..840bb543 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -23,6 +23,8 @@ services: GEOINTEL_MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500} ORTHOPHOTO_ENABLED: ${ORTHOPHOTO_ENABLED:-true} ORTHOPHOTO_WMS_URL: ${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms} + SPW_ORTHOPHOTO_WMS_URL: ${SPW_ORTHOPHOTO_WMS_URL:-https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer} + BRUSSELS_ORTHOPHOTO_WMS_URL: ${BRUSSELS_ORTHOPHOTO_WMS_URL:-https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows} ORTHOPHOTO_WMS_LAYER: ${ORTHOPHOTO_WMS_LAYER:-Ortho} ORTHOPHOTO_RESOLUTION_M: ${ORTHOPHOTO_RESOLUTION_M:-1.0} ORTHOPHOTO_MIN_SIDE_M: ${ORTHOPHOTO_MIN_SIDE_M:-128} diff --git a/docker-compose.yml b/docker-compose.yml index 69937964..039d90e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,8 @@ services: MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500} ORTHOPHOTO_ENABLED: ${ORTHOPHOTO_ENABLED:-true} ORTHOPHOTO_WMS_URL: ${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms} + SPW_ORTHOPHOTO_WMS_URL: ${SPW_ORTHOPHOTO_WMS_URL:-https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer} + BRUSSELS_ORTHOPHOTO_WMS_URL: ${BRUSSELS_ORTHOPHOTO_WMS_URL:-https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows} ORTHOPHOTO_WMS_LAYER: ${ORTHOPHOTO_WMS_LAYER:-Ortho} ORTHOPHOTO_RESOLUTION_M: ${ORTHOPHOTO_RESOLUTION_M:-1.0} ORTHOPHOTO_MIN_SIDE_M: ${ORTHOPHOTO_MIN_SIDE_M:-128} diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 6df5c468..a958e17a 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -365,16 +365,20 @@ Vector uploads remain stored as original files and are also persisted into `vect ### GET `/api/v1/projects/{project_id}/datasets/orthophoto/products` -Return the governed Digitaal Vlaanderen orthophoto product allowlist in the -canonical envelope. Every product reports its key, display/observation label, -temporal granularity, native resolution, colour mode, catalogue URL, -limitations and whether current configured-YOLO detection is allowed. +Return the governed Belgian orthophoto product allowlist in the canonical +envelope. It contains Digitaal Vlaanderen products plus `wallonia_latest` +(SPW) and `brussels_latest` (Paradigm UrbIS). Every product reports its key, +provider, coverage zone, display/observation label, temporal granularity, +native resolution, colour mode, catalogue URL, attribution, licence note, +limitations and whether configured-YOLO detection is allowed. ### POST `/api/v1/projects/{project_id}/datasets/orthophoto/acquire` Explicitly acquire a bounded orthophoto selection from a governed official -Digitaal Vlaanderen WMS product. Arbitrary WMS URLs and layer names are not -accepted. +regional WMS product. Arbitrary WMS URLs and layer names are not accepted. +The regional products require the persisted project Areas `Wallonia` or +`Brussels-Capital Region` and reject a rectangle unless that Area covers at +least 99% of it. ```json { @@ -493,9 +497,9 @@ Safety contract: reuse; - WMS bytes are georeferenced to EPSG:31370 and persisted only through `DatasetService`; no fetch runs on startup; -- only `most_recent` can enter the current configured-YOLO plus GRB-QA path; - historical products are visual evidence and are never validated against the - current GRB state; +- only `most_recent`, `wallonia_latest` and `brussels_latest` can enter the + configured-YOLO path; QA must use the matching regional reference source + (GRB, PICC or UrbIS). Historical products remain visual evidence; - product periods such as `1979_1990` remain explicitly multi-year and are not presented as exact annual observations. diff --git a/docs/PYTORCH_TRAINING_ROADMAP_BELGIUM.md b/docs/PYTORCH_TRAINING_ROADMAP_BELGIUM.md index 7ccd63a9..d8b15a86 100644 --- a/docs/PYTORCH_TRAINING_ROADMAP_BELGIUM.md +++ b/docs/PYTORCH_TRAINING_ROADMAP_BELGIUM.md @@ -110,10 +110,10 @@ Exit: schema tests pass and no runtime can advertise an unproven class/scope. ### WP1 — National imagery acquisition - [x] verify official SPW and Brussels imagery services and usage conditions; -- [ ] implement bounded SPW orthophoto acquisition with the existing AOI worker; -- [ ] implement bounded UrbIS orthophoto acquisition with the existing AOI worker; -- [ ] expose provider edition, resolution, time, CRS, attribution and checksum; -- [ ] add retry/cache/size limits and live provider preflights; +- [x] implement bounded SPW orthophoto acquisition with the existing AOI worker; +- [x] implement bounded UrbIS orthophoto acquisition with the existing AOI worker; +- [x] expose provider edition, resolution, time, CRS, attribution and checksum; +- [x] reuse retry/cache/size limits and verify both live WMS providers; - [ ] retain explicit `partial`/`not_configured` status when a service is down. Exit: one checksum-bound RGB GeoTIFF per target stratum can be acquired without diff --git a/frontend/src/lib/datasetDisplay.ts b/frontend/src/lib/datasetDisplay.ts index e14dc2f3..3fca03a7 100644 --- a/frontend/src/lib/datasetDisplay.ts +++ b/frontend/src/lib/datasetDisplay.ts @@ -27,6 +27,8 @@ const DATASET_SOURCE_LABELS: Record = { department_omgeving_land_use: 'Departement Omgeving', agentschap_landbouw_zeevisserij_agricultural_parcels: 'Agentschap Landbouw en Zeevisserij', digitaal_vlaanderen_orthophoto: 'Digitaal Vlaanderen', + spw_orthophoto: 'SPW Orthofoto', + urbis_orthophoto: 'Paradigm UrbIS', digitaal_vlaanderen_buildings_addresses_register: 'Digitaal Vlaanderen', digitaal_vlaanderen_dhmv: 'Digitaal Vlaanderen', spw_terrain: 'Service public de Wallonie', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5a1b1d43..a95a3d4b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -362,6 +362,7 @@ export interface OrthophotoAcquireRequest { export interface OrthophotoProductRead { key: string + provider: string display_name: string observation_label: string temporal_granularity: string @@ -369,6 +370,9 @@ export interface OrthophotoProductRead { supports_detection: boolean color_mode: string catalog_url: string + coverage_zone: string + attribution: string + license_note: string limitation_message: string }