|
|
|
@@ -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()
|
|
|
|
|