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>
126 lines
4.3 KiB
Python
126 lines
4.3 KiB
Python
"""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
|