diff --git a/supervision/detection/utils/iou_and_nms.py b/supervision/detection/utils/iou_and_nms.py index 299a6160..f4c68a03 100644 --- a/supervision/detection/utils/iou_and_nms.py +++ b/supervision/detection/utils/iou_and_nms.py @@ -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 diff --git a/test/detection/utils/test_iou_and_nms.py b/test/detection/utils/test_iou_and_nms.py index 29c11dde..964e7345 100644 --- a/test/detection/utils/test_iou_and_nms.py +++ b/test/detection/utils/test_iou_and_nms.py @@ -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, + ) diff --git a/test/test_utils.py b/test/test_utils.py index e512de6f..424d4a3c 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -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):