Add governed Wallonia and Brussels orthophotos
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-26 22:02:10 +02:00
parent 0c328fd3d1
commit ec6f2d9061
17 changed files with 238 additions and 49 deletions
+2
View File
@@ -7,6 +7,8 @@ MAX_UPLOAD_MB=500
CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202 CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202
ORTHOPHOTO_ENABLED=true ORTHOPHOTO_ENABLED=true
ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms 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_WMS_LAYER=Ortho
ORTHOPHOTO_RESOLUTION_M=1.0 ORTHOPHOTO_RESOLUTION_M=1.0
ORTHOPHOTO_MIN_SIDE_M=128 ORTHOPHOTO_MIN_SIDE_M=128
+5 -1
View File
@@ -1336,7 +1336,10 @@ not copied into queryable properties.
`GET /api/v1/projects/{project_id}/datasets/orthophoto/products` lists the `GET /api/v1/projects/{project_id}/datasets/orthophoto/products` lists the
governed product allowlist. `POST .../datasets/orthophoto/acquire` accepts an governed product allowlist. `POST .../datasets/orthophoto/acquire` accepts an
explicit EPSG:4326 map rectangle plus `product_key` and stores the official 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 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 24-hour exact-request cache. It runs synchronously behind the existing Job
abstraction and never during startup. 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. is the constrained binary PNG endpoint used by the MapLibre image overlay.
Settings: `ORTHOPHOTO_ENABLED`, `ORTHOPHOTO_WMS_URL`, Settings: `ORTHOPHOTO_ENABLED`, `ORTHOPHOTO_WMS_URL`,
`SPW_ORTHOPHOTO_WMS_URL`, `BRUSSELS_ORTHOPHOTO_WMS_URL`,
`ORTHOPHOTO_WMS_LAYER`, `ORTHOPHOTO_RESOLUTION_M`, `ORTHOPHOTO_WMS_LAYER`, `ORTHOPHOTO_RESOLUTION_M`,
`ORTHOPHOTO_MIN_SIDE_M`, `ORTHOPHOTO_MAX_SIDE_M`, `ORTHOPHOTO_MIN_SIDE_M`, `ORTHOPHOTO_MAX_SIDE_M`,
`ORTHOPHOTO_TIMEOUT_SECONDS`, `ORTHOPHOTO_MAX_RESPONSE_MB` and `ORTHOPHOTO_TIMEOUT_SECONDS`, `ORTHOPHOTO_MAX_RESPONSE_MB` and
+8
View File
@@ -40,6 +40,14 @@ class Settings(BaseSettings):
validation_alias="ORTHOPHOTO_WMS_URL", validation_alias="ORTHOPHOTO_WMS_URL",
) )
orthophoto_wms_layer: str = Field(default="Ortho", validation_alias="ORTHOPHOTO_WMS_LAYER") 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_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_min_side_m: float = Field(default=128.0, gt=0, validation_alias="ORTHOPHOTO_MIN_SIDE_M")
orthophoto_max_side_m: float = Field(default=1024.0, gt=0, validation_alias="ORTHOPHOTO_MAX_SIDE_M") orthophoto_max_side_m: float = Field(default=1024.0, gt=0, validation_alias="ORTHOPHOTO_MAX_SIDE_M")
+4
View File
@@ -24,6 +24,10 @@ class OrthophotoProductRead(BaseModel):
color_mode: str color_mode: str
catalog_url: str catalog_url: str
limitation_message: str limitation_message: str
provider: str
coverage_zone: str
attribution: str
license_note: str
class OrthophotoAcquisitionResult(BaseModel): class OrthophotoAcquisitionResult(BaseModel):
@@ -379,6 +379,7 @@ REGIONAL_THEME_DATASETS: dict[str, dict[str, dict[str, tuple[str, ...]]]] = {
"elevation": {"spw_terrain": ()}, "elevation": {"spw_terrain": ()},
"flood_climate": {"spw_flood_hazard": ("flood_hazard",)}, "flood_climate": {"spw_flood_hazard": ("flood_hazard",)},
"bathymetry": {"spw_bathymetry": ()}, "bathymetry": {"spw_bathymetry": ()},
"orthophoto": {"spw_orthophoto": ()},
}, },
"urbis": { "urbis": {
"buildings": {"urbis": ("buildings",)}, "buildings": {"urbis": ("buildings",)},
@@ -386,6 +387,7 @@ REGIONAL_THEME_DATASETS: dict[str, dict[str, dict[str, tuple[str, ...]]]] = {
"roads": {"urbis": ("roads",)}, "roads": {"urbis": ("roads",)},
"surface_water": {"urbis": ("water",)}, "surface_water": {"urbis": ("water",)},
"land_cover_use": {"urbis": ("space_occupation", "forest")}, "land_cover_use": {"urbis": ("space_occupation", "forest")},
"orthophoto": {"urbis_orthophoto": ()},
}, },
} }
@@ -37,6 +37,12 @@ class OrthophotoProduct:
layer: str layer: str
catalog_url: str catalog_url: str
limitation_message: 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 supports_detection: bool = False
color_mode: str = "rgb" color_mode: str = "rgb"
observed_at: datetime | None = None observed_at: datetime | None = None
@@ -70,6 +76,46 @@ class OrthophotoAcquisitionService:
supports_detection=True, 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): for year in range(2025, 2011, -1):
products.append( products.append(
OrthophotoProduct( OrthophotoProduct(
@@ -163,6 +209,10 @@ class OrthophotoAcquisitionService:
color_mode=product.color_mode, color_mode=product.color_mode,
catalog_url=product.catalog_url, catalog_url=product.catalog_url,
limitation_message=product.limitation_message, limitation_message=product.limitation_message,
provider=product.provider,
coverage_zone=product.coverage_zone,
attribution=product.attribution,
license_note=product.license_note,
).model_dump() ).model_dump()
for product in OrthophotoAcquisitionService._products(resolved_settings).values() 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_4326 = [min_x, min_y, max_x, max_y]
bbox_31370 = [float(value) for value in lambert_bounds] bbox_31370 = [float(value) for value in lambert_bounds]
request_identity = { request_identity = {
"provider": OrthophotoAcquisitionService.PROVIDER, "provider": product.provider,
"product_key": product.key, "product_key": product.key,
"wms_url": product.wms_url, "wms_url": product.wms_url,
"layer": product.layer, "layer": product.layer,
@@ -259,9 +309,45 @@ class OrthophotoAcquisitionService:
} }
@staticmethod @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): if not db.get(Project, project_id):
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) 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: if area_id is None:
return return
area = db.get(Area, area_id) 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) raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
if area.project_id != project_id: if area.project_id != project_id:
raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400) raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400)
selection = box(*bbox_epsg4326) require_contains(
area_geometry = to_shape(area.geometry) area,
transformer = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True) code="ORTHOPHOTO_SELECTION_OUTSIDE_AREA",
selection_metric = shapely_transform(transformer.transform, selection) message="Keep the orthophoto rectangle inside the selected work area",
area_metric = shapely_transform(transformer.transform, area_geometry) )
overlap_ratio = area_metric.intersection(selection_metric).area / selection_metric.area
if overlap_ratio < 0.99:
raise AppError(
code="ORTHOPHOTO_SELECTION_OUTSIDE_AREA",
message="Keep the orthophoto rectangle inside the selected work area",
details={"coverage_ratio": overlap_ratio},
status_code=422,
)
@staticmethod @staticmethod
def _cached_dataset( def _cached_dataset(
@@ -291,14 +369,15 @@ class OrthophotoAcquisitionService:
settings: Settings, settings: Settings,
product: OrthophotoProduct, product: OrthophotoProduct,
) -> Dataset | None: ) -> 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 return None
candidate = ( candidate = (
db.query(Dataset) db.query(Dataset)
.filter( .filter(
Dataset.project_id == project_id, Dataset.project_id == project_id,
Dataset.name == filename, Dataset.name == filename,
Dataset.source_name == OrthophotoAcquisitionService.PROVIDER, Dataset.source_name == product.provider,
Dataset.status == "ready", Dataset.status == "ready",
) )
.order_by(Dataset.imported_at.desc()) .order_by(Dataset.imported_at.desc())
@@ -311,7 +390,7 @@ class OrthophotoAcquisitionService:
return None return None
if imported_at.tzinfo is None: if imported_at.tzinfo is None:
imported_at = imported_at.replace(tzinfo=UTC) 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 None
return candidate return candidate
@@ -384,9 +463,9 @@ class OrthophotoAcquisitionService:
with output_memory.open(**profile) as output: with output_memory.open(**profile) as output:
output.write(image) output.write(image)
output.update_tags( output.update_tags(
source=f"Digitaal Vlaanderen WMS {product.layer}", source=f"{product.source_label} {product.layer}",
source_url=prepared["request_url"], source_url=prepared["request_url"],
attribution=OrthophotoAcquisitionService.ATTRIBUTION, attribution=product.attribution,
acquisition="explicit_bounded_map_selection", acquisition="explicit_bounded_map_selection",
) )
return output_memory.read() 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) raise AppError(code="ORTHOPHOTO_NOT_CONFIGURED", message="Official orthophoto acquisition is disabled", status_code=503)
prepared = OrthophotoAcquisitionService._prepared_request(payload, resolved_settings) prepared = OrthophotoAcquisitionService._prepared_request(payload, resolved_settings)
product: OrthophotoProduct = prepared["product"] 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" filename = f"orthofoto_{product.key}_{prepared['request_hash'][:12]}.tif"
cached = None if payload.force_refresh else OrthophotoAcquisitionService._cached_dataset( cached = None if payload.force_refresh else OrthophotoAcquisitionService._cached_dataset(
@@ -428,7 +513,7 @@ class OrthophotoAcquisitionService:
return OrthophotoAcquisitionResult( return OrthophotoAcquisitionResult(
output_dataset_id=cached.id, output_dataset_id=cached.id,
reused=True, reused=True,
provider=OrthophotoAcquisitionService.PROVIDER, provider=product.provider,
product_key=product.key, product_key=product.key,
display_name=product.display_name, display_name=product.display_name,
observation_label=product.observation_label, observation_label=product.observation_label,
@@ -440,7 +525,7 @@ class OrthophotoAcquisitionService:
resolution_m=resolved_settings.orthophoto_resolution_m, resolution_m=resolved_settings.orthophoto_resolution_m,
bbox_epsg4326=prepared["bbox_epsg4326"], bbox_epsg4326=prepared["bbox_epsg4326"],
bbox_epsg31370=prepared["bbox_epsg31370"], bbox_epsg31370=prepared["bbox_epsg31370"],
attribution=OrthophotoAcquisitionService.ATTRIBUTION, attribution=product.attribution,
limitation_message=product.limitation_message, limitation_message=product.limitation_message,
).model_dump(mode="json") ).model_dump(mode="json")
@@ -454,21 +539,21 @@ class OrthophotoAcquisitionService:
area_id=payload.area_id, area_id=payload.area_id,
filename=filename, filename=filename,
content=geotiff_content, content=geotiff_content,
source=f"Digitaal Vlaanderen WMS {product.layer}", source=f"{product.source_label} {product.layer}",
source_name=OrthophotoAcquisitionService.PROVIDER, source_name=product.provider,
temporal_series_key=f"digitaal-vlaanderen:orthophoto:{prepared['spatial_hash'][:24]}", temporal_series_key=f"{product.series_namespace}:orthophoto:{prepared['spatial_hash'][:24]}",
observed_at=observed_at, observed_at=observed_at,
valid_from=product.valid_from or observed_at, valid_from=product.valid_from or observed_at,
valid_to=product.valid_to, valid_to=product.valid_to,
temporal_granularity=product.temporal_granularity, temporal_granularity=product.temporal_granularity,
source_version=( source_version=(
f"most_recent_at_{acquired_at.date().isoformat()}" f"{product.key}_at_{acquired_at.date().isoformat()}"
if product.key == "most_recent" if product.key == "most_recent" or product.key.endswith("_latest")
else product.key else product.key
), ),
content_type="image/tiff", content_type="image/tiff",
source_metadata={ source_metadata={
"provider": OrthophotoAcquisitionService.PROVIDER, "provider": product.provider,
"service": "WMS", "service": "WMS",
"service_version": "1.3.0", "service_version": "1.3.0",
"product_key": product.key, "product_key": product.key,
@@ -481,8 +566,9 @@ class OrthophotoAcquisitionService:
"supports_detection": product.supports_detection, "supports_detection": product.supports_detection,
"layer": product.layer, "layer": product.layer,
"catalog_url": product.catalog_url, "catalog_url": product.catalog_url,
"attribution": OrthophotoAcquisitionService.ATTRIBUTION, "attribution": product.attribution,
"license_note": "Gebruik volgens het gebruiksrecht geografische webdiensten van Digitaal Vlaanderen.", "license_note": product.license_note,
"coverage_zone": product.coverage_zone,
}, },
provenance_metadata={ provenance_metadata={
"acquisition": "explicit_bounded_map_selection", "acquisition": "explicit_bounded_map_selection",
@@ -502,7 +588,7 @@ class OrthophotoAcquisitionService:
return OrthophotoAcquisitionResult( return OrthophotoAcquisitionResult(
output_dataset_id=dataset.id, output_dataset_id=dataset.id,
reused=False, reused=False,
provider=OrthophotoAcquisitionService.PROVIDER, provider=product.provider,
product_key=product.key, product_key=product.key,
display_name=product.display_name, display_name=product.display_name,
observation_label=product.observation_label, observation_label=product.observation_label,
@@ -514,7 +600,7 @@ class OrthophotoAcquisitionService:
resolution_m=resolved_settings.orthophoto_resolution_m, resolution_m=resolved_settings.orthophoto_resolution_m,
bbox_epsg4326=prepared["bbox_epsg4326"], bbox_epsg4326=prepared["bbox_epsg4326"],
bbox_epsg31370=prepared["bbox_epsg31370"], bbox_epsg31370=prepared["bbox_epsg31370"],
attribution=OrthophotoAcquisitionService.ATTRIBUTION, attribution=product.attribution,
limitation_message=product.limitation_message, limitation_message=product.limitation_message,
).model_dump(mode="json") ).model_dump(mode="json")
@@ -524,7 +610,7 @@ class OrthophotoAcquisitionService:
if ( if (
dataset is None dataset is None
or dataset.project_id != project_id 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 dataset.status != "ready"
or not dataset.storage_path or not dataset.storage_path
or not Path(dataset.storage_path).is_file() or not Path(dataset.storage_path).is_file()
@@ -37,6 +37,8 @@ SOURCE_POLICIES: dict[str, SourcePolicy] = {
"Gebouwen- en adressenregister", "rolling_snapshot", 90 "Gebouwen- en adressenregister", "rolling_snapshot", 90
), ),
"digitaal_vlaanderen_orthophoto": SourcePolicy("Orthofoto Vlaanderen", "rolling_snapshot", 180), "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( "agentschap_landbouw_zeevisserij_agricultural_parcels": SourcePolicy(
"Landbouwgebruikspercelen", "annual_release" "Landbouwgebruikspercelen", "annual_release"
), ),
@@ -151,7 +151,66 @@ def test_orthophoto_product_registry_exposes_only_governed_official_layers() ->
assert keys[0] == "most_recent" assert keys[0] == "most_recent"
assert {"2025", "2012", "2008_2011", "2000_2003", "1979_1990", "1971"}.issubset(keys) 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 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) prepared = OrthophotoAcquisitionService._prepared_request(_selection_payload(product_key="1971"), settings)
assert prepared["params"]["LAYERS"] == "OKZPAN71VL" assert prepared["params"]["LAYERS"] == "OKZPAN71VL"
@@ -39,6 +39,8 @@
<Config Name="Operator Session TTL" Target="GEOINTEL_AUTH_SESSION_TTL_SECONDS" Default="43200" Mode="" Description="Session lifetime in seconds (900-604800)." Type="Variable" Display="advanced" Required="true" Mask="false">43200</Config> <Config Name="Operator Session TTL" Target="GEOINTEL_AUTH_SESSION_TTL_SECONDS" Default="43200" Mode="" Description="Session lifetime in seconds (900-604800)." Type="Variable" Display="advanced" Required="true" Mask="false">43200</Config>
<Config Name="Official Orthophoto Acquisition" Target="ORTHOPHOTO_ENABLED" Default="true" Mode="" Description="Allow explicit bounded map selections to request the official Digitaal Vlaanderen orthophoto WMS." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config> <Config Name="Official Orthophoto Acquisition" Target="ORTHOPHOTO_ENABLED" Default="true" Mode="" Description="Allow explicit bounded map selections to request the official Digitaal Vlaanderen orthophoto WMS." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
<Config Name="Orthophoto WMS URL" Target="ORTHOPHOTO_WMS_URL" Default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms" Mode="" Description="Official Digitaal Vlaanderen most-recent winter orthophoto WMS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geo.api.vlaanderen.be/OMWRGBMRVL/wms</Config> <Config Name="Orthophoto WMS URL" Target="ORTHOPHOTO_WMS_URL" Default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms" Mode="" Description="Official Digitaal Vlaanderen most-recent winter orthophoto WMS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geo.api.vlaanderen.be/OMWRGBMRVL/wms</Config>
<Config Name="SPW Orthophoto WMS URL" Target="SPW_ORTHOPHOTO_WMS_URL" Default="https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer" Mode="" Description="Official SPW latest Walloon orthophoto WMS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer</Config>
<Config Name="Brussels Orthophoto WMS URL" Target="BRUSSELS_ORTHOPHOTO_WMS_URL" Default="https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows" Mode="" Description="Official Paradigm UrbIS Brussels orthophoto WMS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows</Config>
<Config Name="Orthophoto WMS Layer" Target="ORTHOPHOTO_WMS_LAYER" Default="Ortho" Mode="" Description="Allowlisted official orthophoto WMS layer identifier." Type="Variable" Display="advanced" Required="true" Mask="false">Ortho</Config> <Config Name="Orthophoto WMS Layer" Target="ORTHOPHOTO_WMS_LAYER" Default="Ortho" Mode="" Description="Allowlisted official orthophoto WMS layer identifier." Type="Variable" Display="advanced" Required="true" Mask="false">Ortho</Config>
<Config Name="Orthophoto Resolution (m)" Target="ORTHOPHOTO_RESOLUTION_M" Default="1.0" Mode="" Description="Requested analysis sampling in metres per pixel. Keep at 1.0 for the active building model profile." Type="Variable" Display="advanced" Required="true" Mask="false">1.0</Config> <Config Name="Orthophoto Resolution (m)" Target="ORTHOPHOTO_RESOLUTION_M" Default="1.0" Mode="" Description="Requested analysis sampling in metres per pixel. Keep at 1.0 for the active building model profile." Type="Variable" Display="advanced" Required="true" Mask="false">1.0</Config>
<Config Name="Orthophoto Minimum Side (m)" Target="ORTHOPHOTO_MIN_SIDE_M" Default="128" Mode="" Description="Minimum bounded request side length." Type="Variable" Display="advanced" Required="true" Mask="false">128</Config> <Config Name="Orthophoto Minimum Side (m)" Target="ORTHOPHOTO_MIN_SIDE_M" Default="128" Mode="" Description="Minimum bounded request side length." Type="Variable" Display="advanced" Required="true" Mask="false">128</Config>
+2
View File
@@ -43,6 +43,8 @@ GEOINTEL_AUTH_SESSION_TTL_SECONDS=43200
# Explicit, bounded acquisition from the official Digitaal Vlaanderen WMS. # Explicit, bounded acquisition from the official Digitaal Vlaanderen WMS.
ORTHOPHOTO_ENABLED=true ORTHOPHOTO_ENABLED=true
ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms 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_WMS_LAYER=Ortho
ORTHOPHOTO_RESOLUTION_M=1.0 ORTHOPHOTO_RESOLUTION_M=1.0
ORTHOPHOTO_MIN_SIDE_M=128 ORTHOPHOTO_MIN_SIDE_M=128
+4
View File
@@ -42,6 +42,8 @@ GEOINTEL_AUTH_SESSION_SECRET="${GEOINTEL_AUTH_SESSION_SECRET:-}"
GEOINTEL_AUTH_SESSION_TTL_SECONDS="${GEOINTEL_AUTH_SESSION_TTL_SECONDS:-43200}" GEOINTEL_AUTH_SESSION_TTL_SECONDS="${GEOINTEL_AUTH_SESSION_TTL_SECONDS:-43200}"
ORTHOPHOTO_ENABLED="${ORTHOPHOTO_ENABLED:-true}" ORTHOPHOTO_ENABLED="${ORTHOPHOTO_ENABLED:-true}"
ORTHOPHOTO_WMS_URL="${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms}" 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_WMS_LAYER="${ORTHOPHOTO_WMS_LAYER:-Ortho}"
ORTHOPHOTO_RESOLUTION_M="${ORTHOPHOTO_RESOLUTION_M:-1.0}" ORTHOPHOTO_RESOLUTION_M="${ORTHOPHOTO_RESOLUTION_M:-1.0}"
ORTHOPHOTO_MIN_SIDE_M="${ORTHOPHOTO_MIN_SIDE_M:-128}" 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 GEOINTEL_AUTH_SESSION_TTL_SECONDS="$GEOINTEL_AUTH_SESSION_TTL_SECONDS" \
-e ORTHOPHOTO_ENABLED="$ORTHOPHOTO_ENABLED" \ -e ORTHOPHOTO_ENABLED="$ORTHOPHOTO_ENABLED" \
-e ORTHOPHOTO_WMS_URL="$ORTHOPHOTO_WMS_URL" \ -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_WMS_LAYER="$ORTHOPHOTO_WMS_LAYER" \
-e ORTHOPHOTO_RESOLUTION_M="$ORTHOPHOTO_RESOLUTION_M" \ -e ORTHOPHOTO_RESOLUTION_M="$ORTHOPHOTO_RESOLUTION_M" \
-e ORTHOPHOTO_MIN_SIDE_M="$ORTHOPHOTO_MIN_SIDE_M" \ -e ORTHOPHOTO_MIN_SIDE_M="$ORTHOPHOTO_MIN_SIDE_M" \
+2
View File
@@ -23,6 +23,8 @@ services:
GEOINTEL_MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500} GEOINTEL_MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500}
ORTHOPHOTO_ENABLED: ${ORTHOPHOTO_ENABLED:-true} ORTHOPHOTO_ENABLED: ${ORTHOPHOTO_ENABLED:-true}
ORTHOPHOTO_WMS_URL: ${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms} 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_WMS_LAYER: ${ORTHOPHOTO_WMS_LAYER:-Ortho}
ORTHOPHOTO_RESOLUTION_M: ${ORTHOPHOTO_RESOLUTION_M:-1.0} ORTHOPHOTO_RESOLUTION_M: ${ORTHOPHOTO_RESOLUTION_M:-1.0}
ORTHOPHOTO_MIN_SIDE_M: ${ORTHOPHOTO_MIN_SIDE_M:-128} ORTHOPHOTO_MIN_SIDE_M: ${ORTHOPHOTO_MIN_SIDE_M:-128}
+2
View File
@@ -25,6 +25,8 @@ services:
MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500} MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500}
ORTHOPHOTO_ENABLED: ${ORTHOPHOTO_ENABLED:-true} ORTHOPHOTO_ENABLED: ${ORTHOPHOTO_ENABLED:-true}
ORTHOPHOTO_WMS_URL: ${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms} 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_WMS_LAYER: ${ORTHOPHOTO_WMS_LAYER:-Ortho}
ORTHOPHOTO_RESOLUTION_M: ${ORTHOPHOTO_RESOLUTION_M:-1.0} ORTHOPHOTO_RESOLUTION_M: ${ORTHOPHOTO_RESOLUTION_M:-1.0}
ORTHOPHOTO_MIN_SIDE_M: ${ORTHOPHOTO_MIN_SIDE_M:-128} ORTHOPHOTO_MIN_SIDE_M: ${ORTHOPHOTO_MIN_SIDE_M:-128}
+13 -9
View File
@@ -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` ### GET `/api/v1/projects/{project_id}/datasets/orthophoto/products`
Return the governed Digitaal Vlaanderen orthophoto product allowlist in the Return the governed Belgian orthophoto product allowlist in the canonical
canonical envelope. Every product reports its key, display/observation label, envelope. It contains Digitaal Vlaanderen products plus `wallonia_latest`
temporal granularity, native resolution, colour mode, catalogue URL, (SPW) and `brussels_latest` (Paradigm UrbIS). Every product reports its key,
limitations and whether current configured-YOLO detection is allowed. 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` ### POST `/api/v1/projects/{project_id}/datasets/orthophoto/acquire`
Explicitly acquire a bounded orthophoto selection from a governed official Explicitly acquire a bounded orthophoto selection from a governed official
Digitaal Vlaanderen WMS product. Arbitrary WMS URLs and layer names are not regional WMS product. Arbitrary WMS URLs and layer names are not accepted.
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 ```json
{ {
@@ -493,9 +497,9 @@ Safety contract:
reuse; reuse;
- WMS bytes are georeferenced to EPSG:31370 and persisted only through - WMS bytes are georeferenced to EPSG:31370 and persisted only through
`DatasetService`; no fetch runs on startup; `DatasetService`; no fetch runs on startup;
- only `most_recent` can enter the current configured-YOLO plus GRB-QA path; - only `most_recent`, `wallonia_latest` and `brussels_latest` can enter the
historical products are visual evidence and are never validated against the configured-YOLO path; QA must use the matching regional reference source
current GRB state; (GRB, PICC or UrbIS). Historical products remain visual evidence;
- product periods such as `1979_1990` remain explicitly multi-year and are not - product periods such as `1979_1990` remain explicitly multi-year and are not
presented as exact annual observations. presented as exact annual observations.
+4 -4
View File
@@ -110,10 +110,10 @@ Exit: schema tests pass and no runtime can advertise an unproven class/scope.
### WP1 — National imagery acquisition ### WP1 — National imagery acquisition
- [x] verify official SPW and Brussels imagery services and usage conditions; - [x] verify official SPW and Brussels imagery services and usage conditions;
- [ ] implement bounded SPW orthophoto acquisition with the existing AOI worker; - [x] implement bounded SPW orthophoto acquisition with the existing AOI worker;
- [ ] implement bounded UrbIS orthophoto acquisition with the existing AOI worker; - [x] implement bounded UrbIS orthophoto acquisition with the existing AOI worker;
- [ ] expose provider edition, resolution, time, CRS, attribution and checksum; - [x] expose provider edition, resolution, time, CRS, attribution and checksum;
- [ ] add retry/cache/size limits and live provider preflights; - [x] reuse retry/cache/size limits and verify both live WMS providers;
- [ ] retain explicit `partial`/`not_configured` status when a service is down. - [ ] retain explicit `partial`/`not_configured` status when a service is down.
Exit: one checksum-bound RGB GeoTIFF per target stratum can be acquired without Exit: one checksum-bound RGB GeoTIFF per target stratum can be acquired without
+2
View File
@@ -27,6 +27,8 @@ const DATASET_SOURCE_LABELS: Record<string, string> = {
department_omgeving_land_use: 'Departement Omgeving', department_omgeving_land_use: 'Departement Omgeving',
agentschap_landbouw_zeevisserij_agricultural_parcels: 'Agentschap Landbouw en Zeevisserij', agentschap_landbouw_zeevisserij_agricultural_parcels: 'Agentschap Landbouw en Zeevisserij',
digitaal_vlaanderen_orthophoto: 'Digitaal Vlaanderen', digitaal_vlaanderen_orthophoto: 'Digitaal Vlaanderen',
spw_orthophoto: 'SPW Orthofoto',
urbis_orthophoto: 'Paradigm UrbIS',
digitaal_vlaanderen_buildings_addresses_register: 'Digitaal Vlaanderen', digitaal_vlaanderen_buildings_addresses_register: 'Digitaal Vlaanderen',
digitaal_vlaanderen_dhmv: 'Digitaal Vlaanderen', digitaal_vlaanderen_dhmv: 'Digitaal Vlaanderen',
spw_terrain: 'Service public de Wallonie', spw_terrain: 'Service public de Wallonie',
+4
View File
@@ -362,6 +362,7 @@ export interface OrthophotoAcquireRequest {
export interface OrthophotoProductRead { export interface OrthophotoProductRead {
key: string key: string
provider: string
display_name: string display_name: string
observation_label: string observation_label: string
temporal_granularity: string temporal_granularity: string
@@ -369,6 +370,9 @@ export interface OrthophotoProductRead {
supports_detection: boolean supports_detection: boolean
color_mode: string color_mode: string
catalog_url: string catalog_url: string
coverage_zone: string
attribution: string
license_note: string
limitation_message: string limitation_message: string
} }