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:
parent
f173905c8b
commit
0e1056df19
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue