Preserve native orthophoto detail for training
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-27 01:20:10 +02:00
parent 7a83df1e7f
commit 9f05451f04
8 changed files with 59 additions and 10 deletions
+4
View File
@@ -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:
+2 -1
View File
@@ -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):
@@ -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,
@@ -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)
+7
View File
@@ -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`
+1
View File
@@ -358,6 +358,7 @@ export interface OrthophotoAcquireRequest {
area_id?: string
product_key?: string
force_refresh?: boolean
resolution_m?: number
}
export interface OrthophotoProductRead {
+6 -1
View File
@@ -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")
@@ -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())),