fix(metrics): count false positives on empty-GT images (#2397)

- Fixed mAP calculation to count predictions on background-only images as false positives
- Fixed all-background mAP inputs to return 0.0 instead of NaN when no ground-truth classes exist
- Updated `from_tensors` documentation to define empty-target background images and their false-positive behavior

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
Jirka Borovec 2026-07-03 16:17:08 +02:00 committed by GitHub
parent f173905c8b
commit 0e1056df19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 129 additions and 5 deletions

View File

@ -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([])

View File

@ -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."""