improve docstrings, examples, variable naming conventions and unit tests

This commit is contained in:
SkalskiP 2025-11-14 11:57:31 +01:00
parent eee6a85eaa
commit f716b7fa3b
3 changed files with 608 additions and 177 deletions

View File

@ -64,7 +64,7 @@ class OverlapMetric(Enum):
IOS = "IOS"
@classmethod
def list(cls):
def list(cls) -> list[str]:
return list(map(lambda c: c.value, cls))
@classmethod
@ -72,7 +72,7 @@ class OverlapMetric(Enum):
if isinstance(value, cls):
return value
if isinstance(value, str):
value = value.lower()
value = value.upper()
try:
return cls(value)
except ValueError:
@ -86,91 +86,107 @@ class OverlapMetric(Enum):
def box_iou(
box_true: list[float] | np.ndarray,
box_detection: list[float] | np.ndarray,
overlap_metric: OverlapMetric | str = OverlapMetric.IOU,
) -> float:
r"""
Compute the Intersection over Union (IoU) between two bounding boxes.
"""
Compute overlap metric between two bounding boxes.
\[
\text{IoU} = \frac{|\text{box}_{\text{true}} \cap \text{box}_{\text{detection}}|}{|\text{box}_{\text{true}} \cup \text{box}_{\text{detection}}|}
\]
Note:
Use `box_iou` when computing IoU between two individual boxes.
For comparing multiple boxes (arrays of boxes), use `box_iou_batch` for better
performance.
Supports standard IOU (intersection-over-union) and IOS
(intersection-over-smaller-area) metrics. Returns the overlap value in range
`[0, 1]`.
Args:
box_true (Union[List[float], np.ndarray]): A single bounding box represented as
[x_min, y_min, x_max, y_max].
box_detection (Union[List[float], np.ndarray]):
A single bounding box represented as [x_min, y_min, x_max, y_max].
box_true (`list[float]` or `numpy.array`): Ground truth box in format
`(x_min, y_min, x_max, y_max)`.
box_detection (`list[float]` or `numpy.array`): Detected box in format
`(x_min, y_min, x_max, y_max)`.
overlap_metric (`OverlapMetric` or `str`): Overlap type.
Use `OverlapMetric.IOU` for IOU or
`OverlapMetric.IOS` for IOS. Defaults to `OverlapMetric.IOU`.
Returns:
IoU (float): IoU score between the two boxes. Ranges from 0.0 (no overlap)
to 1.0 (perfect overlap).
(`float`): Overlap value between boxes in `[0, 1]`.
Raises:
ValueError: If `overlap_metric` is not IOU or IOS.
Examples:
```python
import numpy as np
```
import supervision as sv
box_true = np.array([100, 100, 200, 200])
box_detection = np.array([150, 150, 250, 250])
box_true = [100, 100, 200, 200]
box_detection = [150, 150, 250, 250]
sv.box_iou(box_true=box_true, box_detection=box_detection)
# 0.14285814285714285
sv.box_iou(box_true, box_detection, overlap_metric=sv.OverlapMetric.IOU)
# 0.14285714285714285
sv.box_iou(box_true, box_detection, overlap_metric=sv.OverlapMetric.IOS)
# 0.25
```
""" # noqa: E501
box_true = np.array(box_true)
box_detection = np.array(box_detection)
"""
overlap_metric = OverlapMetric.from_value(overlap_metric)
x_min_true, y_min_true, x_max_true, y_max_true = np.array(box_true)
x_min_det, y_min_det, x_max_det, y_max_det = np.array(box_detection)
inter_x1 = max(box_true[0], box_detection[0])
inter_y1 = max(box_true[1], box_detection[1])
inter_x2 = min(box_true[2], box_detection[2])
inter_y2 = min(box_true[3], box_detection[3])
x_min_inter = max(x_min_true, x_min_det)
y_min_inter = max(y_min_true, y_min_det)
x_max_inter = min(x_max_true, x_max_det)
y_max_inter = min(y_max_true, y_max_det)
inter_w = max(0, inter_x2 - inter_x1)
inter_h = max(0, inter_y2 - inter_y1)
inter_w = max(0.0, x_max_inter - x_min_inter)
inter_h = max(0.0, y_max_inter - y_min_inter)
inter_area = inter_w * inter_h
area_inter = inter_w * inter_h
area_true = (box_true[2] - box_true[0]) * (box_true[3] - box_true[1])
area_detection = (box_detection[2] - box_detection[0]) * (
box_detection[3] - box_detection[1]
)
area_true = (x_max_true - x_min_true) * (y_max_true - y_min_true)
area_det = (x_max_det - x_min_det) * (y_max_det - y_min_det)
union_area = area_true + area_detection - inter_area
if overlap_metric == OverlapMetric.IOU:
area_norm = area_true + area_det - area_inter
elif overlap_metric == OverlapMetric.IOS:
area_norm = min(area_true, area_det)
else:
raise ValueError(
f"overlap_metric {overlap_metric} is not supported, "
"only 'IOU' and 'IOS' are supported"
)
return inter_area / union_area + 1e-6
if area_norm <= 0.0:
return 0.0
return float(area_inter / area_norm)
def box_iou_batch(
boxes_true: np.ndarray,
boxes_detection: np.ndarray,
overlap_metric: OverlapMetric = OverlapMetric.IOU,
overlap_metric: OverlapMetric | str = OverlapMetric.IOU,
) -> np.ndarray:
"""
Compute Intersection over Union (IoU) of two sets of bounding boxes -
`boxes_true` and `boxes_detection`. Both sets
of boxes are expected to be in `(x_min, y_min, x_max, y_max)` format.
Compute pairwise overlap scores between batches of bounding boxes.
Note:
Use `box_iou` when computing IoU between two individual boxes.
For comparing multiple boxes (arrays of boxes), use `box_iou_batch` for better
performance.
Supports standard IOU (intersection-over-union) and IOS
(intersection-over-smaller-area) metrics for all `boxes_true` and
`boxes_detection` pairs. Returns a matrix of overlap values in range
`[0, 1]`, matching each box from the first batch to each from the second.
Args:
boxes_true (np.ndarray): 2D `np.ndarray` representing ground-truth boxes.
`shape = (N, 4)` where `N` is number of true objects.
boxes_detection (np.ndarray): 2D `np.ndarray` representing detection boxes.
`shape = (M, 4)` where `M` is number of detected objects.
overlap_metric (OverlapMetric): Metric used to compute the degree of overlap
between pairs of boxes (e.g., IoU, IoS).
boxes_true (`numpy.array`): Array of reference boxes in
shape `(N, 4)` as `(x_min, y_min, x_max, y_max)`.
boxes_detection (`numpy.array`): Array of detected boxes in
shape `(M, 4)` as `(x_min, y_min, x_max, y_max)`.
overlap_metric (`OverlapMetric` or `str`): Overlap type.
Use `OverlapMetric.IOU` for intersection-over-union,
`OverlapMetric.IOS` for intersection-over-smaller-area.
Defaults to `OverlapMetric.IOU`.
Returns:
np.ndarray: Pairwise IoU of boxes from `boxes_true` and `boxes_detection`.
`shape = (N, M)` where `N` is number of true objects and
`M` is number of detected objects.
(`numpy.array`): Overlap matrix of shape `(N, M)`, where entry
`[i, j]` is the overlap between `boxes_true[i]` and
`boxes_detection[j]`.
Raises:
ValueError: If `overlap_metric` is not IOU or IOS.
Examples:
```python
@ -186,43 +202,48 @@ def box_iou_batch(
[320, 320, 420, 420]
])
sv.box_iou_batch(boxes_true=boxes_true, boxes_detection=boxes_detection)
# array([
# [0.14285714, 0. ],
# [0. , 0.47058824]
# ])
sv.box_iou_batch(boxes_true, boxes_detection, overlap_metric=OverlapMetric.IOU)
# array([[0.14285715, 0. ],
# [0. , 0.47058824]])
sv.box_iou_batch(boxes_true, boxes_detection, overlap_metric=OverlapMetric.IOS)
# array([[0.25, 0. ],
# [0. , 0.64]])
```
"""
overlap_metric = OverlapMetric.from_value(overlap_metric)
x_min_true, y_min_true, x_max_true, y_max_true = boxes_true.T
x_min_det, y_min_det, x_max_det, y_max_det = boxes_detection.T
count_true, count_det = boxes_true.shape[0], boxes_detection.shape[0]
tx1, ty1, tx2, ty2 = boxes_true.T
dx1, dy1, dx2, dy2 = boxes_detection.T
N, M = boxes_true.shape[0], boxes_detection.shape[0]
if count_true == 0 or count_det == 0:
return np.empty((count_true, count_det), dtype=np.float32)
top_left_x = np.empty((N, M), dtype=np.float32)
bottom_right_x = np.empty_like(top_left_x)
top_left_y = np.empty_like(top_left_x)
bottom_right_y = np.empty_like(top_left_x)
x_min_inter = np.empty((count_true, count_det), dtype=np.float32)
x_max_inter = np.empty_like(x_min_inter)
y_min_inter = np.empty_like(x_min_inter)
y_max_inter = np.empty_like(x_min_inter)
np.maximum(tx1[:, None], dx1[None, :], out=top_left_x)
np.minimum(tx2[:, None], dx2[None, :], out=bottom_right_x)
np.maximum(ty1[:, None], dy1[None, :], out=top_left_y)
np.minimum(ty2[:, None], dy2[None, :], out=bottom_right_y)
np.maximum(x_min_true[:, None], x_min_det[None, :], out=x_min_inter)
np.minimum(x_max_true[:, None], x_max_det[None, :], out=x_max_inter)
np.maximum(y_min_true[:, None], y_min_det[None, :], out=y_min_inter)
np.minimum(y_max_true[:, None], y_max_det[None, :], out=y_max_inter)
np.subtract(bottom_right_x, top_left_x, out=bottom_right_x) # W
np.subtract(bottom_right_y, top_left_y, out=bottom_right_y) # H
np.clip(bottom_right_x, 0.0, None, out=bottom_right_x)
np.clip(bottom_right_y, 0.0, None, out=bottom_right_y)
# we reuse x_max_inter and y_max_inter to store inter_w and inter_h
np.subtract(x_max_inter, x_min_inter, out=x_max_inter) # inter_w
np.subtract(y_max_inter, y_min_inter, out=y_max_inter) # inter_h
np.clip(x_max_inter, 0.0, None, out=x_max_inter)
np.clip(y_max_inter, 0.0, None, out=y_max_inter)
area_inter = bottom_right_x * bottom_right_y
area_inter = x_max_inter * y_max_inter # inter_w * inter_h
area_true = (tx2 - tx1) * (ty2 - ty1)
area_detection = (dx2 - dx1) * (dy2 - dy1)
area_true = (x_max_true - x_min_true) * (y_max_true - y_min_true)
area_det = (x_max_det - x_min_det) * (y_max_det - y_min_det)
if overlap_metric == OverlapMetric.IOU:
denom = area_true[:, None] + area_detection[None, :] - area_inter
area_norm = area_true[:, None] + area_det[None, :] - area_inter
elif overlap_metric == OverlapMetric.IOS:
denom = np.minimum(area_true[:, None], area_detection[None, :])
area_norm = np.minimum(area_true[:, None], area_det[None, :])
else:
raise ValueError(
f"overlap_metric {overlap_metric} is not supported, "
@ -230,7 +251,7 @@ def box_iou_batch(
)
out = np.zeros_like(area_inter, dtype=np.float32)
np.divide(area_inter, denom, out=out, where=denom > 0)
np.divide(area_inter, area_norm, out=out, where=area_norm > 0)
return out

View File

@ -12,8 +12,9 @@ from supervision.detection.utils.iou_and_nms import (
box_non_max_suppression,
mask_non_max_merge,
mask_non_max_suppression,
OverlapMetric
)
from test.test_utils import mock_boxes
from test.test_utils import random_boxes
@pytest.mark.parametrize(
@ -637,102 +638,502 @@ def test_mask_non_max_merge(
@pytest.mark.parametrize(
"boxes_true, boxes_detection, expected_iou, exception",
"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],
[0.0, 0.0, 10.0, 10.0],
"IOU",
1.0,
DoesNotRaise(),
), # identical boxes, both boxes are arrays, IOS 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(),
), # empty
),
# one true box, no detections
(
np.array([[0, 0, 10, 10]], dtype=np.float32),
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(),
), # one true box, no detections
),
# no true boxes, one detection
(
np.empty((0, 4), dtype=np.float32),
np.array([[0, 0, 10, 10]], 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(),
), # no true boxes, one detection
),
# 1x1 partial overlap, IOU
(
np.array([[0, 0, 10, 10]], dtype=np.float32),
np.array([[0, 0, 10, 10]], dtype=np.float32),
np.array([[1.0]]),
DoesNotRaise(),
), # perfect overlap
(
np.array([[0, 0, 10, 10]], dtype=np.float32),
np.array([[20, 20, 30, 30]], dtype=np.float32),
np.array([[0.0]]),
DoesNotRaise(),
), # no overlap
(
np.array([[0, 0, 10, 10]], dtype=np.float32),
np.array([[5, 5, 15, 15]], dtype=np.float32),
np.array([[25.0 / 175.0]]), # intersection: 5x5=25, union: 100+100-25=175
DoesNotRaise(),
), # partial overlap
(
np.array([[0, 0, 10, 10]], dtype=np.float32),
np.array([[0, 0, 5, 5]], dtype=np.float32),
np.array([[25.0 / 100.0]]), # intersection: 5x5=25, union: 100
DoesNotRaise(),
), # detection inside true box
(
np.array([[0, 0, 5, 5]], dtype=np.float32),
np.array([[0, 0, 10, 10]], dtype=np.float32),
np.array([[25.0 / 100.0]]), # true box inside detection
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([[0, 0, 10, 10], [20, 20, 30, 30]], dtype=np.float32),
np.array([[0, 0, 10, 10], [20, 20, 30, 30]], dtype=np.float32),
np.array([[1.0, 0.0], [0.0, 1.0]]),
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(),
), # two boxes, perfect matches
),
# 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),
),
],
)
def test_box_iou_batch(
boxes_true: np.ndarray,
boxes_detection: np.ndarray,
expected_iou: np.ndarray,
overlap_metric: str | OverlapMetric,
expected_overlap: np.ndarray | None,
exception: Exception,
) -> None:
with exception:
result = box_iou_batch(boxes_true, boxes_detection)
assert result.shape == expected_iou.shape
assert np.allclose(result, expected_iou, rtol=1e-5, atol=1e-5)
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,
)
def test_box_iou_batch_consistency_with_box_iou():
"""Test that box_iou_batch gives same results as box_iou for single boxes."""
boxes_true = np.array(mock_boxes(5, seed=1), dtype=np.float32)
boxes_detection = np.array(mock_boxes(5, seed=2), dtype=np.float32)
@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 = random_boxes(num_true)
boxes_det = random_boxes(num_det)
batch_result = box_iou_batch(boxes_true, boxes_detection)
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,
)
for i, box_true in enumerate(boxes_true):
for j, box_detection in enumerate(boxes_detection):
single_result = box_iou(box_true, box_detection)
assert np.allclose(batch_result[i, j], single_result, rtol=1e-5, atol=1e-5)
def test_box_iou_batch_with_mock_detections():
"""Test box_iou_batch with generated boxes and verify results are valid."""
boxes_true = np.array(mock_boxes(10, seed=1), dtype=np.float32)
boxes_detection = np.array(mock_boxes(15, seed=2), dtype=np.float32)
result = box_iou_batch(boxes_true, boxes_detection)
assert result.shape == (10, 15)
assert np.all(result >= 0)
assert np.all(result <= 1.0)
# and symmetric
result_reversed = box_iou_batch(boxes_detection, boxes_true)
assert result_reversed.shape == (15, 10)
assert np.allclose(result.T, result_reversed, rtol=1e-5, atol=1e-5)
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,
)

View File

@ -53,39 +53,48 @@ def mock_key_points(
)
def mock_boxes(
n: int,
resolution_wh: tuple[int, int] = (1920, 1080),
min_size: int = 20,
max_size: int = 200,
def random_boxes(
count: int,
image_size: tuple[int, int] = (1920, 1080),
min_box_size: int = 20,
max_box_size: int = 200,
seed: int | None = None,
) -> list[list[float]]:
) -> np.ndarray:
"""
Generate N valid bounding boxes of format [x_min, y_min, x_max, y_max].
Generate random bounding boxes within given image dimensions and size constraints.
Creates `count` bounding boxes randomly positioned and sized, ensuring each
stays within image bounds and has width and height in the specified range.
Args:
n: Number of boxes to generate.
resolution_wh: Image resolution as (width, height). Defaults to (1920, 1080).
min_size: Minimum box size (width/height). Defaults to 20.
max_size: Maximum box size (width/height). Defaults to 200.
seed: Random seed for reproducibility. Defaults to None.
count (`int`): Number of random bounding boxes to generate.
image_size (`tuple[int, int]`): Image size as `(width, height)`. Defaults to `(1920, 1080)`.
min_box_size (`int`): Minimum side length (pixels) for generated boxes. Defaults to `20`.
max_box_size (`int`): Maximum side length (pixels) for generated boxes. Defaults to `200`.
seed (`int` or `None`): Optional random seed for reproducibility. Defaults to `None`.
Returns:
List of boxes, each as [x_min, y_min, x_max, y_max].
(`numpy.ndarray`): Array of shape `(count, 4)` with bounding boxes as
`(x_min, y_min, x_max, y_max)`.
"""
if seed is not None:
random.seed(seed)
width, height = resolution_wh
boxes = []
for _ in range(n):
w = random.uniform(min_size, max_size)
h = random.uniform(min_size, max_size)
x1 = random.uniform(0, width - w)
y1 = random.uniform(0, height - h)
x2 = x1 + w
y2 = y1 + h
boxes.append([x1, y1, x2, y2])
return boxes
img_w, img_h = image_size
out = np.zeros((count, 4), dtype=np.float32)
for i in range(count):
w = random.uniform(min_box_size, max_box_size)
h = random.uniform(min_box_size, max_box_size)
x_min = random.uniform(0, img_w - w)
y_min = random.uniform(0, img_h - h)
x_max = x_min + w
y_max = y_min + h
out[i] = (x_min, y_min, x_max, y_max)
return out
def assert_almost_equal(actual, expected, tolerance=1e-5):