diff --git a/src/supervision/metrics/detection.py b/src/supervision/metrics/detection.py index 6b478fd7..d251cbc3 100644 --- a/src/supervision/metrics/detection.py +++ b/src/supervision/metrics/detection.py @@ -1364,9 +1364,13 @@ class MeanAveragePrecision: targets: Each element of the list describes a single image and has `shape = (N, 5)` where `N` is the number of ground-truth objects. Each row is expected to be in - `(x_min, y_min, x_max, y_max, class)` format. + `(x_min, y_min, x_max, y_max, class)` format. An empty array + (``N = 0``) represents a background image; all predictions on + that image count as false positives and reduce AP accordingly. + Returns: - New instance of MeanAveragePrecision. + MeanAveragePrecision: New instance computed from the provided + predictions and targets. Examples: ```pycon @@ -1393,6 +1397,15 @@ class MeanAveragePrecision: >>> round(float(mAP.map50), 2) 0.81 + >>> bg_pred = [np.array([[0., 0., 10., 10., 0, 0.9]], dtype=np.float32)] + >>> bg_tgt = [np.zeros((0, 5), dtype=np.float32)] + >>> mAP_bg = sv.MeanAveragePrecision.from_tensors( + ... predictions=bg_pred, + ... targets=bg_tgt, + ... ) + >>> float(mAP_bg.map50) + 0.0 + ``` """ _validate_input_tensors(predictions, targets) @@ -1424,6 +1437,19 @@ class MeanAveragePrecision: true_objs[:, 4], ) ) + else: + # Predictions on a ground-truth-empty (background) image are all + # false positives; record them so precision/AP is penalized. + stats.append( + ( + np.zeros( + (predicted_objs.shape[0], iou_thresholds.size), dtype=bool + ), + predicted_objs[:, 5], + predicted_objs[:, 4], + np.zeros((0,), dtype=np.float32), + ) + ) # Compute average precisions if any matches exist if stats: @@ -1434,9 +1460,19 @@ class MeanAveragePrecision: cast(npt.NDArray[np.int32], concatenated_stats[2]), cast(npt.NDArray[np.int32], concatenated_stats[3]), ) - map50 = average_precisions[:, 0].mean() - map75 = average_precisions[:, 5].mean() - map50_95 = average_precisions.mean() + map50 = ( + float(average_precisions[:, 0].mean()) + if average_precisions.size > 0 + else 0.0 + ) + map75 = ( + float(average_precisions[:, 5].mean()) + if average_precisions.size > 0 + else 0.0 + ) + map50_95 = ( + float(average_precisions.mean()) if average_precisions.size > 0 else 0.0 + ) else: map50, map75, map50_95 = 0, 0, 0 average_precisions = np.array([]) diff --git a/tests/metrics/test_detection.py b/tests/metrics/test_detection.py index 2a016e00..71b64a43 100644 --- a/tests/metrics/test_detection.py +++ b/tests/metrics/test_detection.py @@ -1633,6 +1633,94 @@ class TestDetectionMetrics: assert result.map50 == pytest.approx(1.0, abs=0.01) +class TestMeanAveragePrecisionBackgroundFalsePositives: + """MeanAveragePrecision.from_tensors penalizes predictions on GT-empty images.""" + + def test_background_false_positives_lower_map(self) -> None: + """False positives on a GT-empty image drop map50 below the FP-free baseline.""" + # Arrange + matched_target = np.array([[0.0, 0.0, 10.0, 10.0, 0]], dtype=np.float32) + matched_prediction = np.array( + [[0.0, 0.0, 10.0, 10.0, 0, 0.9]], dtype=np.float32 + ) + background_target = np.zeros((0, 5), dtype=np.float32) + background_predictions = np.array( + [ + [100.0, 100.0, 110.0, 110.0, 0, 0.95], + [200.0, 200.0, 210.0, 210.0, 0, 0.95], + [300.0, 300.0, 310.0, 310.0, 0, 0.95], + ], + dtype=np.float32, + ) + + # Act + without_fp = MeanAveragePrecision.from_tensors( + predictions=[matched_prediction], + targets=[matched_target], + ) + with_fp = MeanAveragePrecision.from_tensors( + predictions=[matched_prediction, background_predictions], + targets=[matched_target, background_target], + ) + + # Assert + assert without_fp.map50 == pytest.approx(1.0, abs=0.01) + assert with_fp.map50 < without_fp.map50 + assert with_fp.map50 < 0.5 + assert with_fp.map75 < without_fp.map75 + assert with_fp.map50_95 < without_fp.map50_95 + + def test_ground_truth_present_path_unchanged(self) -> None: + """GT-present scenario keeps its pinned map50 (guards normal-path numerics).""" + # Arrange + targets = [ + np.array( + [ + [0.0, 0.0, 3.0, 3.0, 0], + [2.0, 2.0, 5.0, 5.0, 0], + [6.0, 1.0, 8.0, 3.0, 1], + ], + dtype=np.float32, + ) + ] + predictions = [ + np.array( + [ + [0.0, 0.0, 3.0, 3.0, 0, 0.9], + [0.1, 0.1, 3.0, 3.0, 0, 0.9], + [6.0, 1.0, 8.0, 3.0, 1, 0.8], + ], + dtype=np.float32, + ) + ] + + # Act + result = MeanAveragePrecision.from_tensors( + predictions=predictions, targets=targets + ) + + # Assert + assert round(float(result.map50), 2) == 0.81 + + def test_all_background_images_return_zero_not_nan(self) -> None: + """Dataset with only background images must return map50=0, not NaN.""" + # Arrange + background_pred = np.array([[0.0, 0.0, 10.0, 10.0, 0, 0.9]], dtype=np.float32) + background_tgt = np.zeros((0, 5), dtype=np.float32) + + # Act + result = MeanAveragePrecision.from_tensors( + predictions=[background_pred], + targets=[background_tgt], + ) + + # Assert + assert not np.isnan(result.map50), ( + "map50 must not be NaN for all-background dataset" + ) + assert result.map50 == pytest.approx(0.0) + + class TestSplitDetectionsByOutcome: """Tests for _split_detections_by_outcome matching and filtering logic."""