diff --git a/backend/README.md b/backend/README.md index cf2ca2ef..aa42fc0b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1357,6 +1357,10 @@ Settings: `ORTHOPHOTO_ENABLED`, `ORTHOPHOTO_WMS_URL`, `ORTHOPHOTO_TIMEOUT_SECONDS`, `ORTHOPHOTO_MAX_RESPONSE_MB` and `ORTHOPHOTO_CACHE_TTL_HOURS`. Keep the official HTTPS URL and 1 m profile unless a separately verified deployment/model profile requires a change. +An explicit bounded request may provide `resolution_m` down to the governed +product's native resolution. This is intended for reviewed training corpora; +the service rejects source oversampling and records rolling-latest observation +time as unknown per pixel rather than equating it with download time. Before a future `most_recent` source release is allowed into a governed pixel stage, run the metadata-only preflight for the exact intended rectangle: diff --git a/backend/app/schemas/orthophoto.py b/backend/app/schemas/orthophoto.py index c1479425..95303633 100644 --- a/backend/app/schemas/orthophoto.py +++ b/backend/app/schemas/orthophoto.py @@ -2,7 +2,7 @@ from __future__ import annotations from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, Field from .operations import VectorSelectionBBox @@ -12,6 +12,7 @@ class OrthophotoAcquireRequest(BaseModel): area_id: UUID | None = None product_key: str = "most_recent" force_refresh: bool = False + resolution_m: float | None = Field(default=None, ge=0.1, le=2.0) class OrthophotoProductRead(BaseModel): diff --git a/backend/app/services/orthophoto_acquisition_service.py b/backend/app/services/orthophoto_acquisition_service.py index f523060e..a992248e 100644 --- a/backend/app/services/orthophoto_acquisition_service.py +++ b/backend/app/services/orthophoto_acquisition_service.py @@ -262,8 +262,16 @@ class OrthophotoAcquisitionService: status_code=422, ) - width = max(1, math.ceil(width_m / settings.orthophoto_resolution_m)) - height = max(1, math.ceil(height_m / settings.orthophoto_resolution_m)) + resolution_m = float(payload.resolution_m or settings.orthophoto_resolution_m) + if resolution_m < product.native_resolution_m: + raise AppError( + code="ORTHOPHOTO_RESOLUTION_EXCEEDS_SOURCE", + message="Requested sampling cannot be finer than the governed source resolution", + details={"requested_resolution_m": resolution_m, "native_resolution_m": product.native_resolution_m}, + status_code=422, + ) + width = max(1, math.ceil(width_m / resolution_m)) + height = max(1, math.ceil(height_m / resolution_m)) bbox_4326 = [min_x, min_y, max_x, max_y] bbox_31370 = [float(value) for value in lambert_bounds] request_identity = { @@ -275,14 +283,14 @@ class OrthophotoAcquisitionService: "bbox_epsg31370": [round(value, 3) for value in bbox_31370], "width": width, "height": height, - "resolution_m": settings.orthophoto_resolution_m, + "resolution_m": resolution_m, } request_hash = hashlib.sha256(json.dumps(request_identity, sort_keys=True).encode("utf-8")).hexdigest() spatial_identity = { "bbox_epsg4326": request_identity["bbox_epsg4326"], "width": width, "height": height, - "resolution_m": settings.orthophoto_resolution_m, + "resolution_m": resolution_m, } spatial_hash = hashlib.sha256(json.dumps(spatial_identity, sort_keys=True).encode("utf-8")).hexdigest() params = { @@ -522,7 +530,7 @@ class OrthophotoAcquisitionService: layer=product.layer, width=prepared["width"], height=prepared["height"], - resolution_m=resolved_settings.orthophoto_resolution_m, + resolution_m=float(prepared["resolution_m"]), bbox_epsg4326=prepared["bbox_epsg4326"], bbox_epsg31370=prepared["bbox_epsg31370"], attribution=product.attribution, @@ -561,7 +569,10 @@ class OrthophotoAcquisitionService: "observation_label": product.observation_label, "observation_date_precision": product.temporal_granularity, "native_resolution_m": product.native_resolution_m, - "requested_resolution_m": resolved_settings.orthophoto_resolution_m, + "requested_resolution_m": float(prepared["resolution_m"]), + "observation_time_precision": ( + "unknown_per_pixel" if product.key == "most_recent" or product.key.endswith("_latest") else "product_period" + ), "color_mode": product.color_mode, "supports_detection": product.supports_detection, "layer": product.layer, @@ -581,7 +592,7 @@ class OrthophotoAcquisitionService: "bbox_epsg31370": prepared["bbox_epsg31370"], "width": prepared["width"], "height": prepared["height"], - "resolution_m": resolved_settings.orthophoto_resolution_m, + "resolution_m": float(prepared["resolution_m"]), "limitation_message": product.limitation_message, }, ) @@ -597,7 +608,7 @@ class OrthophotoAcquisitionService: layer=product.layer, width=prepared["width"], height=prepared["height"], - resolution_m=resolved_settings.orthophoto_resolution_m, + resolution_m=float(prepared["resolution_m"]), bbox_epsg4326=prepared["bbox_epsg4326"], bbox_epsg31370=prepared["bbox_epsg31370"], attribution=product.attribution, diff --git a/backend/tests/test_sprint196_map_orthophoto_analysis.py b/backend/tests/test_sprint196_map_orthophoto_analysis.py index 3576608d..c3f5443f 100644 --- a/backend/tests/test_sprint196_map_orthophoto_analysis.py +++ b/backend/tests/test_sprint196_map_orthophoto_analysis.py @@ -92,6 +92,7 @@ def _selection_payload( force_refresh: bool = True, area_id=None, product_key: str = "most_recent", + resolution_m: float | None = None, ) -> OrthophotoAcquireRequest: west, south = 199_000.0, 210_000.0 transformer = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True) @@ -108,6 +109,7 @@ def _selection_payload( area_id=area_id, product_key=product_key, force_refresh=force_refresh, + resolution_m=resolution_m, ) @@ -143,6 +145,21 @@ def test_orthophoto_request_is_bounded_and_uses_official_wms_contract() -> None: assert len(prepared["request_hash"]) == 64 +def test_training_request_can_use_native_resolution_but_not_oversample_source() -> None: + settings = Settings(_env_file=None) + prepared = OrthophotoAcquisitionService._prepared_request( + _selection_payload(product_key="wallonia_latest", resolution_m=0.25), settings + ) + assert 2_000 <= prepared["width"] <= 2_120 + assert prepared["resolution_m"] == 0.25 + + with pytest.raises(AppError) as exc_info: + OrthophotoAcquisitionService._prepared_request( + _selection_payload(product_key="wallonia_latest", resolution_m=0.1), settings + ) + assert exc_info.value.code == "ORTHOPHOTO_RESOLUTION_EXCEEDS_SOURCE" + + def test_orthophoto_product_registry_exposes_only_governed_official_layers() -> None: settings = Settings(_env_file=None) products = OrthophotoAcquisitionService.list_products(settings) diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index a958e17a..70e4d090 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -385,6 +385,7 @@ least 99% of it. "bbox": {"min_x": 5.10, "min_y": 51.17, "max_x": 5.11, "max_y": 51.18, "crs": "EPSG:4326"}, "area_id": "optional-project-area-uuid", "product_key": "most_recent", + "resolution_m": 0.25, "force_refresh": false } ``` @@ -394,6 +395,12 @@ identifies the raster Dataset; `result_json` contains provider, layer, pixel dimensions, EPSG:4326/EPSG:31370 bounds, sampling resolution, attribution, cache reuse and limitation text. Historical products also persist their observation/validity period and a spatially scoped temporal-series key. +`resolution_m` is optional (0.1-2.0 m) and can never be finer than the +allowlisted product's native resolution. It exists for governed training and +review exports; ordinary workbench requests retain the configured 1 m default. +For rolling `latest` products, acquisition time is not represented as the +per-pixel observation date: provenance explicitly records +`observation_time_precision=unknown_per_pixel`. ### GET `/api/v1/projects/{project_id}/datasets/grb/products` diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a95a3d4b..52e60d2a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -358,6 +358,7 @@ export interface OrthophotoAcquireRequest { area_id?: string product_key?: string force_refresh?: boolean + resolution_m?: number } export interface OrthophotoProductRead { diff --git a/scripts/assemble_belgium_building_corpus.py b/scripts/assemble_belgium_building_corpus.py index aedfa754..d619d7c1 100644 --- a/scripts/assemble_belgium_building_corpus.py +++ b/scripts/assemble_belgium_building_corpus.py @@ -142,7 +142,12 @@ def main() -> int: raster_path=raster_target, source_name=reference_source, min_label_px=args.min_label_px, - imagery_observed_at=raster.observed_at.isoformat() if raster.observed_at else None, + imagery_observed_at=( + raster.observed_at.isoformat() + if raster.observed_at + and (raster.source_metadata or {}).get("observation_time_precision") != "unknown_per_pixel" + else None + ), reference_observed_at=reference.observed_at.isoformat() if reference.observed_at else None, ) normalized_target.write_text(json.dumps(normalized, ensure_ascii=False), encoding="utf-8") diff --git a/scripts/normalize_belgium_building_labels.py b/scripts/normalize_belgium_building_labels.py index 4379f364..8918285a 100644 --- a/scripts/normalize_belgium_building_labels.py +++ b/scripts/normalize_belgium_building_labels.py @@ -160,10 +160,12 @@ def normalize( counts[str(decision["reason"])] += 1 temporal_mismatch_days = None + temporal_alignment_status = "unknown" if imagery_observed_at and reference_observed_at: imagery_date = datetime.fromisoformat(imagery_observed_at.replace("Z", "+00:00")) reference_date = datetime.fromisoformat(reference_observed_at.replace("Z", "+00:00")) temporal_mismatch_days = abs((imagery_date - reference_date).days) + temporal_alignment_status = "measured" normalized = { "type": "FeatureCollection", "name": f"canonical-building-{source_name}", @@ -179,6 +181,7 @@ def normalize( "imagery_observed_at": imagery_observed_at, "reference_observed_at": reference_observed_at, "temporal_mismatch_days": temporal_mismatch_days, + "temporal_alignment_status": temporal_alignment_status, "input_feature_count": len(payload["features"]), "accepted_feature_count": len(accepted), "decision_counts": dict(sorted(counts.items())),