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>
124 lines
3.8 KiB
Python
124 lines
3.8 KiB
Python
"""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]
|