Preserve native orthophoto detail for training
This commit is contained in:
@@ -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,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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -358,6 +358,7 @@ export interface OrthophotoAcquireRequest {
|
||||
area_id?: string
|
||||
product_key?: string
|
||||
force_refresh?: boolean
|
||||
resolution_m?: number
|
||||
}
|
||||
|
||||
export interface OrthophotoProductRead {
|
||||
|
||||
@@ -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())),
|
||||
|
||||
Reference in New Issue
Block a user