9194ef5abd
Docs/Test Workflow / Test docs build (push) Failing after 0s
Check links & references / links-check (push) Failing after 1s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.10) (push) Failing after 0s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.11) (push) Failing after 0s
PR Conflict Labeler / main (push) Failing after 2s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.12) (push) Failing after 2s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.13) (push) Failing after 0s
Pytest/Test Workflow / Build this Package (push) Failing after 5s
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / testing-guardian (push) Has been cancelled
370 lines
12 KiB
Python
370 lines
12 KiB
Python
"""Tests for windowed GeoTIFF reads in InferenceSlicer."""
|
|
|
|
import threading
|
|
import time
|
|
from collections.abc import Callable
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from supervision.detection.core import Detections
|
|
from supervision.detection.tools.inference_slicer import InferenceSlicer
|
|
|
|
|
|
class _FakeCRS:
|
|
"""Minimal rasterio-style CRS stub exposing only `is_projected`."""
|
|
|
|
def __init__(self, is_projected: bool):
|
|
self.is_projected = is_projected
|
|
|
|
def __repr__(self) -> str:
|
|
kind = "projected" if self.is_projected else "geographic"
|
|
return f"_FakeCRS({kind})"
|
|
|
|
|
|
class _FakeRasterDataset:
|
|
"""Lightweight rasterio-style dataset supporting windowed reads.
|
|
|
|
Mimics the duck-typed interface that ``InferenceSlicer`` relies on without
|
|
requiring ``rasterio`` to be installed.
|
|
"""
|
|
|
|
def __init__(self, image_hwc: np.ndarray, crs: object | None = None):
|
|
self._image = image_hwc # numpy (H, W, C)
|
|
self.height, self.width = image_hwc.shape[:2]
|
|
self.crs = crs # None or object with .is_projected
|
|
|
|
def read(self, window: tuple[tuple[int, int], tuple[int, int]]) -> np.ndarray:
|
|
(row_start, row_stop), (col_start, col_stop) = window
|
|
crop = self._image[row_start:row_stop, col_start:col_stop, :]
|
|
return np.transpose(crop, (2, 0, 1)) # (C, H, W) like rasterio
|
|
|
|
|
|
class _ConcurrencyCheckDataset:
|
|
"""Dataset that tracks peak concurrent reads to verify read serialization."""
|
|
|
|
def __init__(self, image_hwc: np.ndarray):
|
|
self._image = image_hwc
|
|
self.height, self.width = image_hwc.shape[:2]
|
|
self.crs = None
|
|
self._lock = threading.Lock()
|
|
self._active = 0
|
|
self.peak_concurrent = 0
|
|
|
|
def read(self, window: tuple[tuple[int, int], tuple[int, int]]) -> np.ndarray:
|
|
with self._lock:
|
|
self._active += 1
|
|
self.peak_concurrent = max(self.peak_concurrent, self._active)
|
|
time.sleep(0.002) # amplify race window so concurrent reads are detectable
|
|
(row_start, row_stop), (col_start, col_stop) = window
|
|
crop = self._image[row_start:row_stop, col_start:col_stop, :]
|
|
result = np.transpose(crop, (2, 0, 1))
|
|
with self._lock:
|
|
self._active -= 1
|
|
return result
|
|
|
|
|
|
def _sortable(detections: Detections) -> np.ndarray:
|
|
"""Sort detection boxes so two runs can be compared order-independently."""
|
|
return np.array(sorted(detections.xyxy.tolist()), dtype=float)
|
|
|
|
|
|
@pytest.fixture
|
|
def make_raster_dataset() -> Callable[..., _FakeRasterDataset]:
|
|
"""Factory: create a _FakeRasterDataset from a numpy image array."""
|
|
|
|
def factory(image_hwc: np.ndarray, crs: object | None = None) -> _FakeRasterDataset:
|
|
return _FakeRasterDataset(image_hwc, crs=crs)
|
|
|
|
return factory
|
|
|
|
|
|
@pytest.fixture
|
|
def fixed_detection_callback() -> Callable[[np.ndarray], Detections]:
|
|
"""Return a constant single-box detection for every tile."""
|
|
|
|
def callback(_: np.ndarray) -> Detections:
|
|
return Detections(
|
|
xyxy=np.array([[0, 0, 10, 10]], dtype=float),
|
|
confidence=np.array([0.9]),
|
|
class_id=np.array([0]),
|
|
)
|
|
|
|
return callback
|
|
|
|
|
|
@pytest.fixture
|
|
def make_recording_callback() -> Callable[[list[np.ndarray]], Callable]:
|
|
"""Factory: given a sink list, return a callback that records each tile."""
|
|
|
|
def factory(sink: list[np.ndarray]) -> Callable[[np.ndarray], Detections]:
|
|
def callback(tile: np.ndarray) -> Detections:
|
|
sink.append(tile.copy())
|
|
return Detections.empty()
|
|
|
|
return callback
|
|
|
|
return factory
|
|
|
|
|
|
class TestInferenceSlicerGeoTIFF:
|
|
def test_windowed_raster_matches_in_memory_array(
|
|
self,
|
|
make_raster_dataset: Callable,
|
|
fixed_detection_callback: Callable,
|
|
) -> None:
|
|
"""Raster and array paths produce identical merged detections."""
|
|
# Arrange
|
|
rng = np.random.default_rng(42)
|
|
image = rng.integers(0, 255, size=(256, 256, 3), dtype=np.uint8)
|
|
dataset = make_raster_dataset(image, crs=_FakeCRS(is_projected=True))
|
|
slicer = InferenceSlicer(
|
|
callback=fixed_detection_callback,
|
|
slice_wh=128,
|
|
overlap_wh=0,
|
|
)
|
|
|
|
# Act
|
|
detections_array = slicer(image)
|
|
detections_raster = slicer(dataset)
|
|
|
|
# Assert
|
|
assert np.array_equal(_sortable(detections_array), _sortable(detections_raster))
|
|
|
|
@pytest.mark.parametrize(
|
|
("seed", "image_shape", "slice_wh", "overlap_wh"),
|
|
[
|
|
pytest.param(7, (128, 192, 3), 64, 0, id="no-overlap"),
|
|
pytest.param(99, (200, 220, 3), 96, 32, id="with-overlap"),
|
|
],
|
|
)
|
|
def test_raster_tiles_match_array_tiles(
|
|
self,
|
|
make_raster_dataset: Callable,
|
|
make_recording_callback: Callable,
|
|
seed: int,
|
|
image_shape: tuple[int, int, int],
|
|
slice_wh: int,
|
|
overlap_wh: int,
|
|
) -> None:
|
|
"""Windowed raster read returns identical pixel tiles as array path."""
|
|
# Arrange
|
|
rng = np.random.default_rng(seed)
|
|
image = rng.integers(0, 255, size=image_shape, dtype=np.uint8)
|
|
dataset = make_raster_dataset(image)
|
|
|
|
array_tiles: list[np.ndarray] = []
|
|
raster_tiles: list[np.ndarray] = []
|
|
|
|
slicer_array = InferenceSlicer(
|
|
callback=make_recording_callback(array_tiles),
|
|
slice_wh=slice_wh,
|
|
overlap_wh=overlap_wh,
|
|
)
|
|
slicer_raster = InferenceSlicer(
|
|
callback=make_recording_callback(raster_tiles),
|
|
slice_wh=slice_wh,
|
|
overlap_wh=overlap_wh,
|
|
)
|
|
|
|
# Act
|
|
slicer_array(image)
|
|
slicer_raster(dataset)
|
|
|
|
# Assert
|
|
assert len(array_tiles) == len(raster_tiles) > 0
|
|
for array_tile, raster_tile in zip(array_tiles, raster_tiles):
|
|
assert np.array_equal(array_tile, raster_tile)
|
|
|
|
def test_windowed_raster_preserves_band_dtype(
|
|
self, make_raster_dataset: Callable
|
|
) -> None:
|
|
"""Tiles read from a dataset keep the source dtype (e.g. uint16)."""
|
|
# Arrange
|
|
rng = np.random.default_rng(5)
|
|
image = rng.integers(0, 4000, size=(128, 128, 3), dtype=np.uint16)
|
|
dataset = make_raster_dataset(image)
|
|
|
|
seen: list[np.ndarray] = []
|
|
|
|
def callback(tile: np.ndarray) -> Detections:
|
|
seen.append(tile)
|
|
return Detections.empty()
|
|
|
|
slicer = InferenceSlicer(callback=callback, slice_wh=64, overlap_wh=0)
|
|
|
|
# Act
|
|
slicer(dataset)
|
|
|
|
# Assert
|
|
assert seen
|
|
assert all(tile.dtype == np.uint16 for tile in seen)
|
|
|
|
@pytest.mark.parametrize(
|
|
"crs",
|
|
[
|
|
pytest.param(None, id="no-crs"),
|
|
pytest.param(_FakeCRS(is_projected=True), id="projected-crs"),
|
|
],
|
|
)
|
|
def test_crs_allows_slicing(
|
|
self,
|
|
make_raster_dataset: Callable,
|
|
fixed_detection_callback: Callable,
|
|
crs: object | None,
|
|
) -> None:
|
|
"""None CRS and projected CRS both allow slicing without error."""
|
|
image = np.zeros((128, 128, 3), dtype=np.uint8)
|
|
dataset = make_raster_dataset(image, crs=crs)
|
|
slicer = InferenceSlicer(
|
|
callback=fixed_detection_callback,
|
|
slice_wh=64,
|
|
overlap_wh=0,
|
|
)
|
|
|
|
detections = slicer(dataset)
|
|
|
|
assert len(detections) == 4
|
|
|
|
def test_geographic_crs_raises(
|
|
self,
|
|
make_raster_dataset: Callable,
|
|
fixed_detection_callback: Callable,
|
|
) -> None:
|
|
"""Dataset with a geographic (non-projected) CRS raises ValueError."""
|
|
image = np.zeros((128, 128, 3), dtype=np.uint8)
|
|
dataset = make_raster_dataset(image, crs=_FakeCRS(is_projected=False))
|
|
slicer = InferenceSlicer(
|
|
callback=fixed_detection_callback,
|
|
slice_wh=64,
|
|
overlap_wh=0,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="projected coordinate reference"):
|
|
slicer(dataset)
|
|
|
|
def test_single_band_raster_produces_hwc1_tiles(
|
|
self, make_raster_dataset: Callable
|
|
) -> None:
|
|
"""Single-band raster tiles arrive at the callback as (H, W, 1) arrays."""
|
|
image = np.zeros((128, 128, 1), dtype=np.uint8)
|
|
dataset = make_raster_dataset(image)
|
|
|
|
seen: list[np.ndarray] = []
|
|
|
|
def callback(tile: np.ndarray) -> Detections:
|
|
seen.append(tile)
|
|
return Detections.empty()
|
|
|
|
slicer = InferenceSlicer(callback=callback, slice_wh=64, overlap_wh=0)
|
|
|
|
slicer(dataset)
|
|
|
|
assert seen
|
|
assert all(tile.ndim == 3 and tile.shape[2] == 1 for tile in seen)
|
|
|
|
def test_raster_smaller_than_slice_produces_single_tile(
|
|
self, make_raster_dataset: Callable
|
|
) -> None:
|
|
"""Raster smaller than slice_wh is processed as exactly one tile."""
|
|
image = np.zeros((48, 64, 3), dtype=np.uint8)
|
|
dataset = make_raster_dataset(image)
|
|
|
|
tile_count: list[int] = [0]
|
|
|
|
def callback(tile: np.ndarray) -> Detections:
|
|
tile_count[0] += 1
|
|
return Detections.empty()
|
|
|
|
slicer = InferenceSlicer(callback=callback, slice_wh=128, overlap_wh=0)
|
|
|
|
slicer(dataset)
|
|
|
|
assert tile_count[0] == 1
|
|
|
|
def test_compact_masks_with_windowed_raster(
|
|
self, make_raster_dataset: Callable
|
|
) -> None:
|
|
"""compact_masks=True correctly moves and compresses masks from raster tiles."""
|
|
rng = np.random.default_rng(17)
|
|
image = rng.integers(0, 255, size=(128, 128, 3), dtype=np.uint8)
|
|
dataset = make_raster_dataset(image)
|
|
|
|
def masked_callback(tile: np.ndarray) -> Detections:
|
|
h, w = tile.shape[:2]
|
|
mask = np.zeros((1, h, w), dtype=bool)
|
|
mask[0, : h // 2, : w // 2] = True
|
|
return Detections(
|
|
xyxy=np.array([[0, 0, w // 2, h // 2]], dtype=float),
|
|
confidence=np.array([0.9]),
|
|
class_id=np.array([0]),
|
|
mask=mask,
|
|
)
|
|
|
|
slicer = InferenceSlicer(
|
|
callback=masked_callback,
|
|
slice_wh=64,
|
|
overlap_wh=0,
|
|
compact_masks=True,
|
|
)
|
|
|
|
detections = slicer(dataset)
|
|
|
|
assert len(detections) > 0
|
|
|
|
def test_thread_workers_with_raster_serializes_reads(
|
|
self, fixed_detection_callback: Callable
|
|
) -> None:
|
|
"""Raster reads are serialized even when thread_workers > 1."""
|
|
rng = np.random.default_rng(3)
|
|
image = rng.integers(0, 255, size=(256, 256, 3), dtype=np.uint8)
|
|
dataset = _ConcurrencyCheckDataset(image)
|
|
|
|
slicer = InferenceSlicer(
|
|
callback=fixed_detection_callback,
|
|
slice_wh=64,
|
|
overlap_wh=0,
|
|
thread_workers=4,
|
|
)
|
|
|
|
slicer(dataset)
|
|
|
|
assert dataset.peak_concurrent == 1
|
|
|
|
def test_real_rasterio_memoryfile_integration(
|
|
self, fixed_detection_callback: Callable
|
|
) -> None:
|
|
"""Real rasterio MemoryFile produces same detections as the array path."""
|
|
pytest.importorskip("rasterio")
|
|
from rasterio.io import MemoryFile
|
|
|
|
# Arrange
|
|
rng = np.random.default_rng(123)
|
|
image = rng.integers(0, 255, size=(128, 128, 3), dtype=np.uint8)
|
|
bands = np.transpose(image, (2, 0, 1)) # (C, H, W)
|
|
|
|
slicer = InferenceSlicer(
|
|
callback=fixed_detection_callback,
|
|
slice_wh=64,
|
|
overlap_wh=0,
|
|
)
|
|
detections_array = slicer(image)
|
|
|
|
profile = {
|
|
"driver": "GTiff",
|
|
"height": image.shape[0],
|
|
"width": image.shape[1],
|
|
"count": image.shape[2],
|
|
"dtype": image.dtype,
|
|
}
|
|
|
|
# Act
|
|
with MemoryFile() as memfile:
|
|
with memfile.open(**profile) as dst:
|
|
dst.write(bands)
|
|
with memfile.open() as dataset:
|
|
detections_raster = slicer(dataset)
|
|
|
|
# Assert
|
|
assert np.array_equal(_sortable(detections_array), _sortable(detections_raster))
|