Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:06:10 +08:00

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]]))