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
719 lines
24 KiB
Python
719 lines
24 KiB
Python
import threading
|
|
import warnings
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from supervision.config import ORIENTED_BOX_COORDINATES
|
|
from supervision.detection.core import Detections
|
|
from supervision.detection.tools.inference_slicer import (
|
|
InferenceSlicer,
|
|
move_detections,
|
|
)
|
|
from supervision.detection.utils.iou_and_nms import OverlapFilter
|
|
from supervision.utils.internal import SupervisionWarnings
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_callback():
|
|
"""Mock callback function for testing."""
|
|
|
|
def callback(_: np.ndarray) -> Detections:
|
|
return Detections(xyxy=np.array([[0, 0, 10, 10]]))
|
|
|
|
return callback
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("resolution_wh", "slice_wh", "overlap_wh", "expected_offsets"),
|
|
[
|
|
# Case 1: Square image, square slices, no overlap
|
|
(
|
|
(256, 256),
|
|
(128, 128),
|
|
(0, 0),
|
|
np.array(
|
|
[
|
|
[0, 0, 128, 128],
|
|
[128, 0, 256, 128],
|
|
[0, 128, 128, 256],
|
|
[128, 128, 256, 256],
|
|
]
|
|
),
|
|
),
|
|
# Case 2: Square image, square slices, non-zero overlap
|
|
(
|
|
(256, 256),
|
|
(128, 128),
|
|
(64, 64),
|
|
np.array(
|
|
[
|
|
[0, 0, 128, 128],
|
|
[64, 0, 192, 128],
|
|
[128, 0, 256, 128],
|
|
[0, 64, 128, 192],
|
|
[64, 64, 192, 192],
|
|
[128, 64, 256, 192],
|
|
[0, 128, 128, 256],
|
|
[64, 128, 192, 256],
|
|
[128, 128, 256, 256],
|
|
]
|
|
),
|
|
),
|
|
# Case 3: Rectangle image (horizontal), square slices, no overlap
|
|
(
|
|
(192, 128),
|
|
(64, 64),
|
|
(0, 0),
|
|
np.array(
|
|
[
|
|
[0, 0, 64, 64],
|
|
[64, 0, 128, 64],
|
|
[128, 0, 192, 64],
|
|
[0, 64, 64, 128],
|
|
[64, 64, 128, 128],
|
|
[128, 64, 192, 128],
|
|
]
|
|
),
|
|
),
|
|
# Case 4: Rectangle image (horizontal), square slices, non-zero overlap
|
|
(
|
|
(192, 128),
|
|
(64, 64),
|
|
(32, 32),
|
|
np.array(
|
|
[
|
|
[0, 0, 64, 64],
|
|
[32, 0, 96, 64],
|
|
[64, 0, 128, 64],
|
|
[96, 0, 160, 64],
|
|
[128, 0, 192, 64],
|
|
[0, 32, 64, 96],
|
|
[32, 32, 96, 96],
|
|
[64, 32, 128, 96],
|
|
[96, 32, 160, 96],
|
|
[128, 32, 192, 96],
|
|
[0, 64, 64, 128],
|
|
[32, 64, 96, 128],
|
|
[64, 64, 128, 128],
|
|
[96, 64, 160, 128],
|
|
[128, 64, 192, 128],
|
|
]
|
|
),
|
|
),
|
|
# Case 5: Rectangle image (vertical), square slices, no overlap
|
|
(
|
|
(128, 192),
|
|
(64, 64),
|
|
(0, 0),
|
|
np.array(
|
|
[
|
|
[0, 0, 64, 64],
|
|
[64, 0, 128, 64],
|
|
[0, 64, 64, 128],
|
|
[64, 64, 128, 128],
|
|
[0, 128, 64, 192],
|
|
[64, 128, 128, 192],
|
|
]
|
|
),
|
|
),
|
|
# Case 6: Rectangle image (vertical), square slices, non-zero overlap
|
|
(
|
|
(128, 192),
|
|
(64, 64),
|
|
(32, 32),
|
|
np.array(
|
|
[
|
|
[0, 0, 64, 64],
|
|
[32, 0, 96, 64],
|
|
[64, 0, 128, 64],
|
|
[0, 32, 64, 96],
|
|
[32, 32, 96, 96],
|
|
[64, 32, 128, 96],
|
|
[0, 64, 64, 128],
|
|
[32, 64, 96, 128],
|
|
[64, 64, 128, 128],
|
|
[0, 96, 64, 160],
|
|
[32, 96, 96, 160],
|
|
[64, 96, 128, 160],
|
|
[0, 128, 64, 192],
|
|
[32, 128, 96, 192],
|
|
[64, 128, 128, 192],
|
|
]
|
|
),
|
|
),
|
|
# Case 7: Square image, rectangular slices (horizontal), no overlap
|
|
(
|
|
(160, 160),
|
|
(80, 40),
|
|
(0, 0),
|
|
np.array(
|
|
[
|
|
[0, 0, 80, 40],
|
|
[80, 0, 160, 40],
|
|
[0, 40, 80, 80],
|
|
[80, 40, 160, 80],
|
|
[0, 80, 80, 120],
|
|
[80, 80, 160, 120],
|
|
[0, 120, 80, 160],
|
|
[80, 120, 160, 160],
|
|
]
|
|
),
|
|
),
|
|
# Case 8: Square image, rectangular slices (vertical), non-zero overlap
|
|
(
|
|
(160, 160),
|
|
(40, 80),
|
|
(10, 20),
|
|
np.array(
|
|
[
|
|
[0, 0, 40, 80],
|
|
[30, 0, 70, 80],
|
|
[60, 0, 100, 80],
|
|
[90, 0, 130, 80],
|
|
[120, 0, 160, 80],
|
|
[0, 60, 40, 140],
|
|
[30, 60, 70, 140],
|
|
[60, 60, 100, 140],
|
|
[90, 60, 130, 140],
|
|
[120, 60, 160, 140],
|
|
[0, 80, 40, 160],
|
|
[30, 80, 70, 160],
|
|
[60, 80, 100, 160],
|
|
[90, 80, 130, 160],
|
|
[120, 80, 160, 160],
|
|
]
|
|
),
|
|
),
|
|
],
|
|
)
|
|
def test_generate_offset(
|
|
resolution_wh: tuple[int, int],
|
|
slice_wh: tuple[int, int],
|
|
overlap_wh: tuple[int, int],
|
|
expected_offsets: np.ndarray,
|
|
) -> None:
|
|
offsets = InferenceSlicer._generate_offset(
|
|
resolution_wh=resolution_wh,
|
|
slice_wh=slice_wh,
|
|
overlap_wh=overlap_wh,
|
|
)
|
|
|
|
assert np.array_equal(offsets, expected_offsets), (
|
|
f"Expected {expected_offsets}, got {offsets}"
|
|
)
|
|
|
|
|
|
def test_run_callback_warns_when_detections_outside_slice_bounds() -> None:
|
|
"""Test that a warning is emitted when callback returns detections with
|
|
coordinates outside the slice bounds."""
|
|
|
|
def out_of_bounds_callback(_: np.ndarray) -> Detections:
|
|
# Return detections with coordinates exceeding the 64x64 slice size
|
|
return Detections(
|
|
xyxy=np.array([[0, 0, 128, 128]], dtype=float),
|
|
confidence=np.array([0.9]),
|
|
class_id=np.array([0]),
|
|
)
|
|
|
|
image = np.zeros((128, 128, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(callback=out_of_bounds_callback, slice_wh=64, overlap_wh=0)
|
|
|
|
with pytest.warns(SupervisionWarnings, match="outside the slice bounds"):
|
|
slicer(image)
|
|
|
|
|
|
def test_run_callback_warns_only_once_for_out_of_bounds_detections() -> None:
|
|
"""Test that the out-of-bounds warning is only emitted once even across
|
|
multiple slices."""
|
|
|
|
def out_of_bounds_callback(_: np.ndarray) -> Detections:
|
|
return Detections(
|
|
xyxy=np.array([[0, 0, 128, 128]], dtype=float),
|
|
confidence=np.array([0.9]),
|
|
class_id=np.array([0]),
|
|
)
|
|
|
|
image = np.zeros((256, 256, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(callback=out_of_bounds_callback, slice_wh=64, overlap_wh=0)
|
|
|
|
with warnings.catch_warnings(record=True) as recorded_warnings:
|
|
warnings.simplefilter("always")
|
|
slicer(image)
|
|
|
|
out_of_bounds_warnings = [
|
|
w
|
|
for w in recorded_warnings
|
|
if issubclass(w.category, SupervisionWarnings)
|
|
and "outside the slice bounds" in str(w.message)
|
|
]
|
|
assert len(out_of_bounds_warnings) == 1
|
|
|
|
|
|
def test_run_callback_no_warning_when_detections_inside_slice_bounds() -> None:
|
|
"""Test that no warning is emitted when callback returns detections within
|
|
the slice bounds."""
|
|
|
|
def in_bounds_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]),
|
|
)
|
|
|
|
image = np.zeros((128, 128, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(callback=in_bounds_callback, slice_wh=64, overlap_wh=0)
|
|
|
|
with warnings.catch_warnings(record=True) as recorded_warnings:
|
|
warnings.simplefilter("always")
|
|
slicer(image)
|
|
|
|
out_of_bounds_warnings = [
|
|
w
|
|
for w in recorded_warnings
|
|
if issubclass(w.category, SupervisionWarnings)
|
|
and "outside the slice bounds" in str(w.message)
|
|
]
|
|
assert len(out_of_bounds_warnings) == 0
|
|
|
|
|
|
def test_run_callback_warns_when_detections_have_negative_coordinates() -> None:
|
|
"""Test that a warning is emitted when callback returns detections with
|
|
negative coordinates, indicating wrong reference frame."""
|
|
|
|
def negative_coords_callback(_: np.ndarray) -> Detections:
|
|
# Return detections with negative coordinates (e.g., returned in full-image
|
|
# coordinates that are to the left/top of this slice's origin)
|
|
return Detections(
|
|
xyxy=np.array([[-10, -10, 10, 10]], dtype=float),
|
|
confidence=np.array([0.9]),
|
|
class_id=np.array([0]),
|
|
)
|
|
|
|
image = np.zeros((128, 128, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(
|
|
callback=negative_coords_callback, slice_wh=64, overlap_wh=0
|
|
)
|
|
|
|
with pytest.warns(SupervisionWarnings, match="outside the slice bounds"):
|
|
slicer(image)
|
|
|
|
|
|
def test_run_callback_warns_only_once_with_multiple_threads() -> None:
|
|
"""Test that exactly one warning fires even with thread_workers > 1, validating
|
|
that the threading.Lock makes the check-and-set atomic."""
|
|
|
|
def out_of_bounds_callback(_: np.ndarray) -> Detections:
|
|
return Detections(
|
|
xyxy=np.array([[0, 0, 128, 128]], dtype=float),
|
|
confidence=np.array([0.9]),
|
|
class_id=np.array([0]),
|
|
)
|
|
|
|
# 512x512 / 64 slice -> 64 slices; all 4 threads will see out-of-bounds detections
|
|
image = np.zeros((512, 512, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(
|
|
callback=out_of_bounds_callback,
|
|
slice_wh=64,
|
|
overlap_wh=0,
|
|
thread_workers=4,
|
|
)
|
|
|
|
with warnings.catch_warnings(record=True) as recorded_warnings:
|
|
warnings.simplefilter("always")
|
|
slicer(image)
|
|
|
|
out_of_bounds_warnings = [
|
|
w
|
|
for w in recorded_warnings
|
|
if issubclass(w.category, SupervisionWarnings)
|
|
and "outside the slice bounds" in str(w.message)
|
|
]
|
|
assert len(out_of_bounds_warnings) == 1
|
|
|
|
|
|
def test_run_callback_no_warning_for_detection_exactly_at_slice_boundary() -> None:
|
|
"""Test that a detection whose coordinates exactly equal the slice dimensions
|
|
does not trigger the warning (boundary is exclusive: > not >=)."""
|
|
|
|
def at_boundary_callback(_: np.ndarray) -> Detections:
|
|
# x2=64, y2=64 on a 64x64 slice — touching the edge but not exceeding it
|
|
return Detections(
|
|
xyxy=np.array([[0, 0, 64, 64]], dtype=float),
|
|
confidence=np.array([0.9]),
|
|
class_id=np.array([0]),
|
|
)
|
|
|
|
image = np.zeros((128, 128, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(callback=at_boundary_callback, slice_wh=64, overlap_wh=0)
|
|
|
|
with warnings.catch_warnings(record=True) as recorded_warnings:
|
|
warnings.simplefilter("always")
|
|
slicer(image)
|
|
|
|
out_of_bounds_warnings = [
|
|
w
|
|
for w in recorded_warnings
|
|
if issubclass(w.category, SupervisionWarnings)
|
|
and "outside the slice bounds" in str(w.message)
|
|
]
|
|
assert len(out_of_bounds_warnings) == 0
|
|
|
|
|
|
def test_run_callback_does_not_rewarn_on_second_call() -> None:
|
|
"""Test that a second call to the same slicer instance does not re-emit
|
|
the out-of-bounds warning even when detections are still out of bounds."""
|
|
|
|
def out_of_bounds_callback(_: np.ndarray) -> Detections:
|
|
return Detections(
|
|
xyxy=np.array([[0, 0, 128, 128]], dtype=float),
|
|
confidence=np.array([0.9]),
|
|
class_id=np.array([0]),
|
|
)
|
|
|
|
image = np.zeros((128, 128, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(callback=out_of_bounds_callback, slice_wh=64, overlap_wh=0)
|
|
|
|
with warnings.catch_warnings(record=True) as recorded_warnings:
|
|
warnings.simplefilter("always")
|
|
slicer(image) # first call — warning fires
|
|
slicer(image) # second call — must not re-warn
|
|
|
|
out_of_bounds_warnings = [
|
|
w
|
|
for w in recorded_warnings
|
|
if issubclass(w.category, SupervisionWarnings)
|
|
and "outside the slice bounds" in str(w.message)
|
|
]
|
|
assert len(out_of_bounds_warnings) == 1
|
|
|
|
|
|
def test_obb_callbacks_run_sequentially_even_with_multiple_workers() -> None:
|
|
"""Test that OBB callbacks are serialized even when thread_workers > 1."""
|
|
|
|
active_calls = 0
|
|
max_active_calls = 0
|
|
concurrent_callbacks = 0
|
|
callback_lock = threading.Lock()
|
|
|
|
def obb_callback(_: np.ndarray) -> Detections:
|
|
nonlocal active_calls, max_active_calls, concurrent_callbacks
|
|
|
|
with callback_lock:
|
|
active_calls += 1
|
|
max_active_calls = max(max_active_calls, active_calls)
|
|
if active_calls > 1:
|
|
concurrent_callbacks += 1
|
|
|
|
with callback_lock:
|
|
active_calls -= 1
|
|
|
|
return Detections(
|
|
xyxy=np.array([[0, 0, 10, 10]], dtype=float),
|
|
confidence=np.array([0.9]),
|
|
class_id=np.array([0]),
|
|
data={
|
|
ORIENTED_BOX_COORDINATES: np.array(
|
|
[[[0, 0], [10, 0], [10, 10], [0, 10]]], dtype=float
|
|
)
|
|
},
|
|
)
|
|
|
|
image = np.zeros((128, 128, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(
|
|
callback=obb_callback,
|
|
slice_wh=64,
|
|
overlap_wh=0,
|
|
thread_workers=4,
|
|
)
|
|
|
|
with pytest.warns(SupervisionWarnings, match="oriented bounding boxes"):
|
|
detections = slicer(image)
|
|
|
|
assert max_active_calls == 1
|
|
assert concurrent_callbacks == 0
|
|
assert len(detections) == 4
|
|
|
|
|
|
def _rotated_rect(
|
|
cx: float, cy: float, w: float, h: float, angle_deg: float
|
|
) -> np.ndarray:
|
|
angle = np.deg2rad(angle_deg)
|
|
cos, sin = np.cos(angle), np.sin(angle)
|
|
rot = np.array([[cos, -sin], [sin, cos]])
|
|
corners = np.array(
|
|
[[-w / 2, -h / 2], [w / 2, -h / 2], [w / 2, h / 2], [-w / 2, h / 2]]
|
|
)
|
|
return (corners @ rot.T + [cx, cy]).astype(np.float32)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"overlap_filter",
|
|
[OverlapFilter.NON_MAX_SUPPRESSION, OverlapFilter.NON_MAX_MERGE],
|
|
)
|
|
def test_inference_slicer_keeps_crossed_obb_detections(
|
|
overlap_filter: OverlapFilter,
|
|
) -> None:
|
|
"""Regression for issue #1679: the SAHI workflow with OBB detections
|
|
dropped valid detections at the merge step because `with_nms`/`with_nmm`
|
|
historically used axis-aligned IoU. For crossed thin rectangles the AABBs
|
|
are nearly identical (IoU ≈ 1.0) while the OBBs barely overlap (IoU ≈ 0.06)
|
|
— so AABB-NMS suppressed one of them.
|
|
|
|
Both crossed OBBs must survive end-to-end through `InferenceSlicer`.
|
|
"""
|
|
quad_a = _rotated_rect(50, 50, 80, 8, +45)
|
|
quad_b = _rotated_rect(50, 50, 80, 8, -45)
|
|
aabb_a = [
|
|
quad_a[:, 0].min(),
|
|
quad_a[:, 1].min(),
|
|
quad_a[:, 0].max(),
|
|
quad_a[:, 1].max(),
|
|
]
|
|
aabb_b = [
|
|
quad_b[:, 0].min(),
|
|
quad_b[:, 1].min(),
|
|
quad_b[:, 0].max(),
|
|
quad_b[:, 1].max(),
|
|
]
|
|
|
|
def callback(_: np.ndarray) -> Detections:
|
|
return Detections(
|
|
xyxy=np.array([aabb_a, aabb_b], dtype=np.float32),
|
|
confidence=np.array([0.9, 0.85], dtype=np.float32),
|
|
class_id=np.array([0, 0], dtype=int),
|
|
data={ORIENTED_BOX_COORDINATES: np.stack([quad_a, quad_b])},
|
|
)
|
|
|
|
image = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(
|
|
callback=callback,
|
|
slice_wh=100,
|
|
overlap_wh=0,
|
|
thread_workers=1,
|
|
overlap_filter=overlap_filter,
|
|
iou_threshold=0.5,
|
|
)
|
|
|
|
detections = slicer(image)
|
|
|
|
assert len(detections) == 2
|
|
|
|
|
|
class TestInferenceSlicerBatch:
|
|
"""Tests for InferenceSlicer batch_size > 1 path."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"batch_size",
|
|
[
|
|
pytest.param(0, id="zero"),
|
|
pytest.param(-1, id="negative"),
|
|
],
|
|
)
|
|
def test_raises_on_invalid_batch_size(self, batch_size: int) -> None:
|
|
"""ValueError raised for batch_size < 1."""
|
|
with pytest.raises(ValueError, match="batch_size"):
|
|
InferenceSlicer(
|
|
callback=lambda x: Detections.empty(), batch_size=batch_size
|
|
)
|
|
|
|
def test_batch_size_one_callback_receives_ndarray(self) -> None:
|
|
"""batch_size=1 delivers np.ndarray to callback, not list."""
|
|
received_types: list[type] = []
|
|
|
|
def callback(tile: np.ndarray) -> Detections:
|
|
received_types.append(type(tile))
|
|
return Detections.empty()
|
|
|
|
image = np.zeros((128, 128, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(
|
|
callback=callback, slice_wh=64, overlap_wh=0, batch_size=1
|
|
)
|
|
slicer(image)
|
|
|
|
assert all(t is np.ndarray for t in received_types)
|
|
|
|
def test_batch_callback_receives_list_of_ndarrays(self) -> None:
|
|
"""batch_size > 1 delivers list[np.ndarray] to callback."""
|
|
received: list[list] = []
|
|
|
|
def callback(tiles: list) -> list:
|
|
received.append(tiles)
|
|
return [Detections.empty() for _ in tiles]
|
|
|
|
# 128x128, slice_wh=64, overlap=0 → 4 slices → 2 batches of 2
|
|
image = np.zeros((128, 128, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(
|
|
callback=callback, slice_wh=64, overlap_wh=0, batch_size=2
|
|
)
|
|
slicer(image)
|
|
|
|
assert len(received) == 2
|
|
assert all(isinstance(batch, list) for batch in received)
|
|
assert all(isinstance(tile, np.ndarray) for batch in received for tile in batch)
|
|
|
|
@pytest.mark.parametrize(
|
|
("image_wh", "batch_size", "expected_batch_sizes"),
|
|
[
|
|
pytest.param((320, 64), 3, [3, 2], id="5-slices-batch-3"),
|
|
pytest.param((256, 64), 4, [4], id="4-slices-batch-4"),
|
|
pytest.param((384, 64), 2, [2, 2, 2], id="6-slices-batch-2"),
|
|
],
|
|
)
|
|
def test_last_batch_shorter_when_not_divisible(
|
|
self,
|
|
image_wh: tuple[int, int],
|
|
batch_size: int,
|
|
expected_batch_sizes: list[int],
|
|
) -> None:
|
|
"""Last batch has remaining slices when total not divisible by batch_size."""
|
|
received_batch_sizes: list[int] = []
|
|
|
|
def callback(tiles: list) -> list:
|
|
received_batch_sizes.append(len(tiles))
|
|
return [Detections.empty() for _ in tiles]
|
|
|
|
image = np.zeros((image_wh[1], image_wh[0], 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(
|
|
callback=callback,
|
|
slice_wh=64,
|
|
overlap_wh=0,
|
|
batch_size=batch_size,
|
|
thread_workers=1,
|
|
)
|
|
slicer(image)
|
|
|
|
assert received_batch_sizes == expected_batch_sizes
|
|
|
|
def test_batch_wrong_return_type_raises(self) -> None:
|
|
"""ValueError raised when batch callback returns Detections instead of list."""
|
|
|
|
def callback(tiles: list) -> Detections: # type: ignore[return]
|
|
return Detections.empty()
|
|
|
|
image = np.zeros((128, 128, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(
|
|
callback=callback, slice_wh=64, overlap_wh=0, batch_size=2
|
|
)
|
|
with pytest.raises(ValueError, match="list\\[Detections\\]"):
|
|
slicer(image)
|
|
|
|
def test_batch_length_mismatch_raises(self) -> None:
|
|
"""ValueError raised when batch callback returns list of wrong length."""
|
|
|
|
def callback(tiles: list) -> list:
|
|
return [Detections.empty()] # always 1, regardless of batch size
|
|
|
|
# 128x128, batch_size=4 → one batch of 4 slices; callback returns 1
|
|
image = np.zeros((128, 128, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(
|
|
callback=callback, slice_wh=64, overlap_wh=0, batch_size=4
|
|
)
|
|
with pytest.raises(ValueError, match="Lengths must match"):
|
|
slicer(image)
|
|
|
|
def test_batch_with_thread_workers_merges_correctly(self) -> None:
|
|
"""batch_size + thread_workers > 1 yields correct merged detection count."""
|
|
call_count = 0
|
|
|
|
def callback(tiles: list) -> list:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return [
|
|
Detections(xyxy=np.array([[0, 0, 10, 10]], dtype=float)) for _ in tiles
|
|
]
|
|
|
|
# 128x128, slice_wh=64, overlap=0 → 4 slices → 2 batches of 2
|
|
image = np.zeros((128, 128, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(
|
|
callback=callback,
|
|
slice_wh=64,
|
|
overlap_wh=0,
|
|
batch_size=2,
|
|
thread_workers=4,
|
|
overlap_filter=OverlapFilter.NONE,
|
|
)
|
|
detections = slicer(image)
|
|
|
|
assert call_count == 2
|
|
assert len(detections) == 4
|
|
|
|
def test_batch_obb_forces_sequential_and_warns(self) -> None:
|
|
"""OBB in first batch forces sequential execution and emits one warning."""
|
|
active_calls = 0
|
|
max_active_calls = 0
|
|
lock = threading.Lock()
|
|
|
|
def callback(tiles: list) -> list:
|
|
nonlocal active_calls, max_active_calls
|
|
with lock:
|
|
active_calls += 1
|
|
max_active_calls = max(max_active_calls, active_calls)
|
|
with lock:
|
|
active_calls -= 1
|
|
return [
|
|
Detections(
|
|
xyxy=np.array([[0, 0, 10, 10]], dtype=float),
|
|
data={
|
|
ORIENTED_BOX_COORDINATES: np.array(
|
|
[[[0, 0], [10, 0], [10, 10], [0, 10]]], dtype=float
|
|
)
|
|
},
|
|
)
|
|
for _ in tiles
|
|
]
|
|
|
|
# 192x192, batch_size=2 → 9 slices → 5 batches; first sync, 4 remaining
|
|
image = np.zeros((192, 192, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(
|
|
callback=callback,
|
|
slice_wh=64,
|
|
overlap_wh=0,
|
|
batch_size=2,
|
|
thread_workers=4,
|
|
overlap_filter=OverlapFilter.NONE,
|
|
)
|
|
|
|
with pytest.warns(SupervisionWarnings, match="oriented bounding boxes"):
|
|
detections = slicer(image)
|
|
|
|
assert max_active_calls == 1
|
|
assert len(detections) == 9
|
|
|
|
def test_batch_warns_out_of_bounds_once(self) -> None:
|
|
"""Out-of-slice-bounds warning fires exactly once in batch path."""
|
|
|
|
def callback(tiles: list) -> list:
|
|
return [
|
|
Detections(xyxy=np.array([[0, 0, 512, 512]], dtype=float))
|
|
for _ in tiles
|
|
]
|
|
|
|
image = np.zeros((128, 128, 3), dtype=np.uint8)
|
|
slicer = InferenceSlicer(
|
|
callback=callback,
|
|
slice_wh=64,
|
|
overlap_wh=0,
|
|
batch_size=2,
|
|
overlap_filter=OverlapFilter.NONE,
|
|
)
|
|
with pytest.warns(SupervisionWarnings, match="outside the slice bounds"):
|
|
slicer(image)
|
|
|
|
def test_move_detections_returns_a_copy(self) -> None:
|
|
"""move_detections must not mutate the caller's Detections object."""
|
|
detections = Detections(
|
|
xyxy=np.array([[1.0, 2.0, 3.0, 4.0]], dtype=np.float32),
|
|
class_id=np.array([0]),
|
|
)
|
|
original_xyxy = detections.xyxy.copy()
|
|
|
|
moved = move_detections(
|
|
detections=detections,
|
|
offset=np.array([10, 20]),
|
|
resolution_wh=(100, 100),
|
|
)
|
|
|
|
np.testing.assert_array_equal(detections.xyxy, original_xyxy)
|
|
np.testing.assert_array_equal(moved.xyxy, np.array([[11.0, 22.0, 13.0, 24.0]]))
|