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
+176
View File
@@ -0,0 +1,176 @@
"""Tiled GPU inference must not run inside an HTTP request.
A configured YOLO run walks up to ``YOLO_MAX_TILES`` tiles through the GPU.
Doing that in the request handler holds a worker thread for minutes, gives the
operator no progress, and times the client out before the result exists. The
run is queued as a Job instead and executed by a background worker, which is
the same pattern the AOI operations already use.
"""
from __future__ import annotations
from uuid import uuid4
import pytest
from app.core.errors import AppError
from app.models import AnalysisRun, Detection, Job
from app.services.analysis_job_worker import AnalysisJobWorker
from app.services.detection_service import DetectionService
class FakeQuery:
def __init__(self, rows):
self.rows = list(rows)
def filter(self, *criteria):
return self
def order_by(self, *_args):
return self
def limit(self, count):
self.rows = self.rows[:count]
return self
def all(self):
return list(self.rows)
class FakeSession:
def __init__(self, objects=None, query_rows=None):
self.objects = dict(objects or {})
self.query_rows = query_rows or {}
self.added = []
def get(self, model, item_id):
return self.objects.get((model, item_id))
def query(self, model):
return FakeQuery(self.query_rows.get(model, []))
def add(self, item):
self.added.append(item)
if getattr(item, "id", None) is not None:
self.objects[(item.__class__, item.id)] = item
def commit(self):
return None
def rollback(self):
return None
def refresh(self, _item):
return None
def close(self):
return None
def _queued_job(**parameters) -> Job:
payload = {
"project_id": str(uuid4()),
"dataset_id": str(uuid4()),
"model_id": "yolo-configured",
"confidence_threshold": 0.4,
"class_filter": ["building"],
"tile_manifest_path": "/tiles/manifest.json",
"parameters_json": {},
}
payload.update(parameters)
return Job(
id=uuid4(),
job_type="detection.run",
status="queued",
project_id=uuid4(),
parameters_json=payload,
)
def test_queued_detection_job_is_dispatched_to_the_detection_service(monkeypatch) -> None:
job = _queued_job()
db = FakeSession(objects={(Job, job.id): job}, query_rows={Job: [job]})
calls: list[dict] = []
def fake_run(**kwargs):
calls.append(kwargs)
return type(
"Result",
(),
{
"status": "success",
"detection_count": 3,
"analysis_run_id": uuid4(),
"job_id": kwargs["existing_job"].id,
"model_dump": lambda self, **_: {"status": "success", "detection_count": 3},
},
)()
monkeypatch.setattr(DetectionService, "run_detection", staticmethod(fake_run))
processed = AnalysisJobWorker.run_once(db=db)
assert processed == 1
assert calls[0]["model_id"] == "yolo-configured"
assert calls[0]["confidence_threshold"] == 0.4
assert calls[0]["tile_manifest_path"] == "/tiles/manifest.json"
assert calls[0]["existing_job"] is job
assert job.status == "success"
def test_a_failing_run_marks_the_job_failed_instead_of_leaving_it_running(monkeypatch) -> None:
job = _queued_job()
db = FakeSession(objects={(Job, job.id): job}, query_rows={Job: [job]})
def exploding(**_kwargs):
raise AppError(code="DETECTION_TILE_NOT_FOUND", message="missing tile", status_code=422)
monkeypatch.setattr(DetectionService, "run_detection", staticmethod(exploding))
processed = AnalysisJobWorker.run_once(db=db)
assert processed == 1
assert job.status == "failed"
assert job.error_message == "missing tile"
assert job.result_json["error_code"] == "DETECTION_TILE_NOT_FOUND"
def test_an_unexpected_error_still_closes_the_job(monkeypatch) -> None:
job = _queued_job()
db = FakeSession(objects={(Job, job.id): job}, query_rows={Job: [job]})
def exploding(**_kwargs):
raise RuntimeError("CUDA out of memory")
monkeypatch.setattr(DetectionService, "run_detection", staticmethod(exploding))
AnalysisJobWorker.run_once(db=db)
assert job.status == "failed"
assert job.result_json["error_code"] == "ANALYSIS_JOB_INTERNAL_ERROR"
def test_job_types_the_worker_does_not_own_are_left_alone() -> None:
job = _queued_job()
job.job_type = "raster.clip"
db = FakeSession(objects={(Job, job.id): job}, query_rows={Job: [job]})
assert AnalysisJobWorker.run_once(db=db) == 0
assert job.status == "queued"
def test_enqueue_validates_before_accepting_the_job() -> None:
"""A bad request is rejected up front, not minutes later in the worker."""
db = FakeSession()
with pytest.raises(AppError) as exc_info:
DetectionService.enqueue_detection(
db=db,
project_id=uuid4(),
dataset_id=uuid4(),
model_id="yolo-configured",
confidence_threshold=0.4,
)
assert exc_info.value.code == "PROJECT_NOT_FOUND"
@@ -0,0 +1,73 @@
"""Inference must refuse to guess a CRS.
Detection QA rejects a tile without explicit CRS metadata, but the inference
side silently assumed EPSG:4326. That produced geometry that renders as a
plausible polygon in the wrong place, which is worse than a clear failure:
"fail closed" is the stated rule for the runtime.
"""
from __future__ import annotations
import pytest
from app.core.errors import AppError
from app.services.detection_georeferencing import (
pixel_bbox_to_epsg4326_polygon,
pixel_points_to_epsg4326_polygon,
)
from app.services.detection_service import DetectionService
TILE_WITHOUT_CRS = {
"bounds": [4.0, 51.0, 5.0, 52.0],
"pixel_window": [0, 0, 100, 100],
}
def test_manifest_without_crs_is_rejected() -> None:
with pytest.raises(AppError) as exc_info:
DetectionService._require_manifest_crs({"tiles": [{"bounds": [0, 0, 1, 1]}]})
assert exc_info.value.code == "DETECTION_TILE_MANIFEST_INVALID"
def test_manifest_crs_is_read_from_any_of_the_documented_keys() -> None:
assert DetectionService._require_manifest_crs({"crs": "EPSG:31370"}) == "EPSG:31370"
assert DetectionService._require_manifest_crs({"source_crs": "EPSG:31370"}) == "EPSG:31370"
assert DetectionService._require_manifest_crs({"dataset_crs": "EPSG:3812"}) == "EPSG:3812"
def test_bbox_georeferencing_requires_an_explicit_crs() -> None:
with pytest.raises(AppError) as exc_info:
pixel_bbox_to_epsg4326_polygon(bbox=[0.0, 0.0, 10.0, 10.0], tile=TILE_WITHOUT_CRS)
assert exc_info.value.code == "DETECTION_TILE_CRS_REQUIRED"
def test_mask_georeferencing_requires_an_explicit_crs() -> None:
with pytest.raises(AppError) as exc_info:
pixel_points_to_epsg4326_polygon(
points=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], tile=TILE_WITHOUT_CRS
)
assert exc_info.value.code == "DETECTION_TILE_CRS_REQUIRED"
def test_explicit_crs_on_the_tile_is_used() -> None:
tile = {**TILE_WITHOUT_CRS, "crs": "EPSG:4326"}
polygon = pixel_bbox_to_epsg4326_polygon(bbox=[0.0, 0.0, 50.0, 50.0], tile=tile)
assert polygon.bounds == pytest.approx((4.0, 51.5, 4.5, 52.0))
def test_projected_bounds_are_reprojected_as_a_whole_rectangle() -> None:
# Lambert 72 around Mol. All four corners must be transformed, otherwise a
# rotated footprint is understated.
bounds = DetectionService._bounds_to_epsg4326([200000.0, 200000.0, 201000.0, 201000.0], "EPSG:31370")
assert bounds is not None
min_x, min_y, max_x, max_y = bounds
assert 4.0 < min_x < 6.0
assert 50.0 < min_y < 52.0
assert max_x > min_x and max_y > min_y
@@ -0,0 +1,125 @@
"""Detections that straddle a tile seam must not become two half buildings.
Tiling uses a fixed overlap. An object wider than that overlap is truncated by
both tiles, so the two boxes barely intersect and plain IoU suppression keeps
them both: two false positives plus one missed footprint for every seam
building. The suppressor therefore also compares overlap against the smaller
box, and truncated boxes that sit against an interior tile edge are dropped in
favour of the neighbouring tile's complete view.
"""
from __future__ import annotations
from shapely.geometry import box
from app.services.detection_service import DetectionService
def _candidate(name: str, geometry, confidence: float, *, tile_index: int = 0, tile_bounds=None):
return {
"class_name": "building",
"confidence": confidence,
"geometry": geometry,
"bbox": [0.0, 0.0, 1.0, 1.0],
"source_tile_path": f"/tiles/tile_{tile_index:04d}.tif",
"properties": {"tile_index": tile_index, "name": name},
"tile_bounds": tile_bounds,
}
def test_identical_overlapping_predictions_are_still_suppressed() -> None:
kept = DetectionService._suppress_duplicate_candidates(
[
_candidate("a", box(0.0, 0.0, 1.0, 1.0), 0.7),
_candidate("b", box(0.02, 0.02, 1.02, 1.02), 0.9),
],
iou_threshold=0.5,
)
assert [item["properties"]["name"] for item in kept] == ["b"]
def test_a_box_contained_in_a_larger_one_is_suppressed() -> None:
"""A truncated seam half sits inside the complete box from the next tile."""
complete = box(0.0, 0.0, 10.0, 10.0)
truncated_half = box(0.0, 0.0, 4.0, 10.0) # IoU with ``complete`` is 0.4
kept = DetectionService._suppress_duplicate_candidates(
[
_candidate("complete", complete, 0.88),
_candidate("truncated", truncated_half, 0.61),
],
iou_threshold=0.5,
)
assert [item["properties"]["name"] for item in kept] == ["complete"]
def test_genuinely_adjacent_buildings_are_both_kept() -> None:
"""Terraced houses touch but do not contain one another."""
kept = DetectionService._suppress_duplicate_candidates(
[
_candidate("left", box(0.0, 0.0, 10.0, 10.0), 0.9),
_candidate("right", box(10.0, 0.0, 20.0, 10.0), 0.85),
],
iou_threshold=0.5,
)
assert sorted(item["properties"]["name"] for item in kept) == ["left", "right"]
def test_different_classes_are_never_merged() -> None:
first = _candidate("a", box(0.0, 0.0, 10.0, 10.0), 0.9)
second = _candidate("b", box(0.0, 0.0, 10.0, 10.0), 0.8)
second["class_name"] = "solar_panel"
kept = DetectionService._suppress_duplicate_candidates([first, second], iou_threshold=0.5)
assert len(kept) == 2
def test_boxes_clipped_by_an_interior_tile_edge_are_dropped() -> None:
"""The overlapping neighbour tile still sees the whole object."""
tile = box(0.0, 0.0, 10.0, 10.0)
raster = box(0.0, 0.0, 30.0, 10.0)
candidates = [
# Sits against the tile's right edge: truncated by the tile, not real.
_candidate("edge", box(9.0, 2.0, 10.0, 4.0), 0.8, tile_bounds=tile.bounds),
# Comfortably inside the tile.
_candidate("interior", box(2.0, 2.0, 4.0, 4.0), 0.8, tile_bounds=tile.bounds),
]
kept = DetectionService._drop_tile_edge_truncations(
candidates, raster_bounds=raster.bounds, tolerance=0.001
)
assert [item["properties"]["name"] for item in kept] == ["interior"]
def test_boxes_against_the_raster_edge_are_kept() -> None:
"""No neighbouring tile exists there, so the box is all the evidence there is."""
tile = box(0.0, 0.0, 10.0, 10.0)
raster = box(0.0, 0.0, 10.0, 10.0)
candidates = [_candidate("edge", box(9.0, 2.0, 10.0, 4.0), 0.8, tile_bounds=tile.bounds)]
kept = DetectionService._drop_tile_edge_truncations(
candidates, raster_bounds=raster.bounds, tolerance=0.001
)
assert [item["properties"]["name"] for item in kept] == ["edge"]
def test_edge_filter_keeps_candidates_without_tile_bounds() -> None:
candidates = [_candidate("unknown", box(2.0, 2.0, 4.0, 4.0), 0.8, tile_bounds=None)]
kept = DetectionService._drop_tile_edge_truncations(
candidates, raster_bounds=(0.0, 0.0, 30.0, 10.0), tolerance=0.001
)
assert len(kept) == 1
@@ -50,6 +50,10 @@ class MockYoloAdapter:
def load_model(self, model_path: Path):
return {"model_path": str(model_path)}
def predict_tiles(self, model, tile_paths, confidence_threshold: float) -> list[list[dict]]:
# The service batches tiles; this double still answers per tile.
return [self.predict_tile(model, tile_path, confidence_threshold) for tile_path in tile_paths]
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]:
assert model["model_path"].endswith("building-detector.pt")
return [
@@ -119,12 +123,15 @@ def _manifest(tmp_path: Path) -> Path:
{
"tile_set_id": "tiles-fixture",
"count": 1,
"crs": "EPSG:4326",
"bounds": [4.0, 51.0, 5.0, 52.0],
"tiles": [
{
"path": str(tile_path),
"pixel_window": [0, 0, 100, 100],
"bounds": [4.0, 51.0, 5.0, 52.0],
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
"crs": "EPSG:4326",
"index": 0,
}
],
@@ -257,6 +257,7 @@ def _manifest(tmp_path: Path, tile_count: int = 1) -> Path:
"pixel_window": [0, 0, 100, 100],
"bounds": [4.0, 51.0, 5.0, 52.0],
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
"crs": "EPSG:4326",
"index": index,
}
)
@@ -267,6 +268,8 @@ def _manifest(tmp_path: Path, tile_count: int = 1) -> Path:
"tile_set_id": "tiles-fixture",
"source_dataset_id": str(uuid4()),
"source_raster_id": str(uuid4()),
"crs": "EPSG:4326",
"bounds": [4.0, 51.0, 5.0, 52.0],
"tile_size": 100,
"overlap": 0,
"count": tile_count,
@@ -0,0 +1,168 @@
"""Segmentation QA must score against the area it actually inferred.
Detection QA already clips both populations to the union of the persisted
inference tiles. Segmentation QA compared candidates against every reference
feature in the dataset, so every building outside the inferred tiles counted
as a false negative and recall collapsed for no modelling reason.
"""
from __future__ import annotations
import json
from pathlib import Path
from uuid import uuid4
import pytest
from geoalchemy2.shape import from_shape
from shapely.geometry import MultiPolygon, box
from app.core.errors import AppError
from app.models import AnalysisRun, Dataset, Segmentation, VectorFeature
from app.services.segmentation_service import SegmentationService
from tests.test_sprint9_segmentation_foundation import ( # noqa: F401
FakeSession,
_authoritative_reference,
)
def _manifest(tmp_path: Path, dataset_id, bounds: list[float]) -> str:
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text(
json.dumps(
{
"source_dataset_id": str(dataset_id),
"crs": "EPSG:4326",
"tiles": [{"path": "tile_0000.tif", "bounds": bounds, "crs": "EPSG:4326"}],
}
),
encoding="utf-8",
)
return str(manifest_path)
def _segmentation(project_id, dataset_id, analysis_run_id, geom):
return Segmentation(
id=uuid4(),
project_id=project_id,
dataset_id=dataset_id,
analysis_run_id=analysis_run_id,
job_id=uuid4(),
model_name="fixture-segmenter",
model_version="fixture-v1",
class_name="building",
confidence=0.9,
geometry=from_shape(MultiPolygon([geom]), srid=4326),
)
def _reference(dataset_id, geom) -> VectorFeature:
return VectorFeature(
id=uuid4(),
dataset_id=dataset_id,
feature_class="building",
geometry=from_shape(geom, srid=4326),
)
def _session(tmp_path: Path, *, with_manifest: bool):
project_id = uuid4()
dataset_id = uuid4()
reference_dataset_id = uuid4()
analysis_run_id = uuid4()
parameters = {}
if with_manifest:
parameters = {"tile_manifest_path": _manifest(tmp_path, dataset_id, [0.0, 0.0, 1.0, 1.0])}
reference_dataset = _authoritative_reference(
Dataset(
id=reference_dataset_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="test",
dataset_role="reference",
)
)
db = FakeSession(
objects={
(AnalysisRun, analysis_run_id): AnalysisRun(
id=analysis_run_id,
project_id=project_id,
dataset_id=dataset_id,
analysis_type="segmentation",
status="success",
parameters_json=parameters,
),
(Dataset, dataset_id): Dataset(
id=dataset_id, project_id=project_id, name="fixture.tif", dataset_type="raster", source="test"
),
(Dataset, reference_dataset_id): reference_dataset,
},
query_rows={
Segmentation: [_segmentation(project_id, dataset_id, analysis_run_id, box(0.1, 0.1, 0.2, 0.2))],
VectorFeature: [
# Inside the inferred tile: a genuine match.
_reference(reference_dataset_id, box(0.1, 0.1, 0.2, 0.2)),
# Far outside it: never looked at by the model.
_reference(reference_dataset_id, box(8.0, 8.0, 8.1, 8.1)),
_reference(reference_dataset_id, box(9.0, 9.0, 9.1, 9.1)),
],
},
)
return db, analysis_run_id, reference_dataset_id
def test_segmentation_qa_scores_only_inside_persisted_tile_coverage(tmp_path: Path) -> None:
db, analysis_run_id, reference_dataset_id = _session(tmp_path, with_manifest=True)
result = SegmentationService.compare_segmentations_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=reference_dataset_id,
iou_threshold=0.5,
)
assert result["matches"] == 1
assert result["false_negatives"] == 0
assert result["recall"] == 1.0
assert result["coverage"]["applied"] is True
assert result["coverage"]["reference_raw_count"] == 3
assert result["coverage"]["reference_evaluated_count"] == 1
assert result["coverage"]["reference_excluded_outside_count"] == 2
assert any("tile" in warning for warning in result["warnings"])
def test_segmentation_qa_without_manifest_reports_unbounded_coverage(tmp_path: Path) -> None:
db, analysis_run_id, reference_dataset_id = _session(tmp_path, with_manifest=False)
result = SegmentationService.compare_segmentations_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=reference_dataset_id,
iou_threshold=0.5,
)
# Unchanged behaviour, but the response now says the score was not bounded
# by an inference footprint so the recall can be read correctly.
assert result["false_negatives"] == 2
assert result["coverage"]["applied"] is False
assert result["coverage"]["mode"] == "unbounded_no_manifest"
def test_segmentation_qa_rejects_reference_entirely_outside_coverage(tmp_path: Path) -> None:
db, analysis_run_id, reference_dataset_id = _session(tmp_path, with_manifest=True)
db.query_rows[VectorFeature] = [
_reference(reference_dataset_id, box(8.0, 8.0, 8.1, 8.1)),
]
with pytest.raises(AppError) as exc_info:
SegmentationService.compare_segmentations_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=reference_dataset_id,
iou_threshold=0.5,
)
assert exc_info.value.code == "REFERENCE_FEATURES_OUTSIDE_COVERAGE"
+22 -13
View File
@@ -71,6 +71,10 @@ class MockYoloAdapter:
self.loaded_model_path = model_path
return object()
def predict_tiles(self, model, tile_paths, confidence_threshold: float) -> list[list[dict]]:
# The service batches tiles; this double still answers per tile.
return [self.predict_tile(model, tile_path, confidence_threshold) for tile_path in tile_paths]
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]:
assert tile_path.name == "tile_0000.tif"
assert confidence_threshold == 0.5
@@ -130,19 +134,21 @@ class RecordingPredictModel:
def predict(self, *, source, conf, imgsz, device, verbose, max_det):
from PIL import Image
with Image.open(source) as image:
self.seen_sources.append(
{
"path": str(source),
"mode": image.mode,
"bands": len(image.getbands()),
"conf": conf,
"imgsz": imgsz,
"device": device,
"verbose": verbose,
"max_det": max_det,
}
)
# Tiles are handed to the model in batches, so ``source`` is a list.
for item in source if isinstance(source, list) else [source]:
with Image.open(item) as image:
self.seen_sources.append(
{
"path": str(item),
"mode": image.mode,
"bands": len(image.getbands()),
"conf": conf,
"imgsz": imgsz,
"device": device,
"verbose": verbose,
"max_det": max_det,
}
)
return []
@@ -311,6 +317,7 @@ def _manifest(tmp_path: Path, tile_count: int = 1) -> Path:
"pixel_window": [0, 0, 100, 100],
"bounds": [4.0, 51.0, 5.0, 52.0],
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
"crs": "EPSG:4326",
"index": index,
}
)
@@ -321,6 +328,8 @@ def _manifest(tmp_path: Path, tile_count: int = 1) -> Path:
"tile_set_id": "tiles-fixture",
"source_dataset_id": str(uuid4()),
"source_raster_id": str(uuid4()),
"crs": "EPSG:4326",
"bounds": [4.0, 51.0, 5.0, 52.0],
"tile_size": 100,
"overlap": 0,
"count": tile_count,
@@ -290,6 +290,11 @@ def test_detection_qa_persists_quality_check_and_metrics() -> None:
"mean_iou",
"false_positive_count",
"false_negative_count",
# Threshold-independent metrics, so two models can be compared without
# both having to be read at the same confidence cut.
"average_precision",
"best_f1",
"best_f1_threshold",
]
@@ -0,0 +1,104 @@
"""Tiles must reach the GPU in batches.
``YOLO_BATCH_SIZE`` existed in the settings but nothing read it: every tile was
a separate ``model.predict`` call plus a separate temporary PNG. On an RTX-class
card that leaves most of the throughput unused for a run of a hundred tiles.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from app.core.errors import AppError
from app.services.yolo_adapter import YoloDetectionAdapter
class RecordingModel:
def __init__(self) -> None:
self.batches: list[list[str]] = []
def predict(self, *, source, conf, imgsz, device, verbose, max_det):
self.batches.append(list(source) if isinstance(source, list) else [source])
return []
def _settings(tmp_path: Path, **overrides):
from app.core.config import Settings
values = {
"yolo_model_path": str(tmp_path / "model.pt"),
"yolo_device": "cpu",
"yolo_image_size": 64,
"yolo_max_detections": 1000,
"yolo_require_cuda": False,
"yolo_batch_size": 4,
}
values.update(overrides)
return Settings(**values)
def _tiles(tmp_path: Path, count: int) -> list[Path]:
Image = pytest.importorskip("PIL.Image")
paths = []
for index in range(count):
path = tmp_path / f"tile_{index:04d}.png"
Image.new("RGB", (16, 16), (index, 20, 30)).save(path)
paths.append(path)
return paths
def test_tiles_are_predicted_in_configured_batches(tmp_path: Path) -> None:
tiles = _tiles(tmp_path, 9)
model = RecordingModel()
YoloDetectionAdapter(_settings(tmp_path, yolo_batch_size=4)).predict_tiles(model, tiles, 0.25)
assert [len(batch) for batch in model.batches] == [4, 4, 1]
def test_batch_size_one_still_works(tmp_path: Path) -> None:
tiles = _tiles(tmp_path, 3)
model = RecordingModel()
YoloDetectionAdapter(_settings(tmp_path, yolo_batch_size=1)).predict_tiles(model, tiles, 0.25)
assert [len(batch) for batch in model.batches] == [1, 1, 1]
def test_results_are_returned_per_tile_in_order(tmp_path: Path) -> None:
tiles = _tiles(tmp_path, 3)
class PerTileModel:
def predict(self, *, source, conf, imgsz, device, verbose, max_det):
sources = list(source) if isinstance(source, list) else [source]
return [_FakeResult(index) for index, _ in enumerate(sources)]
results = YoloDetectionAdapter(_settings(tmp_path)).predict_tiles(PerTileModel(), tiles, 0.25)
assert len(results) == 3
assert [len(detections) for detections in results] == [1, 1, 1]
def test_a_missing_tile_is_reported_before_the_batch_runs(tmp_path: Path) -> None:
tiles = _tiles(tmp_path, 2) + [tmp_path / "absent.png"]
with pytest.raises(AppError) as exc_info:
YoloDetectionAdapter(_settings(tmp_path)).predict_tiles(RecordingModel(), tiles, 0.25)
assert exc_info.value.code == "DETECTION_TILE_NOT_FOUND"
class _FakeBoxes:
xyxy = [[0.0, 0.0, 4.0, 4.0]]
conf = [0.9]
cls = [0]
class _FakeResult:
names = {0: "building"}
boxes = _FakeBoxes()
def __init__(self, _index: int) -> None:
pass
@@ -0,0 +1,123 @@
"""Orthophoto tiles must reach the model as a faithful 8-bit RGB image.
Belgian orthophoto products are routinely 16-bit and/or 4-band (RGB + NIR).
Handing those to ``PIL.Image.convert("RGB")`` truncates the high byte, so a
bright roof arrives as a near-black pixel and the detector sees nothing that
resembles its training data. The tile is read with rasterio instead, the RGB
bands are selected explicitly and the values are percentile-stretched.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
from app.services.yolo_adapter import _prediction_source
rasterio = pytest.importorskip("rasterio")
Image = pytest.importorskip("PIL.Image")
def _write_tile(path: Path, array: np.ndarray, dtype: str) -> None:
count, height, width = array.shape
with rasterio.open(
path,
"w",
driver="GTiff",
width=width,
height=height,
count=count,
dtype=dtype,
) as dataset:
dataset.write(array.astype(dtype))
def _prepared(tile_path: Path) -> np.ndarray:
with _prediction_source(tile_path) as source:
with Image.open(source) as image:
assert image.mode == "RGB"
return np.array(image)
def test_uint16_tile_keeps_its_contrast_instead_of_going_black(tmp_path: Path) -> None:
# A typical 12-bit-in-16-bit orthophoto: values well below 65535.
array = np.zeros((3, 32, 32), dtype=np.uint16)
array[0] = 800
array[1] = 1600
array[2] = 3200
array[:, 0, 0] = 40 # a dark corner so the stretch has a low anchor
tile_path = tmp_path / "uint16.tif"
_write_tile(tile_path, array, "uint16")
prepared = _prepared(tile_path)
assert prepared.shape == (32, 32, 3)
# Naive 16->8 bit truncation would map 800/1600/3200 to near zero.
assert prepared.max() > 200
# The three bands stay distinguishable rather than collapsing together.
assert prepared[16, 16, 0] < prepared[16, 16, 1] < prepared[16, 16, 2]
def test_four_band_rgbi_tile_drops_the_infrared_band(tmp_path: Path) -> None:
array = np.zeros((4, 16, 16), dtype=np.uint8)
array[0] = 10
array[1] = 120
array[2] = 240
array[3] = 255 # near-infrared must not be treated as an alpha or a colour
tile_path = tmp_path / "rgbi.tif"
_write_tile(tile_path, array, "uint8")
prepared = _prepared(tile_path)
assert prepared.shape == (16, 16, 3)
assert prepared[8, 8, 0] < prepared[8, 8, 1] < prepared[8, 8, 2]
def test_single_band_tile_is_replicated_across_rgb(tmp_path: Path) -> None:
array = np.full((1, 16, 16), 128, dtype=np.uint8)
array[0, 0, 0] = 0
array[0, 15, 15] = 255
tile_path = tmp_path / "gray.tif"
_write_tile(tile_path, array, "uint8")
prepared = _prepared(tile_path)
assert prepared.shape == (16, 16, 3)
assert prepared[8, 8, 0] == prepared[8, 8, 1] == prepared[8, 8, 2]
def test_eight_bit_rgb_tile_is_passed_through_unchanged(tmp_path: Path) -> None:
array = np.zeros((3, 16, 16), dtype=np.uint8)
array[0] = 10
array[1] = 120
array[2] = 240
tile_path = tmp_path / "rgb.tif"
_write_tile(tile_path, array, "uint8")
prepared = _prepared(tile_path)
# Already display-ready: no stretch should be invented for it.
assert prepared[8, 8].tolist() == [10, 120, 240]
def test_nodata_pixels_do_not_drive_the_stretch(tmp_path: Path) -> None:
array = np.full((3, 32, 32), 2000, dtype=np.uint16)
array[:, :4, :] = 0 # nodata collar from a clipped orthophoto
tile_path = tmp_path / "nodata.tif"
with rasterio.open(
tile_path,
"w",
driver="GTiff",
width=32,
height=32,
count=3,
dtype="uint16",
nodata=0,
) as dataset:
dataset.write(array)
prepared = _prepared(tile_path)
assert prepared[16, 16].tolist() != [0, 0, 0]