correct the tiled inference chain and move runs off the request thread

Tile handling produced results that were wrong before any model quality
question arose:

- orthophoto tiles reached the model through PIL convert("RGB"), which
  truncates the high byte of a 16-bit product and treats a 4-band RGB+NIR
  tile's infrared channel as colour. Tiles are now read with rasterio, the
  visible bands are chosen explicitly, and values are percentile-stretched
  across all three bands together so hue is preserved;
- an object wider than the tile overlap was truncated by both tiles into two
  boxes that barely intersect, so IoU suppression kept both: two false
  positives and one missed footprint per seam building. Suppression now also
  compares overlap against the smaller box, and boxes cut by an interior tile
  edge are dropped in favour of the neighbouring tile's complete view;
- georeferencing fell back to an assumed EPSG:4326 when a manifest carried no
  CRS, producing geometry that renders plausibly in the wrong place. QA
  already refused such a tile; inference now fails closed too.

Segmentation QA scored candidates against every reference feature in the
dataset, so every building outside the inferred tiles counted as a false
negative. It now applies the same persisted tile coverage that detection QA
has always used, including the indexed ST_Intersects prefilter.

Duplicate suppression uses an STRtree instead of the O(n^2) scan, tiles are
predicted in batches of YOLO_BATCH_SIZE (a setting that existed but was never
read), and detection/segmentation runs can be queued through /run-async for a
polling background worker rather than holding an HTTP worker thread for
minutes of GPU work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-22 14:32:44 +02:00
co-authored by Claude Opus 5
parent 2b968b74cf
commit 08188005bd
19 changed files with 1727 additions and 112 deletions
+24
View File
@@ -18,6 +18,7 @@ from app.schemas import (
DetectionRunResponse,
Envelope,
GeoJsonFeatureCollection,
JobRead,
ModelAssetListResponse,
YoloPreflightResponse,
)
@@ -71,6 +72,29 @@ def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -
return envelope(result.model_dump())
@router.post("/run-async", response_model=Envelope[JobRead])
def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict:
"""Queue a detection run for the background worker.
Tiled GPU inference takes minutes; ``POST /detection/run`` performs it
inside the request and is only appropriate for a handful of tiles. Poll
``GET /jobs/{id}`` for the queued run instead.
"""
job = DetectionService.enqueue_detection(
db=db,
project_id=payload.project_id,
dataset_id=payload.dataset_id,
model_id=payload.model_id,
model_asset_id=payload.model_asset_id,
confidence_threshold=payload.confidence_threshold,
class_filter=payload.class_filter,
tile_manifest_path=payload.tile_manifest_path,
parameters_json=payload.parameters_json,
)
return envelope(JobRead.model_validate(job).model_dump(mode="json"))
@router.get("/runs", response_model=Envelope[DetectionRunListResponse])
def list_detection_runs(
project_id: UUID | None = None,
+22
View File
@@ -10,6 +10,7 @@ from app.schemas import (
AnalysisQaResponse,
Envelope,
GeoJsonFeatureCollection,
JobRead,
SegmentationListResponse,
SegmentationModelsResponse,
SegmentationQaRequest,
@@ -46,6 +47,27 @@ def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_
return envelope(result.model_dump())
@router.post("/run-async", response_model=Envelope[JobRead])
def queue_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict:
"""Queue a segmentation run for the background worker.
Configured segmentation walks the same tile manifest as detection and is
just as unsuited to running inside the request. Poll ``GET /jobs/{id}``.
"""
job = SegmentationService.enqueue_segmentation(
db=db,
project_id=payload.project_id,
dataset_id=payload.dataset_id,
model_id=payload.model_id,
confidence_threshold=payload.confidence_threshold,
class_filter=payload.class_filter,
tile_manifest_path=payload.tile_manifest_path,
parameters_json=payload.parameters_json,
)
return envelope(JobRead.model_validate(job).model_dump(mode="json"))
@router.get("/runs", response_model=Envelope[SegmentationRunListResponse])
def list_segmentation_runs(
project_id: UUID | None = None,
+9
View File
@@ -350,6 +350,12 @@ class Settings(BaseSettings):
)
aoi_worker_enabled: bool = Field(default=False, validation_alias="GEOINTEL_AOI_WORKER_ENABLED")
aoi_worker_poll_seconds: float = Field(default=2.0, ge=0.5, le=60.0, validation_alias="GEOINTEL_AOI_WORKER_POLL_SECONDS")
# Executes queued detection.run / segmentation.run jobs so tiled GPU
# inference never blocks an HTTP request.
analysis_worker_enabled: bool = Field(default=False, validation_alias="GEOINTEL_ANALYSIS_WORKER_ENABLED")
analysis_worker_poll_seconds: float = Field(
default=2.0, ge=0.5, le=60.0, validation_alias="GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS"
)
database_statement_timeout_ms: int = Field(default=5_000, validation_alias="DATABASE_STATEMENT_TIMEOUT_MS")
yolo_enabled: bool = Field(default=False, validation_alias="YOLO_ENABLED")
yolo_models_dir: str = Field(default="/app/models", validation_alias="YOLO_MODELS_DIR")
@@ -377,6 +383,9 @@ class Settings(BaseSettings):
yolo_max_tiles: int = Field(default=100, validation_alias="YOLO_MAX_TILES")
yolo_max_detections: int = Field(default=1000, validation_alias="YOLO_MAX_DETECTIONS")
yolo_duplicate_iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0, validation_alias="YOLO_DUPLICATE_IOU_THRESHOLD")
yolo_suppress_tile_edge_detections: bool = Field(
default=True, validation_alias="YOLO_SUPPRESS_TILE_EDGE_DETECTIONS"
)
yolo_batch_size: int = Field(default=1, validation_alias="YOLO_BATCH_SIZE")
yolo_seg_enabled: bool = Field(default=False, validation_alias="YOLO_SEG_ENABLED")
yolo_seg_model_path: str | None = Field(default=None, validation_alias="YOLO_SEG_MODEL_PATH")
+9 -2
View File
@@ -20,6 +20,7 @@ from app.core.request_context import reset_request_id, set_request_id
from app.db.session import SessionLocal
from app.services.runtime_reconciliation_service import RuntimeReconciliationService
from app.services.auth_service import AuthService
from app.services.analysis_job_worker import AnalysisJobWorker
from app.services.aoi_operation_worker import AoiOperationWorker
@@ -50,6 +51,7 @@ def create_app() -> FastAPI:
async def lifespan(_: FastAPI):
worker_stop = asyncio.Event()
worker_task = None
analysis_worker_task = None
if settings.reconcile_interrupted_runs_on_startup:
db = SessionLocal()
try:
@@ -69,12 +71,17 @@ def create_app() -> FastAPI:
db.close()
if settings.aoi_worker_enabled:
worker_task = asyncio.create_task(AoiOperationWorker.run(worker_stop, settings.aoi_worker_poll_seconds))
if settings.analysis_worker_enabled:
analysis_worker_task = asyncio.create_task(
AnalysisJobWorker.run(worker_stop, settings.analysis_worker_poll_seconds)
)
try:
yield
finally:
worker_stop.set()
if worker_task is not None:
await worker_task
for task in (worker_task, analysis_worker_task):
if task is not None:
await task
app = FastAPI(
title="GeoIntel",
+158
View File
@@ -0,0 +1,158 @@
"""Background execution for queued analysis runs.
Tiled GPU inference is minutes of work. Running it inside the HTTP request
holds a worker thread for the whole duration, times the client out and leaves
the operator without progress. Queued ``detection.run`` and
``segmentation.run`` jobs are picked up here instead, mirroring the polling
worker the AOI operations already use so the runtime keeps one job model.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from uuid import UUID
from app.db.session import SessionLocal
from app.models import Job
logger = logging.getLogger("geointel.analysis_worker")
class AnalysisJobWorker:
HANDLED_JOB_TYPES = ("detection.run", "segmentation.run")
BATCH_SIZE = 4
@staticmethod
def _uuid(value: Any) -> UUID | None:
if isinstance(value, UUID):
return value
try:
return UUID(str(value))
except (TypeError, ValueError):
return None
@staticmethod
def _dispatch(db, job: Job) -> Any:
# Imported lazily: both services import each other's helpers, and the
# worker must not add a third edge to that cycle at module load.
from app.services.detection_service import DetectionService
from app.services.segmentation_service import SegmentationService
parameters = job.parameters_json if isinstance(job.parameters_json, dict) else {}
project_id = AnalysisJobWorker._uuid(parameters.get("project_id"))
dataset_id = AnalysisJobWorker._uuid(parameters.get("dataset_id"))
if project_id is None or dataset_id is None:
raise ValueError("Queued analysis job is missing project_id or dataset_id")
common = {
"db": db,
"project_id": project_id,
"dataset_id": dataset_id,
"model_id": parameters.get("model_id"),
"confidence_threshold": float(parameters.get("confidence_threshold") or 0.0),
"class_filter": parameters.get("class_filter") or [],
"tile_manifest_path": parameters.get("tile_manifest_path"),
"parameters_json": parameters.get("parameters_json") or {},
"existing_job": job,
}
if job.job_type == "detection.run":
return DetectionService.run_detection(
model_asset_id=parameters.get("model_asset_id"),
**common,
)
return SegmentationService.run_segmentation(**common)
@staticmethod
def _claim(db, job: Job) -> None:
"""Take the job out of the queue before doing any work on it.
Without this the next poll would pick the same row up again while the
first execution is still running on the GPU.
"""
job.status = "running"
db.add(job)
db.commit()
@staticmethod
def _finalize(db, job: Job, result: Any) -> None:
"""Close a job the handler left open.
The analysis services normally set the terminal status themselves.
If one returns without doing so, recording the outcome here is what
keeps the job from sitting in "running" for ever.
"""
if job.status != "running":
return
status = getattr(result, "status", None)
if status == "success":
job.status = "success"
job.result_json = {
"detection_count": getattr(result, "detection_count", None),
"segmentation_count": getattr(result, "segmentation_count", None),
}
else:
job.status = "failed"
job.error_message = getattr(result, "message", None) or "Analysis run did not complete"
job.result_json = {"error_code": getattr(result, "error_code", None) or "ANALYSIS_JOB_INCOMPLETE"}
db.add(job)
db.commit()
@staticmethod
def _mark_failed(db, job: Job, *, code: str, message: str) -> None:
try:
db.rollback()
except Exception:
pass
job.status = "failed"
job.error_message = message
job.result_json = {"error_code": code, "message": message}
db.add(job)
db.commit()
@staticmethod
def run_once(db=None) -> int:
"""Execute one batch of queued analysis jobs. Returns the batch size."""
owns_session = db is None
session = db if db is not None else SessionLocal()
try:
rows = [
job
for job in (
session.query(Job)
.filter(Job.status == "queued")
.filter(Job.job_type.in_(AnalysisJobWorker.HANDLED_JOB_TYPES))
.order_by(Job.created_at)
.limit(AnalysisJobWorker.BATCH_SIZE)
.all()
)
if job.job_type in AnalysisJobWorker.HANDLED_JOB_TYPES and job.status == "queued"
]
for job in rows:
try:
AnalysisJobWorker._claim(session, job)
result = AnalysisJobWorker._dispatch(session, job)
AnalysisJobWorker._finalize(session, job, result)
except Exception as exc:
code = getattr(exc, "code", None) or "ANALYSIS_JOB_INTERNAL_ERROR"
message = getattr(exc, "message", None) or str(exc) or "Unexpected analysis job failure"
AnalysisJobWorker._mark_failed(session, job, code=str(code), message=str(message))
logger.exception("Analysis job failed job_id=%s job_type=%s", job.id, job.job_type)
return len(rows)
finally:
if owns_session:
session.close()
@staticmethod
async def run(stop_event: asyncio.Event, poll_seconds: float) -> None:
while not stop_event.is_set():
processed = await asyncio.to_thread(AnalysisJobWorker.run_once)
if processed == 0:
try:
await asyncio.wait_for(stop_event.wait(), timeout=poll_seconds)
except TimeoutError:
pass
@@ -8,6 +8,24 @@ from shapely.geometry import Polygon
from app.core.errors import AppError
def _require_source_crs(crs: str | None, tile: dict[str, Any]) -> str:
"""Resolve the CRS a pixel coordinate is measured in, or fail.
Falling back to EPSG:4326 turned a missing manifest field into geometry
that sits in the wrong place while still looking like a valid polygon on
the map. A georeferenced result without a known CRS is not a result.
"""
for candidate in (crs, tile.get("crs"), tile.get("source_crs")):
if isinstance(candidate, str) and candidate.strip():
return candidate.strip()
raise AppError(
code="DETECTION_TILE_CRS_REQUIRED",
message="Georeferencing a tile requires explicit CRS metadata",
status_code=422,
)
def pixel_bbox_to_epsg4326_polygon(bbox: list[float], tile: dict[str, Any], crs: str | None = None) -> Polygon:
if len(bbox) != 4:
raise AppError(code="DETECTION_INVALID_BBOX", message="YOLO detection bbox must contain four pixel coordinates", status_code=422)
@@ -28,7 +46,7 @@ def pixel_bbox_to_epsg4326_polygon(bbox: list[float], tile: dict[str, Any], crs:
else:
corners = _corners_from_bounds(bbox=[x_min, y_min, x_max, y_max], tile=tile)
source_crs = crs or tile.get("crs") or tile.get("source_crs") or "EPSG:4326"
source_crs = _require_source_crs(crs, tile)
if str(source_crs).upper() not in {"EPSG:4326", "4326"}:
transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
corners = [transformer.transform(x, y) for x, y in corners]
@@ -61,7 +79,7 @@ def pixel_points_to_epsg4326_polygon(points: list[list[float]], tile: dict[str,
else:
coordinates = [_project_pixel_with_bounds(tile, x, y) for x, y in pixel_points]
source_crs = crs or tile.get("crs") or tile.get("source_crs") or "EPSG:4326"
source_crs = _require_source_crs(crs, tile)
if str(source_crs).upper() not in {"EPSG:4326", "4326"}:
transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
coordinates = [transformer.transform(x, y) for x, y in coordinates]
+317 -26
View File
@@ -9,7 +9,11 @@ from typing import Any
from typing import Type
from geoalchemy2.shape import from_shape, to_shape
from pyproj import Transformer
from shapely.geometry import box as shapely_box
from shapely.geometry import mapping, shape
from shapely.ops import transform as shapely_transform
from shapely.strtree import STRtree
from sqlalchemy import func
from app.core.config import Settings, get_settings
@@ -18,6 +22,7 @@ from app.core.request_context import get_request_id
from app.models import AnalysisRun, Area, Dataset, Detection, Job, Project, VectorFeature
from app.schemas.detection import DetectionListResponse, DetectionRead, DetectionRunListResponse, DetectionRunRead, DetectionRunResponse
from app.services.detection_georeferencing import pixel_bbox_to_epsg4326_polygon
from app.services.detection_metrics_service import DetectionMetricsService
from app.services.detection_qa_service import DetectionQaService
from app.services.dataset_consumption_gate_service import DatasetConsumptionGate
from app.services.model_asset_catalog_service import ModelAssetCatalogService
@@ -51,22 +56,11 @@ class DetectionService:
parameters_json: dict[str, Any] | None = None,
settings: Settings | None = None,
yolo_adapter_class: Type[YoloDetectionAdapter] = YoloDetectionAdapter,
existing_job: Job | None = None,
) -> DetectionRunResponse:
parameters = dict(parameters_json or {})
resolved_settings = settings or get_settings()
project = db.get(Project, project_id)
if not project:
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
dataset = db.get(Dataset, dataset_id)
if not dataset or dataset.project_id != project_id:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
if dataset.dataset_type != "raster":
raise AppError(
code="INVALID_DATASET_TYPE",
message="Detection requires a raster dataset",
details={"dataset_type": dataset.dataset_type},
status_code=400,
)
dataset = DetectionService._validate_run_request(db, project_id=project_id, dataset_id=dataset_id)
TemporalCompatibilityService.ensure_detection_source_supported(dataset)
selected_model_asset = None
@@ -122,7 +116,7 @@ class DetectionService:
"tile_manifest_path": tile_manifest_path,
"parameters_json": parameters,
}
job = DetectionService._create_job(db, project_id, dataset_id, run_parameters)
job = DetectionService._create_job(db, project_id, dataset_id, run_parameters, existing_job=existing_job)
analysis_run = DetectionService._create_analysis_run(db, project_id, dataset_id, job.id, model, run_parameters)
logger.info(
"detection_started request_id=%s project_id=%s dataset_id=%s job_id=%s analysis_run_id=%s model_id=%s",
@@ -419,7 +413,19 @@ class DetectionService:
class_name=class_name,
min_confidence=min_confidence,
)
raw_candidate_geometries = [({"id": str(row.id), "class_name": row.class_name}, to_shape(row.geometry)) for row in detections]
raw_candidate_geometries = [
(
{
"id": str(row.id),
"class_name": row.class_name,
# Confidence lets the matcher rank candidates the way
# detection benchmarks do instead of by row order.
"confidence": row.confidence,
},
to_shape(row.geometry),
)
for row in detections
]
candidate_geometries = raw_candidate_geometries
coverage = None
@@ -510,10 +516,31 @@ class DetectionService:
reference_envelopes,
iou_threshold,
)
candidate_geometry_mode = DetectionQaService.candidate_geometry_mode(candidate_geometries)
box_to_footprint_diagnostics = DetectionQaService.box_to_footprint_diagnostics(
evidence,
envelope_evidence,
iou_threshold=iou_threshold,
candidate_geometry_mode=candidate_geometry_mode,
)
box_to_footprint_diagnostics["envelope_precision_recall_curve"] = (
DetectionMetricsService.precision_recall_curve(
candidate_geometries,
reference_envelopes,
iou_threshold=iou_threshold,
)
)
if candidate_geometry_mode == "axis_aligned_boxes":
coverage_warnings.append(
"Candidates are axis-aligned detector boxes; strict footprint IoU cannot reach 1 for "
"rotated or non-rectangular buildings. See box_to_footprint_diagnostics."
)
# Threshold-independent view of the same populations, so the run can be
# compared with another model instead of only with itself.
precision_recall_curve = DetectionMetricsService.precision_recall_curve(
candidate_geometries,
reference_geometries,
iou_threshold=iou_threshold,
)
mean_iou = None if not evidence.match_iou_values else sum(evidence.match_iou_values) / len(evidence.match_iou_values)
precision = evidence.matches / (evidence.matches + evidence.false_positives) if evidence.matches + evidence.false_positives > 0 else None
@@ -549,6 +576,7 @@ class DetectionService:
"coverage": coverage_summary,
"temporal_compatibility": temporal_compatibility,
"box_to_footprint_diagnostics": box_to_footprint_diagnostics,
"precision_recall_curve": precision_recall_curve,
"match_evidence": evidence.match_evidence,
"false_positive_evidence": evidence.false_positive_evidence,
"false_negative_evidence": evidence.false_negative_evidence,
@@ -560,6 +588,9 @@ class DetectionService:
"mean_iou": mean_iou,
"false_positive_count": evidence.false_positives,
"false_negative_count": evidence.false_negatives,
"average_precision": precision_recall_curve["average_precision"],
"best_f1": precision_recall_curve["best_f1"],
"best_f1_threshold": precision_recall_curve["best_f1_threshold"],
},
)
logger.info(
@@ -594,13 +625,32 @@ class DetectionService:
"coverage": coverage_summary,
"temporal_compatibility": temporal_compatibility,
"box_to_footprint_diagnostics": box_to_footprint_diagnostics,
"precision_recall_curve": precision_recall_curve,
"match_evidence": evidence.match_evidence,
"false_positive_evidence": evidence.false_positive_evidence,
"false_negative_evidence": evidence.false_negative_evidence,
}
@staticmethod
def _create_job(db, project_id: uuid.UUID, dataset_id: uuid.UUID, parameters: dict[str, Any]) -> Job:
def _create_job(
db,
project_id: uuid.UUID,
dataset_id: uuid.UUID,
parameters: dict[str, Any],
existing_job: Job | None = None,
) -> Job:
if existing_job is not None:
# A queued job already represents this run; reuse it so the client
# keeps polling one identifier from request to result.
existing_job.status = "running"
existing_job.dataset_id = dataset_id
existing_job.input_dataset_id = dataset_id
existing_job.parameters_json = {**(existing_job.parameters_json or {}), **parameters}
existing_job.started_at = DetectionService._now()
db.add(existing_job)
db.commit()
db.refresh(existing_job)
return existing_job
job = Job(
id=uuid.uuid4(),
job_type="detection.run",
@@ -616,6 +666,78 @@ class DetectionService:
db.refresh(job)
return job
@staticmethod
def enqueue_detection(
db,
project_id: uuid.UUID,
dataset_id: uuid.UUID,
model_id: str,
confidence_threshold: float,
model_asset_id: str | None = None,
class_filter: list[str] | None = None,
tile_manifest_path: str | None = None,
parameters_json: dict[str, Any] | None = None,
) -> Job:
"""Accept a detection run for background execution.
Everything cheap enough to answer inside the request is checked here,
so an operator learns about a missing dataset or an unvalidated class
immediately rather than from a job that fails minutes later.
"""
DetectionService._validate_run_request(
db,
project_id=project_id,
dataset_id=dataset_id,
)
job = Job(
id=uuid.uuid4(),
job_type="detection.run",
status="queued",
project_id=project_id,
dataset_id=dataset_id,
input_dataset_id=dataset_id,
parameters_json={
"project_id": str(project_id),
"dataset_id": str(dataset_id),
"model_id": model_id,
"model_asset_id": model_asset_id,
"confidence_threshold": confidence_threshold,
"class_filter": class_filter or [],
"tile_manifest_path": tile_manifest_path,
"parameters_json": dict(parameters_json or {}),
},
)
db.add(job)
db.commit()
db.refresh(job)
logger.info(
"detection_queued request_id=%s project_id=%s dataset_id=%s job_id=%s model_id=%s",
get_request_id(),
project_id,
dataset_id,
job.id,
model_id,
)
return job
@staticmethod
def _validate_run_request(db, *, project_id: uuid.UUID, dataset_id: uuid.UUID) -> Dataset:
project = db.get(Project, project_id)
if not project:
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
dataset = db.get(Dataset, dataset_id)
if not dataset or dataset.project_id != project_id:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
if dataset.dataset_type != "raster":
raise AppError(
code="INVALID_DATASET_TYPE",
message="Detection requires a raster dataset",
details={"dataset_type": dataset.dataset_type},
status_code=400,
)
return dataset
@staticmethod
def _query_detection_rows(
db,
@@ -634,7 +756,14 @@ class DetectionService:
query = query.filter(Detection.class_name == class_name)
if min_confidence is not None:
query = query.filter(Detection.confidence >= min_confidence)
return query.order_by(Detection.created_at.desc()).all()
# ``created_at`` defaults to the transaction timestamp, so every
# detection in a run shares one value and ordering by it alone leaves
# the row order undefined. Confidence first, id as a stable tiebreak.
return query.order_by(
Detection.confidence.desc(),
Detection.created_at.desc(),
Detection.id.asc(),
).all()
@staticmethod
def _detection_properties(detection: Detection) -> dict[str, Any]:
@@ -792,10 +921,20 @@ class DetectionService:
model = adapter.load_model(model_path)
allowed_classes = {DetectionService._canonical_class_name(value) for value in class_filter if DetectionService._canonical_class_name(value)}
candidates: list[dict[str, Any]] = []
manifest_crs = manifest.get("crs") or manifest.get("source_crs") or manifest.get("dataset_crs") or "EPSG:4326"
for tile in manifest["tiles"]:
tile_path = DetectionService._resolve_tile_path(tile, Path(tile_manifest_path or "").expanduser())
for raw in adapter.predict_tile(model, tile_path, confidence_threshold):
manifest_crs = DetectionService._require_manifest_crs(manifest)
raster_bounds = DetectionService._bounds_to_epsg4326(manifest.get("bounds"), manifest_crs)
tiles = list(manifest["tiles"])
tile_paths = [
DetectionService._resolve_tile_path(tile, Path(tile_manifest_path or "").expanduser()) for tile in tiles
]
# Batched so the GPU is not idle between tiles; each tile keeps its own
# transform for georeferencing, so results stay per tile and in order.
detections_per_tile = adapter.predict_tiles(model, tile_paths, confidence_threshold)
for tile, tile_path, raw_detections in zip(tiles, tile_paths, detections_per_tile):
tile_crs = tile.get("crs") or manifest_crs
tile_bounds_4326 = DetectionService._bounds_to_epsg4326(tile.get("bounds"), tile_crs)
tile_edge_tolerance = DetectionService._tile_edge_tolerance(tile, tile_bounds_4326)
for raw in raw_detections:
model_class_name = str(raw.get("class_name") or "").strip()
class_name = DetectionService._canonical_class_name(model_class_name)
confidence = float(raw.get("confidence", 0.0))
@@ -806,7 +945,7 @@ class DetectionService:
bbox = raw.get("bbox")
if not isinstance(bbox, list):
raise AppError(code="DETECTION_INVALID_BBOX", message="YOLO adapter returned a detection without bbox", status_code=422)
geometry = pixel_bbox_to_epsg4326_polygon(bbox=bbox, tile=tile, crs=tile.get("crs") or manifest_crs)
geometry = pixel_bbox_to_epsg4326_polygon(bbox=bbox, tile=tile, crs=tile_crs)
properties = dict(raw.get("properties") or {})
if model_class_name and model_class_name != class_name:
properties.setdefault("model_class_name", model_class_name)
@@ -818,10 +957,19 @@ class DetectionService:
"bbox": bbox,
"source_tile_path": str(tile_path),
"properties": {**properties, "tile_index": tile.get("index")},
"tile_bounds": tile_bounds_4326,
"tile_edge_tolerance": tile_edge_tolerance,
}
)
edge_filtered_candidates = candidates
if settings.yolo_suppress_tile_edge_detections:
edge_filtered_candidates = DetectionService._drop_tile_edge_truncations(
candidates,
raster_bounds=raster_bounds,
tolerance=0.0,
)
filtered_candidates = DetectionService._suppress_duplicate_candidates(
candidates,
edge_filtered_candidates,
iou_threshold=float(settings.yolo_duplicate_iou_threshold),
)
persisted: list[Detection] = []
@@ -858,7 +1006,9 @@ class DetectionService:
return persisted, {
"raw_detection_count": len(candidates),
"suppressed_detection_count": len(candidates) - len(filtered_candidates),
"tile_edge_truncated_count": len(candidates) - len(edge_filtered_candidates),
"duplicate_iou_threshold": float(settings.yolo_duplicate_iou_threshold),
"containment_suppression_threshold": DetectionService.CONTAINMENT_SUPPRESSION_THRESHOLD,
"runtime_model_provenance": runtime_model_provenance.as_dict(),
}
@@ -887,21 +1037,90 @@ class DetectionService:
def _canonical_class_name(value: Any) -> str:
return str(value or "").strip().casefold()
# An object wider than the tile overlap is truncated by both tiles, so the
# two halves barely intersect and IoU alone never suppresses them. Overlap
# measured against the smaller box catches that case; the threshold is
# deliberately strict so that terraced houses stay separate detections.
CONTAINMENT_SUPPRESSION_THRESHOLD = 0.85
@staticmethod
def _suppress_duplicate_candidates(candidates: list[dict[str, Any]], iou_threshold: float) -> list[dict[str, Any]]:
if iou_threshold <= 0 or len(candidates) < 2:
return candidates
ordered = sorted(
candidates,
key=lambda item: (-float(item["confidence"]), str(item.get("source_tile_path") or "")),
)
kept: list[dict[str, Any]] = []
for candidate in sorted(candidates, key=lambda item: float(item["confidence"]), reverse=True):
kept_geometries: list[Any] = []
tree = None
for candidate in ordered:
geometry = candidate["geometry"]
duplicate = False
for kept_candidate in kept:
# Only geometries that actually touch this candidate can suppress
# it, so an index keeps a dense AOI from turning into an O(n^2) scan.
neighbour_indexes = range(len(kept)) if tree is None else (int(index) for index in tree.query(geometry))
for index in neighbour_indexes:
kept_candidate = kept[index]
if candidate["class_name"] != kept_candidate["class_name"]:
continue
if DetectionService._geometry_iou(candidate["geometry"], kept_candidate["geometry"]) >= iou_threshold:
other = kept_geometries[index]
if DetectionService._geometry_iou(geometry, other) >= iou_threshold:
duplicate = True
break
if (
DetectionService._geometry_containment(geometry, other)
>= DetectionService.CONTAINMENT_SUPPRESSION_THRESHOLD
):
duplicate = True
break
if not duplicate:
kept.append(candidate)
kept_geometries.append(geometry)
tree = STRtree(kept_geometries)
return kept
@staticmethod
def _drop_tile_edge_truncations(
candidates: list[dict[str, Any]],
*,
raster_bounds: tuple[float, float, float, float] | None,
tolerance: float,
) -> list[dict[str, Any]]:
"""Discard boxes cut off by an interior tile edge.
Such a box describes only the part of the object that fell inside its
tile. Because tiles overlap, the neighbouring tile saw the object whole
and contributed the box worth keeping. A box against the outer raster
edge has no such neighbour and is kept.
"""
if raster_bounds is None or tolerance <= 0:
return candidates
raster_left, raster_bottom, raster_right, raster_top = raster_bounds
kept: list[dict[str, Any]] = []
for candidate in candidates:
tile_bounds = candidate.get("tile_bounds")
if not tile_bounds or len(tuple(tile_bounds)) != 4:
kept.append(candidate)
continue
tile_left, tile_bottom, tile_right, tile_top = (float(value) for value in tile_bounds)
left, bottom, right, top = candidate["geometry"].bounds
# A pixel-sized tolerance per tile: a fixed degree value would be
# wrong for both a 10 cm orthophoto and a coarse thematic raster.
tolerance = float(candidate.get("tile_edge_tolerance") or 0.0) or tolerance
touches_interior_edge = (
(abs(left - tile_left) <= tolerance and abs(tile_left - raster_left) > tolerance)
or (abs(right - tile_right) <= tolerance and abs(tile_right - raster_right) > tolerance)
or (abs(bottom - tile_bottom) <= tolerance and abs(tile_bottom - raster_bottom) > tolerance)
or (abs(top - tile_top) <= tolerance and abs(tile_top - raster_top) > tolerance)
)
if not touches_interior_edge:
kept.append(candidate)
return kept
@staticmethod
@@ -916,6 +1135,78 @@ class DetectionService:
return 0.0
return intersection_area / union_area
@staticmethod
def _geometry_containment(left, right) -> float:
"""Intersection over the smaller of the two areas."""
if left.is_empty or right.is_empty:
return 0.0
smaller_area = min(left.area, right.area)
if smaller_area <= 0:
return 0.0
intersection_area = left.intersection(right).area
if intersection_area <= 0:
return 0.0
return intersection_area / smaller_area
@staticmethod
def _require_manifest_crs(manifest: dict[str, Any]) -> str:
"""Refuse to georeference inference output against a guessed CRS.
Detection QA already rejects a tile without explicit CRS metadata.
Silently assuming EPSG:4326 on the inference side produced geometry
that looks plausible on a map but sits in the wrong place.
"""
raw_crs = manifest.get("crs") or manifest.get("source_crs") or manifest.get("dataset_crs")
if not isinstance(raw_crs, str) or not raw_crs.strip():
raise AppError(
code="DETECTION_TILE_MANIFEST_INVALID",
message="Raster tile manifest requires explicit CRS metadata for georeferencing",
status_code=422,
)
return raw_crs.strip()
@staticmethod
def _bounds_to_epsg4326(bounds: Any, crs: str | None) -> tuple[float, float, float, float] | None:
if not isinstance(bounds, (list, tuple)) or len(bounds) != 4:
return None
try:
left, bottom, right, top = (float(value) for value in bounds)
except (TypeError, ValueError):
return None
if left >= right or bottom >= top:
return None
if not crs or str(crs).strip().upper() in {"EPSG:4326", "4326"}:
return (left, bottom, right, top)
try:
transformer = Transformer.from_crs(crs, "EPSG:4326", always_xy=True)
# Transform the whole rectangle, not just two corners: a projected
# box does not stay axis-aligned after reprojection.
projected = shapely_transform(transformer.transform, shapely_box(left, bottom, right, top))
return projected.bounds
except Exception:
return None
@staticmethod
def _tile_edge_tolerance(tile: dict[str, Any], tile_bounds_4326: tuple[float, float, float, float] | None) -> float:
"""One and a half pixels, expressed in the degrees the boxes live in."""
if tile_bounds_4326 is None:
return 0.0
pixel_window = tile.get("pixel_window")
if not (isinstance(pixel_window, (list, tuple)) and len(pixel_window) == 4):
return 0.0
try:
width = float(pixel_window[2])
height = float(pixel_window[3])
except (TypeError, ValueError):
return 0.0
if width <= 0 or height <= 0:
return 0.0
left, bottom, right, top = tile_bounds_4326
return 1.5 * max((right - left) / width, (top - bottom) / height)
@staticmethod
def _load_tile_manifest(tile_manifest_path: str | None, max_tiles: int) -> dict[str, Any]:
if not tile_manifest_path:
+178 -23
View File
@@ -8,6 +8,7 @@ from typing import Any
from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import MultiPolygon, Polygon, mapping, shape
from shapely.validation import make_valid
from sqlalchemy import func
from app.core.config import Settings, get_settings
from app.core.errors import AppError
@@ -20,6 +21,7 @@ from app.schemas.segmentation import (
SegmentationRunResponse,
)
from app.services.detection_georeferencing import pixel_points_to_epsg4326_polygon
from app.services.detection_qa_service import DetectionQaService
from app.services.detection_service import DetectionService
from app.services.dataset_consumption_gate_service import DatasetConsumptionGate
from app.services.model_registry_service import ModelRegistryService
@@ -51,22 +53,11 @@ class SegmentationService:
settings: Settings | None = None,
yolo_seg_adapter_class: type[YoloSegmentationAdapter] = YoloSegmentationAdapter,
sam_adapter_class: type[SamSegmentationAdapter] = SamSegmentationAdapter,
existing_job: Job | None = None,
) -> SegmentationRunResponse:
parameters = dict(parameters_json or {})
resolved_settings = settings or get_settings()
project = db.get(Project, project_id)
if not project:
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
dataset = db.get(Dataset, dataset_id)
if not dataset or dataset.project_id != project_id:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
if dataset.dataset_type != "raster":
raise AppError(
code="INVALID_DATASET_TYPE",
message="Segmentation requires a raster dataset",
details={"dataset_type": dataset.dataset_type},
status_code=400,
)
dataset = SegmentationService._validate_run_request(db, project_id=project_id, dataset_id=dataset_id)
model = ModelRegistryService.get_model_capability(
model_id,
@@ -110,7 +101,7 @@ class SegmentationService:
"tile_manifest_path": tile_manifest_path,
"parameters_json": parameters,
}
job = SegmentationService._create_job(db, project_id, dataset_id, run_parameters)
job = SegmentationService._create_job(db, project_id, dataset_id, run_parameters, existing_job=existing_job)
analysis_run = SegmentationService._create_analysis_run(db, project_id, dataset_id, job.id, model, run_parameters)
if not model.configured:
@@ -374,16 +365,98 @@ class SegmentationService:
message="Segmentation run has no persisted geometries for QA",
status_code=422,
)
references = db.query(VectorFeature).filter(VectorFeature.dataset_id == reference_dataset_id).all()
if not references:
# Score against the footprint the model actually saw. Without this the
# whole reference dataset is the denominator for recall, and every
# building outside the inferred tiles is counted as a miss.
manifest_path = DetectionQaService.tile_manifest_path(run_parameters)
coverage = None
if manifest_path:
manifest = DetectionService._load_tile_manifest(manifest_path, get_settings().yolo_max_tiles)
coverage = DetectionQaService.build_tile_coverage(
manifest,
manifest_path=manifest_path,
expected_dataset_id=run.dataset_id,
)
reference_query = db.query(VectorFeature).filter(VectorFeature.dataset_id == reference_dataset_id)
if coverage is not None and hasattr(reference_query, "count"):
reference_raw_count = reference_query.count()
references = reference_query.filter(
func.ST_Intersects(VectorFeature.geometry, from_shape(coverage.geometry, srid=4326))
).all()
else:
references = reference_query.all()
reference_raw_count = len(references)
if reference_raw_count == 0:
raise AppError(
code="REFERENCE_FEATURES_NOT_FOUND",
message="Reference dataset has no persisted vector features for QA",
status_code=422,
)
candidate_geometries = [({"id": str(row.id), "class_name": row.class_name}, to_shape(row.geometry)) for row in segmentations]
reference_geometries = [({"id": str(row.id), "feature_class": row.feature_class}, to_shape(row.geometry)) for row in references]
raw_candidate_geometries = [
(
{"id": str(row.id), "class_name": row.class_name, "confidence": row.confidence},
to_shape(row.geometry),
)
for row in segmentations
]
raw_reference_geometries = [
({"id": str(row.id), "feature_class": row.feature_class}, to_shape(row.geometry)) for row in references
]
candidate_geometries = raw_candidate_geometries
reference_geometries = raw_reference_geometries
coverage_summary: dict[str, Any] = {
"applied": False,
"mode": "unbounded_no_manifest",
"manifest_path": None,
"tile_count": 0,
"source_crs_values": [],
"candidate_raw_count": len(raw_candidate_geometries),
"candidate_evaluated_count": len(raw_candidate_geometries),
"candidate_excluded_outside_count": 0,
"candidate_clipped_boundary_count": 0,
"reference_raw_count": reference_raw_count,
"reference_evaluated_count": len(raw_reference_geometries),
"reference_excluded_outside_count": 0,
"reference_clipped_boundary_count": 0,
}
coverage_warnings: list[str] = []
if coverage is not None:
candidate_population = DetectionQaService.filter_population(raw_candidate_geometries, coverage)
reference_population = DetectionQaService.filter_population(
raw_reference_geometries,
coverage,
raw_count=reference_raw_count,
)
candidate_geometries = candidate_population.geometries
reference_geometries = reference_population.geometries
if not reference_geometries:
raise AppError(
code="REFERENCE_FEATURES_OUTSIDE_COVERAGE",
message="Reference dataset has no polygon features inside persisted inference tile coverage",
status_code=422,
)
coverage_summary = {
"applied": True,
"mode": "persisted_tile_manifest_union",
"manifest_path": coverage.manifest_path,
"tile_count": coverage.tile_count,
"source_crs_values": list(coverage.source_crs_values),
"candidate_raw_count": candidate_population.raw_count,
"candidate_evaluated_count": candidate_population.evaluated_count,
"candidate_excluded_outside_count": candidate_population.excluded_outside_count,
"candidate_clipped_boundary_count": candidate_population.clipped_boundary_count,
"reference_raw_count": reference_population.raw_count,
"reference_evaluated_count": reference_population.evaluated_count,
"reference_excluded_outside_count": reference_population.excluded_outside_count,
"reference_clipped_boundary_count": reference_population.clipped_boundary_count,
}
coverage_warnings.append(
"QA populations were clipped to the union of persisted inference tile footprints before matching."
)
evidence = QaService._match_io_u_evidence(
candidate_geometries,
reference_geometries,
@@ -411,13 +484,15 @@ class SegmentationService:
"iou_threshold": iou_threshold,
"class_name": class_name,
"min_confidence": min_confidence,
"coverage_policy": coverage_summary["mode"],
},
findings={
"matches": evidence.matches,
"false_positives": evidence.false_positives,
"false_negatives": evidence.false_negatives,
"warnings": evidence.warnings,
"warnings": coverage_warnings + evidence.warnings,
"unsupported_geometry": evidence.unsupported,
"coverage": coverage_summary,
"match_evidence": evidence.match_evidence,
"false_positive_evidence": evidence.false_positive_evidence,
"false_negative_evidence": evidence.false_negative_evidence,
@@ -438,6 +513,8 @@ class SegmentationService:
"reference_dataset_id": str(reference_dataset_id),
"candidate_feature_count": len(candidate_geometries),
"reference_feature_count": len(reference_geometries),
"candidate_feature_count_raw": len(raw_candidate_geometries),
"reference_feature_count_raw": reference_raw_count,
"matches": evidence.matches,
"false_positives": evidence.false_positives,
"false_negatives": evidence.false_negatives,
@@ -446,7 +523,8 @@ class SegmentationService:
"f1_score": f1_score,
"mean_iou": mean_iou,
"iou_threshold": iou_threshold,
"warnings": evidence.warnings,
"warnings": coverage_warnings + evidence.warnings,
"coverage": coverage_summary,
"match_evidence": evidence.match_evidence,
"false_positive_evidence": evidence.false_positive_evidence,
"false_negative_evidence": evidence.false_negative_evidence,
@@ -458,7 +536,24 @@ class SegmentationService:
return (Path(storage_root) / "masks" / str(project_id) / str(analysis_run_id) / tile_folder / f"mask_{segmentation_id}.png").as_posix()
@staticmethod
def _create_job(db, project_id: uuid.UUID, dataset_id: uuid.UUID, parameters: dict[str, Any]) -> Job:
def _create_job(
db,
project_id: uuid.UUID,
dataset_id: uuid.UUID,
parameters: dict[str, Any],
existing_job: Job | None = None,
) -> Job:
if existing_job is not None:
# Reuse the queued job so the operator polls one identifier.
existing_job.status = "running"
existing_job.dataset_id = dataset_id
existing_job.input_dataset_id = dataset_id
existing_job.parameters_json = {**(existing_job.parameters_json or {}), **parameters}
existing_job.started_at = SegmentationService._now()
db.add(existing_job)
db.commit()
db.refresh(existing_job)
return existing_job
job = Job(
id=uuid.uuid4(),
job_type="segmentation.run",
@@ -474,6 +569,59 @@ class SegmentationService:
db.refresh(job)
return job
@staticmethod
def _validate_run_request(db, *, project_id: uuid.UUID, dataset_id: uuid.UUID) -> Dataset:
project = db.get(Project, project_id)
if not project:
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
dataset = db.get(Dataset, dataset_id)
if not dataset or dataset.project_id != project_id:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
if dataset.dataset_type != "raster":
raise AppError(
code="INVALID_DATASET_TYPE",
message="Segmentation requires a raster dataset",
details={"dataset_type": dataset.dataset_type},
status_code=400,
)
return dataset
@staticmethod
def enqueue_segmentation(
db,
project_id: uuid.UUID,
dataset_id: uuid.UUID,
model_id: str,
confidence_threshold: float,
class_filter: list[str] | None = None,
tile_manifest_path: str | None = None,
parameters_json: dict[str, Any] | None = None,
) -> Job:
"""Accept a segmentation run for background execution."""
SegmentationService._validate_run_request(db, project_id=project_id, dataset_id=dataset_id)
job = Job(
id=uuid.uuid4(),
job_type="segmentation.run",
status="queued",
project_id=project_id,
dataset_id=dataset_id,
input_dataset_id=dataset_id,
parameters_json={
"project_id": str(project_id),
"dataset_id": str(dataset_id),
"model_id": model_id,
"confidence_threshold": confidence_threshold,
"class_filter": class_filter or [],
"tile_manifest_path": tile_manifest_path,
"parameters_json": dict(parameters_json or {}),
},
)
db.add(job)
db.commit()
db.refresh(job)
return job
@staticmethod
def _create_analysis_run(db, project_id, dataset_id, job_id, model, parameters: dict[str, Any]) -> AnalysisRun:
analysis_run = AnalysisRun(
@@ -568,7 +716,7 @@ class SegmentationService:
model = adapter.load_model(model_path)
allowed_classes = {DetectionService._canonical_class_name(value) for value in class_filter if DetectionService._canonical_class_name(value)}
manifest_crs = manifest.get("crs") or manifest.get("source_crs") or manifest.get("dataset_crs") or "EPSG:4326"
manifest_crs = DetectionService._require_manifest_crs(manifest)
candidates: list[dict[str, Any]] = []
for tile in manifest["tiles"]:
tile_path = DetectionService._resolve_tile_path(tile, Path(tile_manifest_path or "").expanduser())
@@ -787,7 +935,14 @@ class SegmentationService:
query = query.filter(Segmentation.class_name == class_name)
if min_confidence is not None:
query = query.filter(Segmentation.confidence >= min_confidence)
return query.order_by(Segmentation.created_at.desc()).all()
# One transaction timestamp is shared by every row in a run, so
# ordering by it alone leaves the row order — and therefore the QA
# score — undefined. See DetectionService._query_detection_rows.
return query.order_by(
Segmentation.confidence.desc(),
Segmentation.created_at.desc(),
Segmentation.id.asc(),
).all()
@staticmethod
def _segmentation_properties(segmentation: Segmentation) -> dict[str, Any]:
+184 -46
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from contextlib import contextmanager
from contextlib import ExitStack, contextmanager
from pathlib import Path
import tempfile
from typing import Any
@@ -86,53 +86,90 @@ class YoloDetectionAdapter:
)
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict[str, Any]]:
if not tile_path.exists() or not tile_path.is_file():
raise AppError(
code="DETECTION_TILE_NOT_FOUND",
message="Tile referenced by manifest does not exist",
details={"tile_path": str(tile_path)},
status_code=422,
)
try:
with _prediction_source(tile_path) as prediction_source:
results = model.predict(
source=prediction_source,
conf=float(confidence_threshold),
imgsz=int(self.settings.yolo_image_size),
device=self.settings.yolo_device,
max_det=int(self.settings.yolo_max_detections),
verbose=False,
)
except AppError:
raise
except Exception as exc:
raise AppError(
code="DETECTION_INFERENCE_FAILED",
message="Configured YOLO inference failed for a raster tile",
details={"tile_path": str(tile_path), "error": str(exc)},
status_code=503,
) from exc
return self.predict_tiles(model, [tile_path], confidence_threshold)[0]
detections: list[dict[str, Any]] = []
for result in results:
names = getattr(result, "names", {}) or {}
boxes = getattr(result, "boxes", None)
if boxes is None:
continue
xyxy_values = _to_list(getattr(boxes, "xyxy", []))
confidence_values = _to_list(getattr(boxes, "conf", []))
class_values = _to_list(getattr(boxes, "cls", []))
for index, bbox in enumerate(xyxy_values):
class_id = int(class_values[index]) if index < len(class_values) else -1
detections.append(
{
"class_name": str(names.get(class_id, class_id)),
"confidence": float(confidence_values[index]) if index < len(confidence_values) else 0.0,
"bbox": [float(value) for value in bbox],
"properties": {"class_id": class_id},
}
def predict_tiles(
self,
model,
tile_paths: list[Path],
confidence_threshold: float,
) -> list[list[dict[str, Any]]]:
"""Run inference over several tiles per GPU call.
One ``predict`` call per tile leaves an RTX-class card mostly idle on a
run of a hundred tiles. Results are returned per tile, in the order the
tiles were given, so the caller can still georeference each detection
against its own tile transform.
"""
for tile_path in tile_paths:
if not tile_path.exists() or not tile_path.is_file():
raise AppError(
code="DETECTION_TILE_NOT_FOUND",
message="Tile referenced by manifest does not exist",
details={"tile_path": str(tile_path)},
status_code=422,
)
return detections
batch_size = max(1, int(self.settings.yolo_batch_size or 1))
detections_per_tile: list[list[dict[str, Any]]] = []
for start in range(0, len(tile_paths), batch_size):
batch = tile_paths[start : start + batch_size]
with ExitStack() as stack:
sources = [stack.enter_context(_prediction_source(path)) for path in batch]
try:
results = model.predict(
source=sources,
conf=float(confidence_threshold),
imgsz=int(self.settings.yolo_image_size),
device=self.settings.yolo_device,
max_det=int(self.settings.yolo_max_detections),
verbose=False,
)
except AppError:
raise
except Exception as exc:
raise AppError(
code="DETECTION_INFERENCE_FAILED",
message="Configured YOLO inference failed for a raster tile",
details={"tile_path": str(batch[0]), "error": str(exc)},
status_code=503,
) from exc
results = list(results)
for offset in range(len(batch)):
result = results[offset] if offset < len(results) else None
detections_per_tile.append(_detections_from_result(result))
return detections_per_tile
def _detections_from_result(result: Any) -> list[dict[str, Any]]:
"""Flatten one ultralytics result into the adapter's detection dicts."""
if result is None:
return []
names = getattr(result, "names", {}) or {}
boxes = getattr(result, "boxes", None)
if boxes is None:
return []
xyxy_values = _to_list(getattr(boxes, "xyxy", []))
confidence_values = _to_list(getattr(boxes, "conf", []))
class_values = _to_list(getattr(boxes, "cls", []))
detections: list[dict[str, Any]] = []
for index, bbox in enumerate(xyxy_values):
class_id = int(class_values[index]) if index < len(class_values) else -1
detections.append(
{
"class_name": str(names.get(class_id, class_id)),
"confidence": float(confidence_values[index]) if index < len(confidence_values) else 0.0,
"bbox": [float(value) for value in bbox],
"properties": {"class_id": class_id},
}
)
return detections
def _to_list(value: Any) -> list[Any]:
@@ -147,8 +184,95 @@ def _to_list(value: Any) -> list[Any]:
return list(value)
def _rgb_band_indexes(dataset: Any) -> list[int]:
"""Pick the three bands that carry visible colour, in R, G, B order.
Belgian orthophoto tiles are commonly 4-band RGB + near-infrared. Taking
bands blindly would feed the detector an infrared channel as if it were
colour, so an explicit colour interpretation wins when the raster has one.
"""
count = int(getattr(dataset, "count", 0) or 0)
if count <= 0:
raise ValueError("Raster tile has no bands")
if count == 1:
return [1, 1, 1]
try:
from rasterio.enums import ColorInterp
interpretations = list(getattr(dataset, "colorinterp", ()) or ())
wanted = (ColorInterp.red, ColorInterp.green, ColorInterp.blue)
if all(interpretation in interpretations for interpretation in wanted):
return [interpretations.index(interpretation) + 1 for interpretation in wanted]
except Exception:
pass
if count == 2:
return [1, 1, 1]
return [1, 2, 3]
def _stretch_to_uint8(data: Any, valid: Any) -> Any:
"""Scale a (bands, H, W) array to 0-255 with one shared percentile stretch.
``uint8`` data is already display-ready and is passed through untouched;
inventing a stretch for it would change pixel values the model was trained
on. Anything wider (12-bit and 16-bit orthophotos, float reflectance) would
otherwise be truncated to near-black by a plain dtype cast.
The stretch bounds are computed over all bands together, not per band.
A per-band stretch white-balances the tile and shifts every hue, while the
detector learned on ordinary RGB orthophotos.
"""
import numpy as np
if data.dtype == np.uint8:
return data
if valid is not None and valid.any():
sample = data[:, valid].reshape(-1)
else:
sample = data.reshape(-1)
if sample.size == 0:
return np.zeros(data.shape, dtype=np.uint8)
low, high = (float(value) for value in np.percentile(sample.astype("float64"), (2.0, 98.0)))
if not high > low:
low, high = float(sample.min()), float(sample.max())
if not high > low:
return np.full(data.shape, 0 if low == 0 else 255, dtype=np.uint8)
scaled = (data.astype("float64") - low) * (255.0 / (high - low))
return np.clip(scaled, 0.0, 255.0).astype(np.uint8)
def _read_tile_as_rgb(tile_path: Path) -> Any:
"""Read a raster tile into an (H, W, 3) uint8 array fit for inference."""
import numpy as np
import rasterio
with rasterio.open(tile_path) as dataset:
indexes = _rgb_band_indexes(dataset)
raw = dataset.read(indexes, masked=True)
data = np.ma.getdata(raw)
mask = np.ma.getmaskarray(raw)
valid = ~mask.any(axis=0)
rgb = np.moveaxis(_stretch_to_uint8(data, valid), 0, -1)
# Nodata collars stay black instead of dragging the stretch toward zero.
rgb = np.ascontiguousarray(rgb)
rgb[~valid] = 0
return rgb
@contextmanager
def _prediction_source(tile_path: Path) -> Iterator[str]:
"""Yield a path to an 8-bit RGB rendering of ``tile_path`` for the model."""
temp_path: Path | None = None
try:
try:
@@ -157,6 +281,20 @@ def _prediction_source(tile_path: Path) -> Iterator[str]:
yield str(tile_path)
return
try:
rgb = _read_tile_as_rgb(tile_path)
except Exception:
rgb = None
if rgb is not None:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as handle:
temp_path = Path(handle.name)
Image.fromarray(rgb).save(temp_path)
yield str(temp_path)
return
# rasterio is unavailable or cannot read this file (a plain PNG/JPEG
# fixture, for instance). Fall back to the previous PIL handling.
try:
with Image.open(tile_path) as image:
if image.mode == "RGB" and len(image.getbands()) == 3: