Files
geointel/backend/app/services/yolo_adapter.py
T
JensandClaude Opus 5 08188005bd 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>
2026-08-22 14:32:44 +02:00

318 lines
11 KiB
Python

from __future__ import annotations
from contextlib import ExitStack, contextmanager
from pathlib import Path
import tempfile
from typing import Any
from collections.abc import Iterator
from app.core.config import Settings
from app.core.errors import AppError
class YoloDetectionAdapter:
def __init__(self, settings: Settings) -> None:
self.settings = settings
@staticmethod
def dependencies_available() -> bool:
try:
import torch # noqa: F401
import ultralytics # noqa: F401
except Exception:
return False
return True
def load_model(self, model_path: Path):
if not model_path.exists() or not model_path.is_file():
raise AppError(
code="DETECTION_MODEL_UNAVAILABLE",
message="Configured YOLO model file does not exist",
details={"model_path": str(model_path)},
status_code=503,
)
if not self.dependencies_available():
raise AppError(
code="DETECTION_DEPENDENCY_UNAVAILABLE",
message="YOLO dependencies are not installed. Install backend optional extras with geointel-backend[ai].",
status_code=503,
)
self.validate_runtime()
try:
from ultralytics import YOLO
except ImportError as exc:
raise AppError(
code="DETECTION_DEPENDENCY_UNAVAILABLE",
message="YOLO dependencies are not importable. Install backend optional extras with geointel-backend[ai].",
status_code=503,
) from exc
try:
return YOLO(str(model_path))
except Exception as exc:
raise AppError(
code="DETECTION_MODEL_LOAD_FAILED",
message="Configured YOLO model could not be loaded",
details={"model_path": str(model_path)},
status_code=503,
) from exc
def validate_runtime(self) -> None:
if not self.settings.yolo_require_cuda:
return
try:
import torch
except Exception as exc:
raise AppError(
code="DETECTION_ACCELERATOR_UNAVAILABLE",
message="NVIDIA CUDA is required for configured YOLO inference, but PyTorch is not importable.",
status_code=503,
) from exc
if not torch.cuda.is_available():
raise AppError(
code="DETECTION_ACCELERATOR_UNAVAILABLE",
message="NVIDIA CUDA is required for configured YOLO inference, but no CUDA device is available.",
details={"configured_device": self.settings.yolo_device},
status_code=503,
)
if not str(self.settings.yolo_device).lower().startswith(("cuda", "0", "1", "2", "3")):
raise AppError(
code="DETECTION_ACCELERATOR_MISCONFIGURED",
message="NVIDIA CUDA is required, but YOLO_DEVICE does not select a CUDA device.",
details={"configured_device": self.settings.yolo_device},
status_code=503,
)
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict[str, Any]]:
return self.predict_tiles(model, [tile_path], confidence_threshold)[0]
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,
)
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]:
if hasattr(value, "detach"):
value = value.detach()
if hasattr(value, "cpu"):
value = value.cpu()
if hasattr(value, "numpy"):
value = value.numpy()
if hasattr(value, "tolist"):
return value.tolist()
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:
from PIL import Image
except Exception:
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:
yield str(tile_path)
return
rgb_image = image.convert("RGB")
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as handle:
temp_path = Path(handle.name)
rgb_image.save(temp_path)
yield str(temp_path)
return
except Exception:
if temp_path is not None:
raise
yield str(tile_path)
return
finally:
if temp_path is not None:
temp_path.unlink(missing_ok=True)