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

2185 lines
76 KiB
Python

from __future__ import annotations
from collections.abc import Callable
from contextlib import ExitStack as DoesNotRaise
import numpy as np
import pytest
from supervision.detection.utils.iou_and_nms import (
OverlapMetric,
_group_overlapping_boxes,
box_iou,
box_iou_batch,
box_iou_batch_with_jaccard,
box_non_max_merge,
box_non_max_suppression,
mask_iou_batch,
mask_non_max_merge,
mask_non_max_suppression,
oriented_box_iou_batch,
oriented_box_non_max_merge,
oriented_box_non_max_suppression,
)
from supervision.utils.internal import SupervisionWarnings
from tests.helpers import _generate_random_boxes
def _rotated_rect(
cx: float, cy: float, w: float, h: float, angle_deg: float
) -> np.ndarray:
"""Return the 4 corners of a rotated rectangle as a (4, 2) float32 array."""
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)
def _aabb_of(corners: np.ndarray) -> np.ndarray:
"""Axis-aligned bounding box of a (4, 2) OBB corner array."""
return np.array(
[
corners[:, 0].min(),
corners[:, 1].min(),
corners[:, 0].max(),
corners[:, 1].max(),
],
dtype=np.float32,
)
@pytest.mark.parametrize(
("predictions", "iou_threshold", "expected_result", "exception"),
[
(
np.empty(shape=(0, 5), dtype=float),
0.5,
[],
DoesNotRaise(),
),
(
np.array([[0, 0, 10, 10, 1.0]]),
0.5,
[[0]],
DoesNotRaise(),
),
(
np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]),
0.5,
[[1, 0]],
DoesNotRaise(),
), # High overlap, tie-break to second det
(
np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 0.99]]),
0.5,
[[0, 1]],
DoesNotRaise(),
), # High overlap, merge to high confidence
(
np.array([[0, 0, 10, 10, 0.99], [0, 0, 9, 9, 1.0]]),
0.5,
[[1, 0]],
DoesNotRaise(),
), # (test symmetry) High overlap, merge to high confidence
(
np.array([[0, 0, 10, 10, 0.90], [0, 0, 9, 9, 1.0]]),
0.5,
[[1, 0]],
DoesNotRaise(),
), # (test symmetry) High overlap, merge to high confidence
(
np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]),
1.0,
[[1], [0]],
DoesNotRaise(),
), # High IOU required
(
np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]),
0.0,
[[1, 0]],
DoesNotRaise(),
), # No IOU required
(
np.array([[0, 0, 10, 10, 1.0], [0, 0, 5, 5, 0.9]]),
0.25,
[[0, 1]],
DoesNotRaise(),
), # Below IOU requirement
(
np.array([[0, 0, 10, 10, 1.0], [0, 0, 5, 5, 0.9]]),
0.26,
[[0], [1]],
DoesNotRaise(),
), # Above IOU requirement
(
np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0], [0, 0, 8, 8, 1.0]]),
0.5,
[[2, 1, 0]],
DoesNotRaise(),
), # 3 boxes
(
np.array(
[
[0, 0, 10, 10, 1.0],
[0, 0, 9, 9, 1.0],
[5, 5, 10, 10, 1.0],
[6, 6, 10, 10, 1.0],
[9, 9, 10, 10, 1.0],
]
),
0.5,
[[4], [3, 2], [1, 0]],
DoesNotRaise(),
), # 5 boxes, 2 merges, 1 separate
(
np.array(
[
[0, 0, 2, 1, 1.0],
[1, 0, 3, 1, 1.0],
[2, 0, 4, 1, 1.0],
[3, 0, 5, 1, 1.0],
[4, 0, 6, 1, 1.0],
]
),
0.33,
[[4, 3], [2, 1], [0]],
DoesNotRaise(),
), # sequential merge, half overlap
(
np.array(
[
[0, 0, 2, 1, 0.9],
[1, 0, 3, 1, 0.9],
[2, 0, 4, 1, 1.0],
[3, 0, 5, 1, 0.9],
[4, 0, 6, 1, 0.9],
]
),
0.33,
[[2, 3, 1], [4], [0]],
DoesNotRaise(),
), # confidence
],
)
def test_group_overlapping_boxes(
predictions: np.ndarray,
iou_threshold: float,
expected_result: list[list[int]],
exception: Exception,
) -> None:
with exception:
result = _group_overlapping_boxes(
predictions=predictions, iou_threshold=iou_threshold
)
assert result == expected_result
@pytest.mark.parametrize(
("predictions", "iou_threshold", "expected_result", "exception"),
[
(
np.empty(shape=(0, 5)),
0.5,
np.array([]),
DoesNotRaise(),
), # single box with no category
(
np.array([[10.0, 10.0, 40.0, 40.0, 0.8]]),
0.5,
np.array([True]),
DoesNotRaise(),
), # single box with no category
(
np.array([[10.0, 10.0, 40.0, 40.0, 0.8, 0]]),
0.5,
np.array([True]),
DoesNotRaise(),
), # single box with category
(
np.array(
[
[10.0, 10.0, 40.0, 40.0, 0.8],
[15.0, 15.0, 40.0, 40.0, 0.9],
]
),
0.5,
np.array([False, True]),
DoesNotRaise(),
), # two boxes with no category
(
np.array(
[
[10.0, 10.0, 40.0, 40.0, 0.8, 0],
[15.0, 15.0, 40.0, 40.0, 0.9, 1],
]
),
0.5,
np.array([True, True]),
DoesNotRaise(),
), # two boxes with different category
(
np.array(
[
[10.0, 10.0, 40.0, 40.0, 0.8, 0],
[15.0, 15.0, 40.0, 40.0, 0.9, 0],
]
),
0.5,
np.array([False, True]),
DoesNotRaise(),
), # two boxes with same category
(
np.array(
[
[0.0, 0.0, 30.0, 40.0, 0.8],
[5.0, 5.0, 35.0, 45.0, 0.9],
[10.0, 10.0, 40.0, 50.0, 0.85],
]
),
0.5,
np.array([False, True, False]),
DoesNotRaise(),
), # three boxes with no category
(
np.array(
[
[0.0, 0.0, 30.0, 40.0, 0.8, 0],
[5.0, 5.0, 35.0, 45.0, 0.9, 1],
[10.0, 10.0, 40.0, 50.0, 0.85, 2],
]
),
0.5,
np.array([True, True, True]),
DoesNotRaise(),
), # three boxes with same category
(
np.array(
[
[0.0, 0.0, 30.0, 40.0, 0.8, 0],
[5.0, 5.0, 35.0, 45.0, 0.9, 0],
[10.0, 10.0, 40.0, 50.0, 0.85, 1],
]
),
0.5,
np.array([False, True, True]),
DoesNotRaise(),
), # three boxes with different category
],
)
def test_box_non_max_suppression(
predictions: np.ndarray,
iou_threshold: float,
expected_result: np.ndarray | None,
exception: Exception,
) -> None:
with exception:
result = box_non_max_suppression(
predictions=predictions, iou_threshold=iou_threshold
)
assert np.array_equal(result, expected_result)
@pytest.mark.parametrize(
("predictions", "masks", "iou_threshold", "expected_result", "exception"),
[
(
np.empty((0, 6)),
np.empty((0, 5, 5)),
0.5,
np.array([]),
DoesNotRaise(),
), # empty predictions and masks
(
np.array([[0, 0, 0, 0, 0.8]]),
np.array(
[
[
[False, False, False, False, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, False, False, False, False],
]
]
),
0.5,
np.array([True]),
DoesNotRaise(),
), # single mask with no category
(
np.array([[0, 0, 0, 0, 0.8, 0]]),
np.array(
[
[
[False, False, False, False, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, False, False, False, False],
]
]
),
0.5,
np.array([True]),
DoesNotRaise(),
), # single mask with category
(
np.array([[0, 0, 0, 0, 0.8], [0, 0, 0, 0, 0.9]]),
np.array(
[
[
[False, False, False, False, False],
[False, True, True, False, False],
[False, True, True, False, False],
[False, False, False, False, False],
[False, False, False, False, False],
],
[
[False, False, False, False, False],
[False, False, False, False, False],
[False, False, False, True, True],
[False, False, False, True, True],
[False, False, False, False, False],
],
]
),
0.5,
np.array([True, True]),
DoesNotRaise(),
), # two masks non-overlapping with no category
(
np.array([[0, 0, 0, 0, 0.8], [0, 0, 0, 0, 0.9]]),
np.array(
[
[
[False, False, False, False, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, False, False, False, False],
],
[
[False, False, False, False, False],
[False, False, True, True, True],
[False, False, True, True, True],
[False, False, True, True, True],
[False, False, False, False, False],
],
]
),
0.4,
np.array([False, True]),
DoesNotRaise(),
), # two masks partially overlapping with no category
(
np.array([[0, 0, 0, 0, 0.8, 0], [0, 0, 0, 0, 0.9, 1]]),
np.array(
[
[
[False, False, False, False, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, False, False, False, False],
],
[
[False, False, False, False, False],
[False, False, True, True, True],
[False, False, True, True, True],
[False, False, True, True, True],
[False, False, False, False, False],
],
]
),
0.5,
np.array([True, True]),
DoesNotRaise(),
), # two masks partially overlapping with different category
(
np.array(
[
[0, 0, 0, 0, 0.8],
[0, 0, 0, 0, 0.85],
[0, 0, 0, 0, 0.9],
]
),
np.array(
[
[
[False, False, False, False, False],
[False, True, True, False, False],
[False, True, True, False, False],
[False, False, False, False, False],
[False, False, False, False, False],
],
[
[False, False, False, False, False],
[False, True, True, False, False],
[False, True, True, False, False],
[False, False, False, False, False],
[False, False, False, False, False],
],
[
[False, False, False, False, False],
[False, False, False, True, True],
[False, False, False, True, True],
[False, False, False, False, False],
[False, False, False, False, False],
],
]
),
0.5,
np.array([False, True, True]),
DoesNotRaise(),
), # three masks with no category
(
np.array(
[
[0, 0, 0, 0, 0.8, 0],
[0, 0, 0, 0, 0.85, 1],
[0, 0, 0, 0, 0.9, 2],
]
),
np.array(
[
[
[False, False, False, False, False],
[False, True, True, False, False],
[False, True, True, False, False],
[False, False, False, False, False],
[False, False, False, False, False],
],
[
[False, False, False, False, False],
[False, True, True, False, False],
[False, True, True, False, False],
[False, True, True, False, False],
[False, False, False, False, False],
],
[
[False, False, False, False, False],
[False, True, True, False, False],
[False, True, True, False, False],
[False, False, False, False, False],
[False, False, False, False, False],
],
]
),
0.5,
np.array([True, True, True]),
DoesNotRaise(),
), # three masks with different category
],
)
def test_mask_non_max_suppression(
predictions: np.ndarray,
masks: np.ndarray,
iou_threshold: float,
expected_result: np.ndarray | None,
exception: Exception,
) -> None:
with exception:
result = mask_non_max_suppression(
predictions=predictions, masks=masks, iou_threshold=iou_threshold
)
assert np.array_equal(result, expected_result)
@pytest.mark.parametrize(
("predictions", "masks", "iou_threshold", "expected_result", "exception"),
[
(
np.empty((0, 6)),
np.empty((0, 5, 5)),
0.5,
[],
DoesNotRaise(),
), # empty predictions and masks
(
np.array([[0, 0, 0, 0, 0.8]]),
np.array(
[
[
[False, False, False, False, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, False, False, False, False],
]
]
),
0.5,
[[0]],
DoesNotRaise(),
), # single mask with no category
(
np.array([[0, 0, 0, 0, 0.8, 0]]),
np.array(
[
[
[False, False, False, False, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, False, False, False, False],
]
]
),
0.5,
[[0]],
DoesNotRaise(),
), # single mask with category
(
np.array([[0, 0, 0, 0, 0.8], [0, 0, 0, 0, 0.9]]),
np.array(
[
[
[False, False, False, False, False],
[False, True, True, False, False],
[False, True, True, False, False],
[False, False, False, False, False],
[False, False, False, False, False],
],
[
[False, False, False, False, False],
[False, False, False, False, False],
[False, False, False, True, True],
[False, False, False, True, True],
[False, False, False, False, False],
],
]
),
0.5,
[[0], [1]],
DoesNotRaise(),
), # two masks non-overlapping with no category
(
np.array([[0, 0, 0, 0, 0.8], [0, 0, 0, 0, 0.9]]),
np.array(
[
[
[False, False, False, False, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, False, False, False, False],
],
[
[False, False, False, False, False],
[False, False, True, True, True],
[False, False, True, True, True],
[False, False, True, True, True],
[False, False, False, False, False],
],
]
),
0.4,
[[0, 1]],
DoesNotRaise(),
), # two masks partially overlapping with no category, merge
(
np.array([[0, 0, 0, 0, 0.8], [0, 0, 0, 0, 0.9]]),
np.array(
[
[
[False, False, False, False, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, False, False, False, False],
],
[
[False, False, False, False, False],
[False, False, True, True, True],
[False, False, True, True, True],
[False, False, True, True, True],
[False, False, False, False, False],
],
]
),
0.6,
[[0], [1]],
DoesNotRaise(),
), # two masks partially overlapping with no category, no merge
(
np.array([[0, 0, 0, 0, 0.8, 0], [0, 0, 0, 0, 0.9, 1]]),
np.array(
[
[
[False, False, False, False, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, False, False, False, False],
],
[
[False, False, False, False, False],
[False, False, True, True, True],
[False, False, True, True, True],
[False, False, True, True, True],
[False, False, False, False, False],
],
]
),
0.4,
[[0], [1]],
DoesNotRaise(),
), # two masks partially overlapping with different categories
(
np.array([[0, 0, 0, 0, 0.8, 0], [0, 0, 0, 0, 0.9, 0]]),
np.array(
[
[
[False, False, False, False, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, True, True, True, False],
[False, False, False, False, False],
],
[
[False, False, False, False, False],
[False, False, True, True, True],
[False, False, True, True, True],
[False, False, True, True, True],
[False, False, False, False, False],
],
]
),
0.4,
[[0, 1]],
DoesNotRaise(),
), # two masks partially overlapping with same category
],
)
def test_mask_non_max_merge(
predictions: np.ndarray,
masks: np.ndarray,
iou_threshold: float,
expected_result: list[list[int]],
exception: Exception,
) -> None:
with exception:
result = mask_non_max_merge(
predictions=predictions, masks=masks, iou_threshold=iou_threshold
)
sorted_result = sorted([sorted(group) for group in result])
sorted_expected_result = sorted([sorted(group) for group in expected_result])
assert sorted_result == sorted_expected_result
def test_mask_non_max_merge_ignores_mask_dimension_for_exact_iou() -> None:
"""Mask NMM ignores downscale dimension and uses exact mask overlap."""
predictions = np.array(
[[0, 0, 4, 4, 0.9, 0], [0, 0, 4, 4, 0.8, 0]], dtype=np.float32
)
masks = np.zeros((2, 4, 4), dtype=bool)
masks[0, 0, 0] = True
masks[0, 0, 1] = True
masks[1, 0, 0] = True
masks[1, 1, 0] = True
result = mask_non_max_merge(
predictions=predictions,
masks=masks,
iou_threshold=0.9,
mask_dimension=1,
)
assert sorted([sorted(group) for group in result]) == [[0], [1]]
def test_mask_non_max_merge_warns_for_legacy_positional_trailing_args() -> None:
"""Mask NMM supports legacy positional trailing args with warning."""
predictions = np.array(
[[0, 0, 4, 4, 0.9, 0], [0, 0, 4, 4, 0.8, 0]], dtype=np.float32
)
masks = np.zeros((2, 4, 4), dtype=bool)
masks[0, 0:2, 0:2] = True
masks[1, 0:2, 0:2] = True
with pytest.warns(SupervisionWarnings, match="positionally.*deprecated"):
legacy_result = mask_non_max_merge(
predictions,
masks,
0.5,
640,
OverlapMetric.IOU,
)
with pytest.warns(SupervisionWarnings, match="positionally.*deprecated"):
reordered_result = mask_non_max_merge(
predictions,
masks,
0.5,
OverlapMetric.IOU,
640,
)
assert legacy_result == [[0, 1]]
assert reordered_result == [[0, 1]]
def test_mask_non_max_merge_compact_mask_matches_dense_chained_union() -> None:
"""CompactMask NMM matches dense masks when merge candidates expand."""
from supervision.detection.compact_mask import CompactMask
predictions = np.array(
[
[0, 0, 6, 2, 0.9, 0],
[0, 0, 6, 2, 0.8, 0],
[0, 0, 6, 2, 0.7, 0],
],
dtype=np.float32,
)
masks = np.zeros((3, 2, 6), dtype=bool)
masks[0, :, 0:2] = True
masks[1, :, 1:4] = True
masks[2, :, 3:5] = True
compact_mask = CompactMask.from_dense(
masks=masks,
xyxy=np.array([[0, 0, 5, 1], [0, 0, 5, 1], [0, 0, 5, 1]], dtype=np.float32),
image_shape=(2, 6),
)
dense_result = mask_non_max_merge(
predictions=predictions,
masks=masks,
iou_threshold=0.2,
)
compact_result = mask_non_max_merge(
predictions=predictions,
masks=compact_mask,
iou_threshold=0.2,
)
assert sorted([sorted(group) for group in dense_result]) == [[0, 1, 2]]
assert sorted([sorted(group) for group in compact_result]) == [[0, 1, 2]]
@pytest.mark.parametrize(
("box_true", "box_detection", "overlap_metric", "expected_overlap", "exception"),
[
(
[100.0, 100.0, 200.0, 200.0],
[150.0, 150.0, 250.0, 250.0],
OverlapMetric.IOU,
0.14285714285714285,
DoesNotRaise(),
), # partial overlap, IOU
(
[100.0, 100.0, 200.0, 200.0],
[150.0, 150.0, 250.0, 250.0],
OverlapMetric.IOS,
0.25,
DoesNotRaise(),
), # partial overlap, IOS
(
np.array([0.0, 0.0, 10.0, 10.0], dtype=np.float32),
np.array([0.0, 0.0, 10.0, 10.0], dtype=np.float32),
OverlapMetric.IOU,
1.0,
DoesNotRaise(),
), # identical boxes, both boxes are arrays, IOU
(
np.array([0.0, 0.0, 10.0, 10.0], dtype=np.float32),
np.array([0.0, 0.0, 10.0, 10.0], dtype=np.float32),
OverlapMetric.IOS,
1.0,
DoesNotRaise(),
), # identical boxes, both boxes are arrays, IOS
(
[0.0, 0.0, 10.0, 10.0],
[0.0, 0.0, 10.0, 10.0],
"iou",
1.0,
DoesNotRaise(),
), # identical boxes, both boxes are arrays, IOU as lowercase string
(
[0.0, 0.0, 10.0, 10.0],
[0.0, 0.0, 10.0, 10.0],
"ios",
1.0,
DoesNotRaise(),
), # identical boxes, both boxes are arrays, IOS as lowercase string
(
[0.0, 0.0, 10.0, 10.0],
[0.0, 0.0, 10.0, 10.0],
"IOU",
1.0,
DoesNotRaise(),
), # identical boxes, both boxes are arrays, IOU as uppercase string
(
[0.0, 0.0, 10.0, 10.0],
[20.0, 20.0, 30.0, 30.0],
OverlapMetric.IOU,
0.0,
DoesNotRaise(),
), # no overlap, IOU
(
[0.0, 0.0, 10.0, 10.0],
[20.0, 20.0, 30.0, 30.0],
OverlapMetric.IOS,
0.0,
DoesNotRaise(),
), # no overlap, IOS
(
[0.0, 0.0, 10.0, 10.0],
[10.0, 0.0, 20.0, 10.0],
OverlapMetric.IOU,
0.0,
DoesNotRaise(),
), # boxes touch at edge, zero intersection, IOU
(
[0.0, 0.0, 10.0, 10.0],
[10.0, 0.0, 20.0, 10.0],
OverlapMetric.IOS,
0.0,
DoesNotRaise(),
), # boxes touch at edge, zero intersection, IOU
(
[0.0, 0.0, 10.0, 10.0],
[2.0, 2.0, 8.0, 8.0],
OverlapMetric.IOU,
0.36,
DoesNotRaise(),
), # one box inside another, IOU
(
[0.0, 0.0, 10.0, 10.0],
[2.0, 2.0, 8.0, 8.0],
OverlapMetric.IOS,
1.0,
DoesNotRaise(),
), # one box inside another, IOS
(
[0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 10.0, 10.0],
OverlapMetric.IOU,
0.0,
DoesNotRaise(),
), # degenerate true box with zero area, IOU
(
[0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 10.0, 10.0],
OverlapMetric.IOS,
0.0,
DoesNotRaise(),
), # degenerate true box with zero area, IOS
(
[0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 0.0],
OverlapMetric.IOU,
0.0,
DoesNotRaise(),
), # both boxes fully degenerate, IOU
(
[0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 0.0],
OverlapMetric.IOS,
0.0,
DoesNotRaise(),
), # both boxes fully degenerate, IOS
(
[-5.0, 0.0, 5.0, 10.0],
[0.0, 0.0, 10.0, 10.0],
OverlapMetric.IOU,
1.0 / 3.0,
DoesNotRaise(),
), # negative x_min, overlapping boxes, IOU is 1/3
(
[-5.0, 0.0, 5.0, 10.0],
[0.0, 0.0, 10.0, 10.0],
OverlapMetric.IOS,
0.5,
DoesNotRaise(),
), # negative x_min, overlapping boxes, IOS is 0.5
(
[0.0, 0.0, 1.0, 1.0],
[0.5, 0.5, 1.5, 1.5],
OverlapMetric.IOU,
0.14285714285714285,
DoesNotRaise(),
), # partial overlap with fractional coordinates, IOU
(
[0.0, 0.0, 1.0, 1.0],
[0.5, 0.5, 1.5, 1.5],
OverlapMetric.IOS,
0.25,
DoesNotRaise(),
), # partial overlap with fractional coordinates, IOS
],
)
def test_box_iou(
box_true: list[float] | np.ndarray,
box_detection: list[float] | np.ndarray,
overlap_metric: str | OverlapMetric,
expected_overlap: float,
exception: Exception,
) -> None:
with exception:
result = box_iou(
box_true=box_true,
box_detection=box_detection,
overlap_metric=overlap_metric,
)
assert result == pytest.approx(expected_overlap, rel=1e-6, abs=1e-12)
@pytest.mark.parametrize(
(
"boxes_true",
"boxes_detection",
"overlap_metric",
"expected_overlap",
"exception",
),
[
# both inputs empty
(
np.empty((0, 4), dtype=np.float32),
np.empty((0, 4), dtype=np.float32),
OverlapMetric.IOU,
np.empty((0, 0), dtype=np.float32),
DoesNotRaise(),
),
# one true box, no detections
(
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
np.empty((0, 4), dtype=np.float32),
OverlapMetric.IOU,
np.empty((1, 0), dtype=np.float32),
DoesNotRaise(),
),
# no true boxes, one detection
(
np.empty((0, 4), dtype=np.float32),
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
OverlapMetric.IOU,
np.empty((0, 1), dtype=np.float32),
DoesNotRaise(),
),
# 1x1 partial overlap, IOU
(
np.array([[100.0, 100.0, 200.0, 200.0]], dtype=np.float32),
np.array([[150.0, 150.0, 250.0, 250.0]], dtype=np.float32),
OverlapMetric.IOU,
np.array([[0.14285715]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 partial overlap, IOS
(
np.array([[100.0, 100.0, 200.0, 200.0]], dtype=np.float32),
np.array([[150.0, 150.0, 250.0, 250.0]], dtype=np.float32),
OverlapMetric.IOS,
np.array([[0.25]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 identical boxes, IOU as lowercase string
(
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
"iou",
np.array([[1.0]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 identical boxes, IOS as lowercase string
(
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
"ios",
np.array([[1.0]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 identical boxes, IOU as uppercase string
(
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
"IOU",
np.array([[1.0]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 identical boxes, IOS as uppercase string
(
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
"IOS",
np.array([[1.0]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 no overlap, IOU
(
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
np.array([[20.0, 20.0, 30.0, 30.0]], dtype=np.float32),
OverlapMetric.IOU,
np.array([[0.0]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 no overlap, IOS
(
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
np.array([[20.0, 20.0, 30.0, 30.0]], dtype=np.float32),
OverlapMetric.IOS,
np.array([[0.0]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 touching at edge, zero intersection, IOU
(
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
np.array([[10.0, 0.0, 20.0, 10.0]], dtype=np.float32),
OverlapMetric.IOU,
np.array([[0.0]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 touching at edge, zero intersection, IOS
(
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
np.array([[10.0, 0.0, 20.0, 10.0]], dtype=np.float32),
OverlapMetric.IOS,
np.array([[0.0]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 box inside another, IOU
(
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
np.array([[2.0, 2.0, 8.0, 8.0]], dtype=np.float32),
OverlapMetric.IOU,
np.array([[0.36]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 box inside another, IOS
(
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
np.array([[2.0, 2.0, 8.0, 8.0]], dtype=np.float32),
OverlapMetric.IOS,
np.array([[1.0]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 degenerate true box, IOU
(
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
OverlapMetric.IOU,
np.array([[0.0]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 degenerate true box, IOS
(
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
OverlapMetric.IOS,
np.array([[0.0]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 both boxes degenerate, IOU
(
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
OverlapMetric.IOU,
np.array([[0.0]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 both boxes degenerate, IOS
(
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
OverlapMetric.IOS,
np.array([[0.0]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 negative coordinate, partial overlap, IOU
(
np.array([[-5.0, 0.0, 5.0, 10.0]], dtype=np.float32),
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
OverlapMetric.IOU,
np.array([[1.0 / 3.0]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 negative coordinate, partial overlap, IOS
(
np.array([[-5.0, 0.0, 5.0, 10.0]], dtype=np.float32),
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
OverlapMetric.IOS,
np.array([[0.5]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 fractional coordinates, partial overlap, IOU
(
np.array([[0.0, 0.0, 1.0, 1.0]], dtype=np.float32),
np.array([[0.5, 0.5, 1.5, 1.5]], dtype=np.float32),
OverlapMetric.IOU,
np.array([[0.14285715]], dtype=np.float32),
DoesNotRaise(),
),
# 1x1 fractional coordinates, partial overlap, IOS
(
np.array([[0.0, 0.0, 1.0, 1.0]], dtype=np.float32),
np.array([[0.5, 0.5, 1.5, 1.5]], dtype=np.float32),
OverlapMetric.IOS,
np.array([[0.25]], dtype=np.float32),
DoesNotRaise(),
),
# true batch case, 2x2, IOU
(
np.array(
[
[0.0, 0.0, 10.0, 10.0],
[10.0, 10.0, 20.0, 20.0],
],
dtype=np.float32,
),
np.array(
[
[0.0, 0.0, 10.0, 10.0],
[5.0, 5.0, 15.0, 15.0],
],
dtype=np.float32,
),
OverlapMetric.IOU,
np.array(
[
[1.0, 0.14285715],
[0.0, 0.14285715],
],
dtype=np.float32,
),
DoesNotRaise(),
),
# true batch case, 2x2, IOS
(
np.array(
[
[0.0, 0.0, 10.0, 10.0],
[10.0, 10.0, 20.0, 20.0],
],
dtype=np.float32,
),
np.array(
[
[0.0, 0.0, 10.0, 10.0],
[5.0, 5.0, 15.0, 15.0],
],
dtype=np.float32,
),
OverlapMetric.IOS,
np.array(
[
[1.0, 0.25],
[0.0, 0.25],
],
dtype=np.float32,
),
DoesNotRaise(),
),
# invalid overlap_metric
(
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
"invalid",
None,
pytest.raises(ValueError, match="Invalid value: INVALID"),
),
],
)
def test_box_iou_batch(
boxes_true: np.ndarray,
boxes_detection: np.ndarray,
overlap_metric: str | OverlapMetric,
expected_overlap: np.ndarray | None,
exception: Exception,
) -> None:
with exception:
result = box_iou_batch(
boxes_true=boxes_true,
boxes_detection=boxes_detection,
overlap_metric=overlap_metric,
)
assert isinstance(result, np.ndarray)
assert result.shape == expected_overlap.shape
assert np.allclose(
result,
expected_overlap,
rtol=1e-6,
atol=1e-12,
)
@pytest.mark.parametrize(
("num_true", "num_det"),
[
(5, 5),
(5, 10),
(10, 5),
(10, 10),
(20, 30),
(30, 20),
(50, 50),
(100, 100),
],
)
@pytest.mark.parametrize(
"overlap_metric",
[OverlapMetric.IOU, OverlapMetric.IOS],
)
def test_box_iou_batch_symmetric_large(
num_true: int,
num_det: int,
overlap_metric: OverlapMetric,
) -> None:
boxes_true = _generate_random_boxes(num_true)
boxes_det = _generate_random_boxes(num_det)
result_ab = box_iou_batch(
boxes_true=boxes_true,
boxes_detection=boxes_det,
overlap_metric=overlap_metric,
)
result_ba = box_iou_batch(
boxes_true=boxes_det,
boxes_detection=boxes_true,
overlap_metric=overlap_metric,
)
assert result_ab.shape == (num_true, num_det)
assert result_ba.shape == (num_det, num_true)
assert np.allclose(
result_ab,
result_ba.T,
rtol=1e-6,
atol=1e-12,
)
def _boundary_box_pair(
origin: int, side: int = 50, shift: int = 25, dtype: type = np.float64
) -> tuple[np.ndarray, np.ndarray]:
"""Build two overlapping boxes sharing an origin for precision tests.
Both boxes are `side`x`side`; the second is shifted by `shift` along x so
the intersection is `shift`x`side` and the union is `2*side**2 - shift*side`.
"""
box_a = np.array([[origin, origin, origin + side, origin + side]], dtype=dtype)
box_b = np.array(
[[origin + shift, origin, origin + side + shift, origin + side]],
dtype=dtype,
)
return box_a, box_b
@pytest.mark.parametrize(
("origin", "overlap_metric", "expected"),
[
pytest.param(
2**24 + 1, OverlapMetric.IOU, 1.0 / 3.0, id="iou-float64-above-2pow24"
),
pytest.param(2**24 + 1, OverlapMetric.IOS, 0.5, id="ios-float64-above-2pow24"),
pytest.param(2**24, OverlapMetric.IOU, 1.0 / 3.0, id="iou-float64-at-2pow24"),
pytest.param(
2**24 - 1, OverlapMetric.IOU, 1.0 / 3.0, id="iou-float64-just-below-2pow24"
),
],
)
def test_box_iou_batch_float64_input_precision_at_2pow24_boundary(
origin: int,
overlap_metric: OverlapMetric,
expected: float,
) -> None:
"""`float64`/`int64` inputs keep full precision at the `2**24` boundary.
`float64` accumulation is exact for these integer-valued coordinates, so the
result must match the analytic value. Two 50x50 boxes shifted by 25 in x give
intersection 25*50=1250 and union 2*2500-1250=3750, so IoU=1/3 and IoS=0.5.
This does not exercise the `float32`-storage case, which cannot be recovered
inside this function; it guards the `float64`/`int64` accumulation path only.
"""
box_a, box_b = _boundary_box_pair(origin, dtype=np.float64)
result = box_iou_batch(
boxes_true=box_a, boxes_detection=box_b, overlap_metric=overlap_metric
)
assert result[0, 0] == pytest.approx(expected, rel=1e-6)
def test_box_iou_batch_int32_input_does_not_overflow() -> None:
"""`int32` coordinates with large areas must not overflow to a wrong IoU.
A 60000x60000 box has area 3.6e9, which wraps to a negative value in `int32`.
Before upcasting the corners to `float64`, this made the union non-positive
and the function returned `0.0`; it must now return the analytic IoU of 1/3.
"""
side, shift = 60000, 30000
box_a, box_b = _boundary_box_pair(0, side=side, shift=shift, dtype=np.int32)
result = box_iou_batch(boxes_true=box_a, boxes_detection=box_b)
assert result[0, 0] != 0.0
assert result[0, 0] == pytest.approx(1.0 / 3.0, rel=1e-6)
@pytest.mark.parametrize(
"scale",
[
pytest.param(np.array([[10, 1]], dtype=np.float32), id="x-dominant"),
pytest.param(np.array([[1, 10]], dtype=np.float32), id="y-dominant"),
],
)
def test_oriented_box_iou_batch_is_invariant_to_non_square_scaling(
scale: np.ndarray,
) -> None:
"""IoU is invariant under per-axis (anisotropic) scaling.
An affine map multiplies every area — intersection and union alike — by the
same determinant, so exact polygon IoU is unchanged. Equality is exact
because the computation is rasterization-free; the analytic IoU of these two
rectangles is 0.25.
"""
boxes_true = np.array([[[1, 0], [0, 1], [3, 4], [4, 3]]], dtype=np.float32)
boxes_detection = np.array([[[1, 1], [2, 0], [4, 2], [3, 3]]], dtype=np.float32)
baseline_iou = oriented_box_iou_batch(boxes_true, boxes_detection)
scaled_iou = oriented_box_iou_batch(boxes_true * scale, boxes_detection * scale)
assert baseline_iou.shape == (1, 1)
assert scaled_iou.shape == (1, 1)
assert baseline_iou[0, 0] == pytest.approx(0.25)
assert np.allclose(scaled_iou, baseline_iou, rtol=1e-5, atol=1e-7)
class TestOrientedBoxIouBatch:
"""Tests for `oriented_box_iou_batch`."""
@pytest.mark.parametrize(
("scale", "offset"),
[
pytest.param(80.0, 0.0, id="large-scale"),
pytest.param(1.0, 3000.0, id="far-from-origin"),
pytest.param(80.0, 3000.0, id="large-and-far"),
],
)
def test_is_invariant_to_canvas_transforms(
self, scale: float, offset: float
) -> None:
"""IoU is invariant under uniform scaling and translation.
Both are affine maps, so exact polygon IoU matches the small-coordinate
baseline exactly — free of the pixel-quantization error that the prior
rasterization-based implementation exhibited."""
boxes_true = _rotated_rect(50, 50, 40, 20, 30)[None]
boxes_detection = _rotated_rect(52, 48, 40, 20, 35)[None]
baseline = oriented_box_iou_batch(boxes_true, boxes_detection)
transformed = oriented_box_iou_batch(
boxes_true * scale + offset,
boxes_detection * scale + offset,
)
assert baseline.shape == (1, 1)
assert transformed.shape == (1, 1)
assert baseline[0, 0] > 0.4
assert np.allclose(transformed, baseline, rtol=1e-5, atol=1e-7)
def test_supports_overlap_metric(self) -> None:
"""`overlap_metric=IOS` divides by the smaller area, so a small box fully
contained in a larger one scores exactly 1.0, while IoU is the area ratio."""
small = _rotated_rect(50, 50, 20, 20, 0)[None]
large = _rotated_rect(50, 50, 60, 60, 0)[None]
iou = oriented_box_iou_batch(small, large, OverlapMetric.IOU)[0, 0]
ios = oriented_box_iou_batch(small, large, OverlapMetric.IOS)[0, 0]
# 20x20 inside 60x60: IoU = 400 / 3600 = 1/9, IoS = 400 / 400 = 1.0.
assert iou == pytest.approx(1 / 9, rel=1e-3)
assert ios == pytest.approx(1.0)
def test_half_overlap_matches_analytic_value(self) -> None:
"""Two unit-height 2x2 squares offset by 1 share half their area: IoU=1/3."""
boxes_true = np.array([[[0, 0], [2, 0], [2, 2], [0, 2]]], dtype=np.float32)
boxes_detection = np.array([[[1, 0], [3, 0], [3, 2], [1, 2]]], dtype=np.float32)
iou = oriented_box_iou_batch(boxes_true, boxes_detection)
# intersection = 2, union = 4 + 4 - 2 = 6, IoU = 2/6 = 1/3.
assert iou[0, 0] == pytest.approx(1 / 3)
def test_disjoint_boxes_score_zero(self) -> None:
"""Boxes whose envelopes do not overlap are pruned by the gate to 0.0."""
boxes_true = _rotated_rect(10, 10, 8, 4, 20)[None]
boxes_detection = _rotated_rect(500, 500, 8, 4, 70)[None]
iou = oriented_box_iou_batch(boxes_true, boxes_detection)
assert iou[0, 0] == 0.0
@pytest.mark.parametrize(
"boxes",
[
pytest.param(
np.stack([_rotated_rect(10, 10, 8, 4, 20)]),
id="n=1",
),
pytest.param(
np.stack(
[_rotated_rect(10, 10, 8, 4, 20), _rotated_rect(40, 40, 8, 4, 70)]
),
id="n=2",
),
],
)
def test_self_comparison_is_symmetric_with_unit_diagonal(
self, boxes: np.ndarray
) -> None:
"""Comparing a set with itself yields a symmetric matrix and a 1.0 diagonal."""
iou = oriented_box_iou_batch(boxes, boxes)
assert np.allclose(iou, iou.T)
for i in range(len(boxes)):
assert iou[i, i] == pytest.approx(1.0)
def test_envelope_overlap_without_polygon_overlap_scores_zero(self) -> None:
"""Parallel rotated bars share an envelope but not a body, so they score 0.
Exercises the path where a pair passes the axis-aligned gate yet has no
exact polygon intersection.
"""
boxes_true = _rotated_rect(50, 50, 100, 4, 45)[None]
boxes_detection = _rotated_rect(72, 28, 100, 4, 45)[None]
iou = oriented_box_iou_batch(boxes_true, boxes_detection)
assert iou[0, 0] == 0.0
def test_rejects_unsupported_overlap_metric(self) -> None:
"""An overlap metric other than IOU or IOS raises ValueError."""
boxes = _rotated_rect(50, 50, 20, 20, 0)[None]
with pytest.raises(ValueError, match="is not supported"):
oriented_box_iou_batch(boxes, boxes, "invalid") # type: ignore[arg-type]
@pytest.mark.parametrize(
("boxes_a", "boxes_b", "expected"),
[
pytest.param(
np.array([[[5, 5], [5, 5], [5, 5], [5, 5]]], dtype=np.float32),
np.array([[[0, 0], [2, 0], [2, 2], [0, 2]]], dtype=np.float32),
0.0,
id="collapsed-box-vs-normal",
),
pytest.param(
np.array([[[0, 0], [1, 0], [2, 0], [3, 0]]], dtype=np.float32),
np.array([[[0, 0], [2, 0], [2, 2], [0, 2]]], dtype=np.float32),
0.0,
id="collinear-box-vs-normal",
),
pytest.param(
np.array([[[5, 5], [5, 5], [5, 5], [5, 5]]], dtype=np.float32),
np.array([[[5, 5], [5, 5], [5, 5], [5, 5]]], dtype=np.float32),
0.0,
id="self-comparison-zero-area",
),
],
)
def test_degenerate_boxes_score_zero(
self,
boxes_a: np.ndarray,
boxes_b: np.ndarray,
expected: float,
) -> None:
"""Degenerate boxes (zero-area collapsed or collinear) always score 0.
Note: self-comparison of a zero-area box returns 0.0, not 1.0 — this
diverges from ``box_iou_batch`` which returns 1.0 for degenerate AABB
self-comparison.
"""
iou = oriented_box_iou_batch(boxes_a, boxes_b)
assert iou[0, 0] == expected
@pytest.mark.parametrize(
("n", "m"),
[
pytest.param(0, 3, id="zero-true"),
pytest.param(3, 0, id="zero-detection"),
pytest.param(0, 0, id="both-zero"),
],
)
def test_empty_input_returns_correct_shape(self, n: int, m: int) -> None:
"""Empty inputs trigger the early-return path; result is zero-filled."""
boxes_true = np.empty((n, 4, 2), dtype=np.float32)
boxes_detection = np.empty((m, 4, 2), dtype=np.float32)
iou = oriented_box_iou_batch(boxes_true, boxes_detection)
assert iou.shape == (n, m)
assert iou.dtype == np.float64
assert np.all(iou == 0.0)
@pytest.mark.parametrize(
("bad_shape", "match"),
[
pytest.param((2, 3, 2), r"expected \(N, 4, 2\)", id="3d-wrong-inner-dims"),
pytest.param((2, 5), r"expected \(N, 8\)", id="2d-wrong-columns"),
pytest.param((8,), r"must be 2-D", id="1d-input"),
],
)
def test_invalid_shape_raises_value_error(
self, bad_shape: tuple, match: str
) -> None:
"""Inputs with wrong shape raise ValueError before any computation."""
boxes_bad = np.zeros(bad_shape, dtype=np.float32)
boxes_ok = np.zeros((2, 4, 2), dtype=np.float32)
with pytest.raises(ValueError, match=match):
oriented_box_iou_batch(boxes_bad, boxes_ok)
class TestOrientedBoxNonMaxSuppression:
"""Tests for `oriented_box_non_max_suppression`."""
def test_keeps_x_pattern(self) -> None:
"""X-pattern: two thin rectangles crossing at +/-45° share an AABB but
barely overlap as OBBs. AABB-NMS would suppress one; OBB-NMS must keep
both."""
quad_a = _rotated_rect(50, 50, 100, 10, +45)
quad_b = _rotated_rect(50, 50, 100, 10, -45)
oriented_boxes = np.stack([quad_a, quad_b])
predictions = np.array(
[
[*_aabb_of(quad_a), 0.9, 0],
[*_aabb_of(quad_b), 0.85, 0],
],
dtype=np.float32,
)
assert box_iou_batch(predictions[:, :4], predictions[:, :4])[0, 1] > 0.95
assert oriented_box_iou_batch(quad_a[None], quad_b[None])[0, 0] < 0.2
keep = oriented_box_non_max_suppression(
predictions=predictions, oriented_boxes=oriented_boxes, iou_threshold=0.5
)
assert np.array_equal(keep, np.array([True, True]))
@pytest.mark.parametrize(
("class_id_b", "expected_keep"),
[
pytest.param(0, [True, False], id="same-class"),
pytest.param(1, [True, True], id="diff-class"),
],
)
def test_suppression_is_class_aware(
self, class_id_b: int, expected_keep: list[bool]
) -> None:
"""Same class: lower-score OBB suppressed. Different class: both kept."""
quad = _rotated_rect(50, 50, 100, 10, 45)
shifted = _rotated_rect(51, 51, 100, 10, 45)
oriented_boxes = np.stack([quad, shifted])
predictions = np.array(
[
[*_aabb_of(quad), 0.9, 0],
[*_aabb_of(shifted), 0.85, class_id_b],
],
dtype=np.float32,
)
assert oriented_box_iou_batch(quad[None], shifted[None])[0, 0] > 0.9
keep = oriented_box_non_max_suppression(
predictions=predictions, oriented_boxes=oriented_boxes, iou_threshold=0.5
)
assert np.array_equal(keep, np.array(expected_keep))
def test_length_mismatch_raises(self) -> None:
"""Mismatched predictions and oriented_boxes must fail loudly, not
silently misalign rows."""
predictions = np.zeros((3, 5), dtype=np.float32)
oriented_boxes = np.zeros((2, 4, 2), dtype=np.float32)
with pytest.raises(ValueError, match="same length"):
oriented_box_non_max_suppression(
predictions=predictions, oriented_boxes=oriented_boxes
)
def test_empty_predictions(self) -> None:
"""No OBB predictions should produce an empty boolean keep mask."""
predictions = np.empty((0, 5), dtype=np.float32)
oriented_boxes = np.empty((0, 4, 2), dtype=np.float32)
keep = oriented_box_non_max_suppression(
predictions=predictions, oriented_boxes=oriented_boxes
)
assert keep.shape == (0,)
assert keep.dtype == bool
@pytest.mark.parametrize("iou_threshold", [0.0, 0.5, 1.0])
def test_keeps_single_prediction(self, iou_threshold: float) -> None:
"""A single OBB prediction is always kept, regardless of threshold."""
quad = _rotated_rect(50, 50, 40, 20, 30)
oriented_boxes = quad[None]
predictions = np.array([[*_aabb_of(quad), 0.9, 0]], dtype=np.float32)
keep = oriented_box_non_max_suppression(
predictions=predictions,
oriented_boxes=oriented_boxes,
iou_threshold=iou_threshold,
)
assert np.array_equal(keep, np.array([True]))
@pytest.mark.parametrize(
("iou_threshold", "expected_keep"),
[
pytest.param(0.0, [True, False], id="threshold-0"),
pytest.param(1.0, [True, True], id="threshold-1"),
],
)
def test_threshold_extremes(
self, iou_threshold: float, expected_keep: list[bool]
) -> None:
"""At threshold extremes, positive-overlap non-identical OBBs suppress at
0.0 and are both kept at 1.0."""
quad_a = _rotated_rect(50, 50, 40, 40, 0)
quad_b = _rotated_rect(55, 50, 40, 40, 0)
oriented_boxes = np.stack([quad_a, quad_b])
predictions = np.array(
[
[*_aabb_of(quad_a), 0.9, 0],
[*_aabb_of(quad_b), 0.85, 0],
],
dtype=np.float32,
)
overlap = oriented_box_iou_batch(quad_a[None], quad_b[None])[0, 0]
assert 0.0 < overlap < 1.0
keep = oriented_box_non_max_suppression(
predictions=predictions,
oriented_boxes=oriented_boxes,
iou_threshold=iou_threshold,
)
assert np.array_equal(keep, np.array(expected_keep))
@pytest.mark.parametrize(
("overlap_metric", "expected_keep"),
[
pytest.param(OverlapMetric.IOU, [True, True], id="iou-keeps-both"),
pytest.param(OverlapMetric.IOS, [True, False], id="ios-suppresses-small"),
],
)
def test_overlap_metric_determines_suppression(
self, overlap_metric: OverlapMetric, expected_keep: list[bool]
) -> None:
"""Small box inside large: IOU keeps both; IOS suppresses small."""
large = _rotated_rect(50, 50, 60, 60, 0)
small = _rotated_rect(50, 50, 20, 20, 0)
oriented_boxes = np.stack([large, small])
predictions = np.array(
[
[*_aabb_of(large), 0.9, 0],
[*_aabb_of(small), 0.85, 0],
],
dtype=np.float32,
)
keep = oriented_box_non_max_suppression(
predictions=predictions,
oriented_boxes=oriented_boxes,
iou_threshold=0.5,
overlap_metric=overlap_metric,
)
assert np.array_equal(keep, np.array(expected_keep))
class TestOrientedBoxNonMaxMerge:
"""Tests for `oriented_box_non_max_merge`."""
def test_empty_predictions_returns_empty_groups(self) -> None:
"""No OBB predictions should produce no merge groups."""
predictions = np.empty((0, 5), dtype=np.float32)
oriented_boxes = np.empty((0, 4, 2), dtype=np.float32)
groups = oriented_box_non_max_merge(
predictions=predictions, oriented_boxes=oriented_boxes
)
assert groups == []
def test_single_prediction_returns_singleton_group(self) -> None:
"""A single OBB prediction should be returned as one singleton group."""
quad = _rotated_rect(50, 50, 40, 20, 30)
oriented_boxes = quad[None]
predictions = np.array([[*_aabb_of(quad), 0.9, 0]], dtype=np.float32)
groups = oriented_box_non_max_merge(
predictions=predictions, oriented_boxes=oriented_boxes
)
assert groups == [[0]]
def test_groups_overlapping_oriented_boxes(self) -> None:
"""Two near-identical OBBs should be merged into one group; an X-pattern
pair should produce two separate groups."""
quad_dup_a = _rotated_rect(50, 50, 100, 10, 45)
quad_dup_b = _rotated_rect(51, 51, 100, 10, 45)
quad_x = _rotated_rect(50, 50, 100, 10, -45)
oriented_boxes = np.stack([quad_dup_a, quad_dup_b, quad_x])
predictions = np.array(
[
[*_aabb_of(quad_dup_a), 0.90, 0],
[*_aabb_of(quad_dup_b), 0.85, 0],
[*_aabb_of(quad_x), 0.80, 0],
],
dtype=np.float32,
)
groups = oriented_box_non_max_merge(
predictions=predictions, oriented_boxes=oriented_boxes, iou_threshold=0.5
)
sorted_groups = sorted(sorted(g) for g in groups)
assert sorted_groups == [[0, 1], [2]]
class TestIouThresholdValidation:
"""Invalid IoU thresholds must raise `ValueError` on public NMS/NMM APIs."""
@pytest.mark.parametrize(
("function", "kwargs"),
[
pytest.param(
box_non_max_suppression,
{"predictions": np.empty((0, 5), dtype=np.float32)},
id="box-nms",
),
pytest.param(
box_non_max_merge,
{"predictions": np.empty((0, 5), dtype=np.float32)},
id="box-nmm",
),
pytest.param(
mask_non_max_suppression,
{
"predictions": np.empty((0, 5), dtype=np.float32),
"masks": np.empty((0, 1, 1), dtype=bool),
},
id="mask-nms",
),
pytest.param(
mask_non_max_merge,
{
"predictions": np.empty((0, 5), dtype=np.float32),
"masks": np.empty((0, 1, 1), dtype=bool),
},
id="mask-nmm",
),
pytest.param(
oriented_box_non_max_suppression,
{
"predictions": np.empty((0, 5), dtype=np.float32),
"oriented_boxes": np.empty((0, 4, 2), dtype=np.float32),
},
id="obb-nms",
),
pytest.param(
oriented_box_non_max_merge,
{
"predictions": np.empty((0, 5), dtype=np.float32),
"oriented_boxes": np.empty((0, 4, 2), dtype=np.float32),
},
id="obb-nmm",
),
],
)
@pytest.mark.parametrize("iou_threshold", [-0.1, 1.1])
def test_rejects_thresholds_outside_closed_unit_interval(
self,
function: Callable[..., object],
kwargs: dict[str, object],
iou_threshold: float,
) -> None:
"""Each public overlap filter rejects thresholds outside [0, 1]."""
with pytest.raises(ValueError, match="closed range from 0 to 1"):
function(iou_threshold=iou_threshold, **kwargs)
def _naive_mask_iou(
masks_true: np.ndarray,
masks_detection: np.ndarray,
overlap_metric: OverlapMetric = OverlapMetric.IOU,
) -> np.ndarray:
"""Reference IoU that materialises the (N, M, H, W) overlap explicitly."""
intersection = np.logical_and(masks_true[:, None], masks_detection).sum(axis=(2, 3))
area_true = masks_true.sum(axis=(1, 2))
area_detection = masks_detection.sum(axis=(1, 2))
if overlap_metric == OverlapMetric.IOU:
denominator = area_true[:, None] + area_detection - intersection
else:
denominator = np.minimum(area_true[:, None], area_detection)
return np.divide(
intersection,
denominator,
out=np.zeros((len(masks_true), len(masks_detection)), dtype=float),
where=denominator != 0,
)
class TestMaskIouBatch:
"""`mask_iou_batch` (matmul intersection) matches the naive boolean reference."""
@pytest.mark.parametrize(
"overlap_metric",
[
pytest.param(OverlapMetric.IOU, id="iou"),
pytest.param(OverlapMetric.IOS, id="ios"),
],
)
@pytest.mark.parametrize(
("n_true", "n_detection", "height", "width"),
[
pytest.param(1, 1, 20, 24, id="single"),
pytest.param(7, 7, 32, 40, id="several-square"),
pytest.param(5, 9, 28, 36, id="rectangular"),
],
)
def test_matches_naive_reference_on_random_masks(
self,
n_true: int,
n_detection: int,
height: int,
width: int,
overlap_metric: OverlapMetric,
) -> None:
"""Random masks give the same matrix as the explicit (N, M, H, W) reference."""
rng = np.random.default_rng(0)
masks_true = rng.random((n_true, height, width)) > 0.5
masks_detection = rng.random((n_detection, height, width)) > 0.5
result = mask_iou_batch(masks_true, masks_detection, overlap_metric)
expected = _naive_mask_iou(masks_true, masks_detection, overlap_metric)
np.testing.assert_allclose(result, expected)
def test_chunking_matches_single_pass(self) -> None:
"""A tiny memory_limit forces chunking yet returns the same matrix."""
rng = np.random.default_rng(1)
masks_true = rng.random((6, 16, 16)) > 0.5
masks_detection = rng.random((4, 16, 16)) > 0.5
single_pass = mask_iou_batch(masks_true, masks_detection)
chunked = mask_iou_batch(masks_true, masks_detection, memory_limit=0)
np.testing.assert_allclose(single_pass, chunked, rtol=1e-6)
# empty masks_true under a tiny limit must not hit np.vstack([])
empty = mask_iou_batch(
np.zeros((0, 16, 16), dtype=bool), masks_detection, memory_limit=0
)
assert empty.shape == (0, 4)
def test_mismatched_spatial_shape_raises(self) -> None:
"""Equal pixel counts but different (H, W) must raise, not silently mismatch."""
masks_true = np.zeros((1, 4, 9), dtype=bool)
masks_detection = np.zeros((1, 6, 6), dtype=bool) # 36 px, different (H, W)
with pytest.raises(ValueError, match="must share the same"):
mask_iou_batch(masks_true, masks_detection)
def test_invalid_ndim_raises(self) -> None:
"""2D and 4D inputs must raise ValueError before reaching shape[1:] check."""
mask_3d = np.zeros((1, 10, 10), dtype=bool)
with pytest.raises(ValueError, match="3D"):
mask_iou_batch(np.zeros((10, 10), dtype=bool), mask_3d)
with pytest.raises(ValueError, match="3D"):
mask_iou_batch(mask_3d, np.zeros((10, 10), dtype=bool))
with pytest.raises(ValueError, match="3D"):
mask_iou_batch(np.zeros((1, 10, 10, 1), dtype=bool), mask_3d)
def test_identical_disjoint_and_empty(self) -> None:
"""Identical/all-True → 1.0; all-False/disjoint → 0.0; empty keeps shape."""
mask = np.zeros((1, 10, 10), dtype=bool)
mask[0, 2:6, 2:6] = True
far = np.zeros((1, 10, 10), dtype=bool)
far[0, 7:9, 7:9] = True
assert mask_iou_batch(mask, mask)[0, 0] == pytest.approx(1.0)
assert mask_iou_batch(mask, far)[0, 0] == 0.0
assert mask_iou_batch(np.zeros((0, 10, 10), dtype=bool), mask).shape == (0, 1)
# all-True: denominator is non-zero, result must be exactly 1.0
all_true = np.ones((2, 5, 5), dtype=bool)
np.testing.assert_allclose(mask_iou_batch(all_true, all_true), np.ones((2, 2)))
# all-False: denominator is zero; result must be 0.0 (not NaN)
all_false = np.zeros((2, 5, 5), dtype=bool)
np.testing.assert_array_equal(
mask_iou_batch(all_false, all_false), np.zeros((2, 2))
)
def test_matmul_emits_no_runtime_warnings(
self, recwarn: pytest.WarningsChecker
) -> None:
"""mask_iou_batch on bool input must not emit any RuntimeWarning."""
rng = np.random.default_rng(2)
masks_true = rng.random((7, 32, 40)) > 0.5
masks_detection = rng.random((5, 32, 40)) > 0.5
mask_iou_batch(masks_true, masks_detection)
runtime = [w for w in recwarn.list if issubclass(w.category, RuntimeWarning)]
assert not runtime, (
f"Unexpected RuntimeWarnings: {[str(w.message) for w in runtime]}"
)
def test_float64_promotion_for_large_float32_masks(self) -> None:
"""float32 masks with pixel count > 2**24 must give exact IoU via float64."""
# 1x(2**24+1) pixels: float32 area sum rounds, float64 area sum is exact.
# Without CRIT-2 fix, IoU would exceed 1.0 due to dtype mismatch.
mask = np.ones((1, 1, 2**24 + 1), dtype=np.float32)
result = mask_iou_batch(mask, mask)
assert result[0, 0] == pytest.approx(1.0)
def test_compact_mask_mixed_input_gives_same_result(self) -> None:
"""CompactMask + dense mixed dispatch must match dense + dense."""
from supervision.detection.compact_mask import CompactMask
from supervision.detection.utils.converters import mask_to_xyxy
rng = np.random.default_rng(3)
masks = (rng.random((4, 20, 24)) > 0.3).astype(bool)
xyxy = mask_to_xyxy(masks)
cm = CompactMask.from_dense(masks, xyxy, image_shape=(20, 24))
dense_vs_dense = mask_iou_batch(masks, masks)
np.testing.assert_allclose(mask_iou_batch(cm, masks), dense_vs_dense, rtol=1e-5)
np.testing.assert_allclose(mask_iou_batch(masks, cm), dense_vs_dense, rtol=1e-5)
def test_detection_exceeds_limit_warns_and_completes(self) -> None:
"""detection > memory_limit emits UserWarning; result shape is still correct."""
rng = np.random.default_rng(4)
masks_true = rng.random((3, 32, 32)) > 0.5
# 500 x 1024 px x 4 bytes ~2 MB > memory_limit=1 MB triggers OOM warning.
masks_detection = rng.random((500, 32, 32)) > 0.5
with pytest.warns(UserWarning, match="exceed"):
result = mask_iou_batch(masks_true, masks_detection, memory_limit=1)
assert result.shape == (3, 500)
def _reference_jaccard_loop(
boxes_true: list[list[float]],
boxes_detection: list[list[float]],
is_crowd: list[bool],
) -> np.ndarray:
"""Independent per-pair COCO Jaccard reference, shape (len(dt), len(gt))."""
eps = np.spacing(1)
out = np.zeros((len(boxes_detection), len(boxes_true)), dtype=np.float64)
for gt_idx, (gx, gy, gw, gh) in enumerate(boxes_true):
for dt_idx, (dx, dy, dw, dh) in enumerate(boxes_detection):
inter_w = max(min(dx + dw, gx + gw) - max(dx, gx), 0.0)
inter_h = max(min(dy + dh, gy + gh) - max(dy, gy), 0.0)
area_inter = inter_w * inter_h
area_det = max(dw, 0.0) * max(dh, 0.0)
area_gt = max(gw, 0.0) * max(gh, 0.0)
if is_crowd[gt_idx]:
out[dt_idx, gt_idx] = area_inter / (area_det + eps)
else:
out[dt_idx, gt_idx] = area_inter / (
area_det + area_gt - area_inter + eps
)
return out
class TestBoxIouBatchWithJaccard:
"""Verify the vectorized COCO-style Jaccard IoU batch."""
# ~1080 pairs: (1x1) + (3x5=15) + (20x50=1000) + (8x8=64) + (3x4=12)
@pytest.mark.parametrize(
("n_gt", "n_dt", "seed"),
[
pytest.param(1, 1, 1, id="single-pair"),
pytest.param(3, 5, 2, id="small-batch"),
pytest.param(20, 50, 3, id="busy-batch"),
pytest.param(8, 8, 4, id="degenerate-and-crowd"),
pytest.param(3, 4, 5, id="all-crowd-multi-gt"),
],
)
def test_matches_per_pair_reference(self, n_gt: int, n_dt: int, seed: int) -> None:
"""Vectorized output equals an independent per-pair Jaccard loop."""
rng = np.random.default_rng(seed)
def _boxes(n: int) -> list[list[float]]:
xy = rng.uniform(0, 100, (n, 2))
wh = rng.uniform(0, 40, (n, 2))
if seed == 4 and n: # inject zero/negative-width degenerate boxes
wh[rng.integers(0, n), 0] = rng.choice([0.0, -5.0])
return np.hstack([xy, wh]).tolist()
boxes_true, boxes_detection = _boxes(n_gt), _boxes(n_dt)
is_crowd = (rng.random(n_gt) < 0.3).tolist()
result = box_iou_batch_with_jaccard(boxes_true, boxes_detection, is_crowd)
expected = _reference_jaccard_loop(boxes_true, boxes_detection, is_crowd)
assert result.shape == (n_dt, n_gt)
np.testing.assert_allclose(result, expected, atol=1e-12)
def test_crowd_uses_detection_area_as_union(self) -> None:
"""A crowd gt enclosing the detection scores 1.0 (union == detection area)."""
boxes_true = [[0.0, 0.0, 100.0, 100.0]]
boxes_detection = [[10.0, 10.0, 20.0, 20.0]] # fully inside the gt
crowd = box_iou_batch_with_jaccard(boxes_true, boxes_detection, [True])
non_crowd = box_iou_batch_with_jaccard(boxes_true, boxes_detection, [False])
assert crowd[0, 0] == pytest.approx(1.0)
assert non_crowd[0, 0] == pytest.approx(0.04) # 400 / 10000
@pytest.mark.parametrize(
("boxes_true", "boxes_detection", "is_crowd", "expected_iou"),
[
pytest.param(
[[0.0, 0.0, 10.0, 10.0]],
[[20.0, 20.0, 10.0, 10.0]],
[False],
0.0,
id="non-overlapping-non-crowd",
),
pytest.param(
[[0.0, 0.0, 10.0, 10.0]],
[[0.0, 0.0, 10.0, 10.0]],
[False],
1.0,
id="identical-non-crowd",
),
],
)
def test_non_crowd_uses_standard_iou(
self,
boxes_true: list[list[float]],
boxes_detection: list[list[float]],
is_crowd: list[bool],
expected_iou: float,
) -> None:
"""Non-crowd GTs use standard IoU (intersection / union)."""
result = box_iou_batch_with_jaccard(boxes_true, boxes_detection, is_crowd)
assert result[0, 0] == pytest.approx(expected_iou)
def test_all_crowd_multi_gt_matches_reference(self) -> None:
"""All-crowd 3-GT x 4-det batch matches per-pair reference loop."""
rng = np.random.default_rng(5)
n_gt, n_dt = 3, 4
def _boxes(n: int) -> list[list[float]]:
xy = rng.uniform(0, 100, (n, 2))
wh = rng.uniform(1, 40, (n, 2))
return np.hstack([xy, wh]).tolist()
boxes_true = _boxes(n_gt)
boxes_detection = _boxes(n_dt)
is_crowd = [True] * n_gt
result = box_iou_batch_with_jaccard(boxes_true, boxes_detection, is_crowd)
expected = _reference_jaccard_loop(boxes_true, boxes_detection, is_crowd)
assert result.shape == (n_dt, n_gt)
np.testing.assert_allclose(result, expected, atol=1e-12)
@pytest.mark.parametrize(
("n_gt", "n_dt"),
[pytest.param(0, 3, id="empty-gt"), pytest.param(3, 0, id="empty-dt")],
)
def test_empty_input_returns_empty_array(self, n_gt: int, n_dt: int) -> None:
"""Empty gt or detections yields an empty array, preserving the contract."""
boxes_true = [[0.0, 0.0, 10.0, 10.0]] * n_gt
boxes_detection = [[0.0, 0.0, 10.0, 10.0]] * n_dt
result = box_iou_batch_with_jaccard(boxes_true, boxes_detection, [False] * n_gt)
assert result.size == 0
def test_mismatched_is_crowd_length_raises(self) -> None:
"""`is_crowd` must align with `boxes_true`."""
with pytest.raises(ValueError, match="`is_crowd` length"):
box_iou_batch_with_jaccard(
[[0.0, 0.0, 1.0, 1.0]], [[0.0, 0.0, 1.0, 1.0]], []
)
# ---------------------------------------------------------------------------
# box_non_max_merge
# ---------------------------------------------------------------------------
class TestBoxNonMaxMerge:
"""box_non_max_merge groups overlapping boxes into clusters."""
def test_empty_input_returns_empty_list(self) -> None:
"""An empty predictions array produces no merge groups."""
predictions = np.empty((0, 5), dtype=np.float32)
result = box_non_max_merge(predictions, iou_threshold=0.5)
assert result == []
def test_non_overlapping_boxes_each_own_group(self) -> None:
"""Boxes with zero overlap remain in separate singleton groups."""
predictions = np.array(
[
[0, 0, 10, 10, 0.9],
[20, 20, 30, 30, 0.8],
[50, 50, 60, 60, 0.7],
],
dtype=np.float32,
)
result = box_non_max_merge(predictions, iou_threshold=0.5)
assert len(result) == 3
assert all(len(g) == 1 for g in result)
def test_identical_boxes_merged_into_one_group(self) -> None:
"""Perfectly overlapping boxes (IoU=1) are merged into a single group."""
predictions = np.array(
[
[0, 0, 10, 10, 0.9],
[0, 0, 10, 10, 0.8],
[0, 0, 10, 10, 0.7],
],
dtype=np.float32,
)
result = box_non_max_merge(predictions, iou_threshold=0.5)
assert len(result) == 1
assert len(result[0]) == 3
def test_two_distinct_clusters(self) -> None:
"""Two groups of overlapping boxes produce exactly two merge groups."""
predictions = np.array(
[
[0, 0, 10, 10, 0.9],
[1, 1, 11, 11, 0.85],
[50, 50, 60, 60, 0.8],
[51, 51, 61, 61, 0.75],
],
dtype=np.float32,
)
result = box_non_max_merge(predictions, iou_threshold=0.3)
assert len(result) == 2
group_sizes = sorted(len(g) for g in result)
assert group_sizes == [2, 2]
def test_6col_different_class_ids_not_merged(self) -> None:
"""Identical boxes with different class_ids stay in separate groups."""
predictions = np.array(
[
[0, 0, 10, 10, 0.9, 0],
[0, 0, 10, 10, 0.8, 1],
],
dtype=np.float32,
)
result = box_non_max_merge(predictions, iou_threshold=0.5)
assert len(result) == 2
assert all(len(g) == 1 for g in result)
def test_6col_same_class_id_merged(self) -> None:
"""Identical boxes with the same class_id are merged into one group."""
predictions = np.array(
[
[0, 0, 10, 10, 0.9, 0],
[0, 0, 10, 10, 0.8, 0],
],
dtype=np.float32,
)
result = box_non_max_merge(predictions, iou_threshold=0.5)
assert len(result) == 1
assert len(result[0]) == 2