9194ef5abd
Docs/Test Workflow / Test docs build (push) Failing after 0s
Check links & references / links-check (push) Failing after 1s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.10) (push) Failing after 0s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.11) (push) Failing after 0s
PR Conflict Labeler / main (push) Failing after 2s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.12) (push) Failing after 2s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.13) (push) Failing after 0s
Pytest/Test Workflow / Build this Package (push) Failing after 5s
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / testing-guardian (push) Has been cancelled
358 lines
14 KiB
Python
358 lines
14 KiB
Python
import numpy as np
|
|
import pytest
|
|
|
|
from supervision.detection.compact_mask import CompactMask
|
|
from supervision.detection.core import Detections
|
|
from supervision.metrics.core import AveragingMethod, MetricTarget
|
|
from supervision.metrics.recall import Recall
|
|
from tests.helpers import assert_almost_equal
|
|
|
|
|
|
class TestRecall:
|
|
@pytest.fixture
|
|
def predictions_multiple_classes(self):
|
|
return Detections(
|
|
xyxy=np.array(
|
|
[
|
|
[10, 10, 50, 50], # class 0, matches first target
|
|
[200, 200, 240, 240], # class 1, matches target
|
|
],
|
|
dtype=np.float32,
|
|
),
|
|
confidence=np.array([0.9, 0.8]),
|
|
class_id=np.array([0, 1]),
|
|
)
|
|
|
|
@pytest.fixture
|
|
def targets_multiple_classes(self):
|
|
return Detections(
|
|
xyxy=np.array(
|
|
[
|
|
[10, 10, 50, 50], # class 0, matched
|
|
[60, 60, 100, 100], # class 0, missed
|
|
[200, 200, 240, 240], # class 1, matched
|
|
],
|
|
dtype=np.float32,
|
|
),
|
|
class_id=np.array([0, 0, 1]),
|
|
)
|
|
|
|
def test_initialization_default(self):
|
|
"""Test that Recall can be initialized with default parameters"""
|
|
metric = Recall()
|
|
assert metric._metric_target == MetricTarget.BOXES
|
|
assert metric.averaging_method == AveragingMethod.WEIGHTED
|
|
assert metric._predictions_list == []
|
|
assert metric._targets_list == []
|
|
|
|
def test_initialization_custom(self):
|
|
"""Test that Recall can be initialized with custom parameters"""
|
|
metric = Recall(
|
|
metric_target=MetricTarget.MASKS,
|
|
averaging_method=AveragingMethod.MACRO,
|
|
)
|
|
assert metric._metric_target == MetricTarget.MASKS
|
|
assert metric.averaging_method == AveragingMethod.MACRO
|
|
|
|
def test_mask_content_preserves_compact_mask(self) -> None:
|
|
"""CompactMask inputs stay compact for mask IoU."""
|
|
dense_mask = np.zeros((1, 4, 5), dtype=bool)
|
|
dense_mask[0, 1:3, 1:4] = True
|
|
xyxy = np.array([[1, 1, 4, 3]], dtype=np.float64)
|
|
compact_mask = CompactMask.from_dense(
|
|
dense_mask, xyxy=xyxy, image_shape=dense_mask.shape[1:]
|
|
)
|
|
detections = Detections(xyxy=xyxy, mask=compact_mask)
|
|
metric = Recall(metric_target=MetricTarget.MASKS)
|
|
|
|
content = metric._detections_content(detections)
|
|
|
|
assert content is compact_mask
|
|
|
|
def test_compute_with_compact_mask_matches_dense(self) -> None:
|
|
"""Recall.compute() yields same recall_at_50 for CompactMask and dense."""
|
|
masks = np.zeros((1, 50, 50), dtype=bool)
|
|
masks[0, 10:20, 10:20] = True
|
|
xyxy = np.array([[10, 10, 19, 19]], dtype=np.float64)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(50, 50))
|
|
det_dense = Detections(
|
|
xyxy=xyxy, mask=masks, confidence=np.array([0.9]), class_id=np.array([0])
|
|
)
|
|
det_compact = Detections(
|
|
xyxy=xyxy, mask=cm, confidence=np.array([0.9]), class_id=np.array([0])
|
|
)
|
|
metric = Recall(metric_target=MetricTarget.MASKS)
|
|
|
|
r_dense = metric.update(det_dense, det_dense).compute()
|
|
metric.reset()
|
|
r_compact = metric.update(det_compact, det_compact).compute()
|
|
|
|
assert r_dense.recall_at_50 == pytest.approx(r_compact.recall_at_50)
|
|
|
|
def test_reset(self, dummy_prediction):
|
|
"""Test that reset() clears all stored data"""
|
|
metric = Recall()
|
|
|
|
# Add some dummy data
|
|
metric.update(dummy_prediction, dummy_prediction)
|
|
|
|
# Verify data was added
|
|
assert len(metric._predictions_list) == 1
|
|
assert len(metric._targets_list) == 1
|
|
|
|
# Reset and verify lists are empty
|
|
metric.reset()
|
|
assert metric._predictions_list == []
|
|
assert metric._targets_list == []
|
|
|
|
def test_perfect_match(self, detections_50_50, targets_50_50):
|
|
"""Test recall with perfect matching predictions and targets"""
|
|
metric = Recall()
|
|
result = metric.update(detections_50_50, targets_50_50).compute()
|
|
|
|
# Perfect match should give recall = 1.0
|
|
# TP = 1, FN = 0 -> recall = TP / (TP + FN) = 1 / 1 = 1.0
|
|
assert result.recall_at_50 == 1.0
|
|
assert result.recall_at_75 == 1.0
|
|
assert len(result.matched_classes) == 1
|
|
assert result.matched_classes[0] == 0
|
|
|
|
def test_no_overlap(self, predictions_no_overlap, targets_no_overlap):
|
|
"""Test recall with predictions that don't overlap with targets"""
|
|
metric = Recall()
|
|
result = metric.update(predictions_no_overlap, targets_no_overlap).compute()
|
|
|
|
# No overlap means no TP, only FN
|
|
# TP = 0, FN = 1 -> recall = TP / (TP + FN) = 0 / 1 = 0.0
|
|
assert result.recall_at_50 == 0.0
|
|
assert result.recall_at_75 == 0.0
|
|
|
|
def test_empty_predictions(self, targets_50_50):
|
|
"""Test recall with empty predictions but existing targets"""
|
|
predictions = Detections.empty()
|
|
|
|
metric = Recall()
|
|
result = metric.update(predictions, targets_50_50).compute()
|
|
|
|
# No predictions means TP = 0, FN = 1 -> recall = 0 / 1 = 0.0
|
|
assert result.recall_at_50 == 0.0
|
|
assert result.recall_at_75 == 0.0
|
|
|
|
def test_empty_targets(self, detections_50_50):
|
|
"""Test recall with predictions but no targets"""
|
|
targets = Detections.empty()
|
|
|
|
metric = Recall()
|
|
result = metric.update(detections_50_50, targets).compute()
|
|
|
|
# No targets means TP = 0, FN = 0 -> recall = 0 / 0 = 0
|
|
assert result.recall_at_50 == 0.0
|
|
assert result.recall_at_75 == 0.0
|
|
|
|
def test_medium_bucket_scores_target_matched_small_prediction(self) -> None:
|
|
"""Medium-object recall keeps valid matches even if the prediction is small."""
|
|
predictions = Detections(
|
|
xyxy=np.array([[0, 0, 31, 31]], dtype=np.float32),
|
|
confidence=np.array([0.9], dtype=np.float32),
|
|
class_id=np.array([0]),
|
|
)
|
|
targets = Detections(
|
|
xyxy=np.array([[0, 0, 32, 32]], dtype=np.float32),
|
|
class_id=np.array([0]),
|
|
)
|
|
|
|
result = Recall().update(predictions, targets).compute()
|
|
|
|
assert result.medium_objects is not None
|
|
assert result.medium_objects.recall_at_50 == 1.0
|
|
|
|
def test_single_class_missed_detections(
|
|
self, detections_50_50, targets_two_objects_class_0
|
|
):
|
|
"""Test recall calculation with some missed detections"""
|
|
metric = Recall()
|
|
result = metric.update(detections_50_50, targets_two_objects_class_0).compute()
|
|
|
|
# TP = 1 (first target matched), FN = 1 (second target missed)
|
|
# recall = TP / (TP + FN) = 1 / 2 = 0.5
|
|
assert_almost_equal(result.recall_at_50, 0.5)
|
|
assert_almost_equal(result.recall_at_75, 0.5)
|
|
|
|
def test_multiple_classes(
|
|
self, predictions_multiple_classes, targets_multiple_classes
|
|
):
|
|
"""Test recall calculation for multiple classes"""
|
|
metric = Recall()
|
|
result = metric.update(
|
|
predictions_multiple_classes, targets_multiple_classes
|
|
).compute()
|
|
|
|
# Class 0: TP=1, FN=1 -> recall=0.5 (weight=2)
|
|
# Class 1: TP=1, FN=0 -> recall=1.0 (weight=1)
|
|
# Weighted avg: (2*0.5 + 1*1.0) / (2+1) = 2.0/3 = 0.6667
|
|
expected_recall = (2 * 0.5 + 1 * 1.0) / (2 + 1)
|
|
assert_almost_equal(result.recall_at_50, expected_recall)
|
|
assert_almost_equal(result.recall_at_75, expected_recall)
|
|
assert len(result.matched_classes) == 2
|
|
assert 0 in result.matched_classes
|
|
assert 1 in result.matched_classes
|
|
|
|
def test_different_iou_thresholds(self, predictions_iou_064, targets_iou_064):
|
|
"""Test recall at different IoU thresholds"""
|
|
metric = Recall()
|
|
result = metric.update(predictions_iou_064, targets_iou_064).compute()
|
|
|
|
# IoU = 0.64 > 0.5 but < 0.75
|
|
# Should match at IoU 0.5 but not at 0.75
|
|
assert result.recall_at_50 == 1.0 # TP=1, FN=0
|
|
assert result.recall_at_75 == 0.0 # TP=0, FN=1
|
|
|
|
def test_confidence_ranking(self, predictions_confidence_ranking, targets_50_50):
|
|
"""Test that higher confidence predictions are preferred for matching"""
|
|
metric = Recall()
|
|
result = metric.update(predictions_confidence_ranking, targets_50_50).compute()
|
|
|
|
# Target should be matched (by higher confidence prediction)
|
|
# TP = 1, FN = 0 -> recall = 1.0
|
|
assert result.recall_at_50 == 1.0
|
|
|
|
def test_multiple_predictions_one_target(
|
|
self, predictions_confidence_ranking, targets_50_50
|
|
):
|
|
"""Test recall when multiple predictions compete for one target"""
|
|
metric = Recall()
|
|
result = metric.update(predictions_confidence_ranking, targets_50_50).compute()
|
|
|
|
# Target should be matched exactly once
|
|
# TP = 1, FN = 0 -> recall = 1.0
|
|
assert result.recall_at_50 == 1.0
|
|
|
|
def test_list_inputs(
|
|
self, detections_50_50, targets_50_50, prediction_class_1, target_class_1
|
|
):
|
|
"""Test recall with list inputs"""
|
|
metric = Recall()
|
|
result = metric.update(
|
|
[detections_50_50, prediction_class_1], [targets_50_50, target_class_1]
|
|
).compute()
|
|
|
|
# Perfect matches for both
|
|
assert result.recall_at_50 == 1.0
|
|
assert result.recall_at_75 == 1.0
|
|
|
|
def test_mismatched_list_lengths(self, detections_50_50, targets_50_50):
|
|
"""Test that mismatched prediction/target list lengths raise error"""
|
|
metric = Recall()
|
|
|
|
# Should raise ValueError for mismatched lengths
|
|
with pytest.raises(ValueError, match="number of predictions"):
|
|
metric.update([detections_50_50], [targets_50_50, targets_50_50])
|
|
|
|
@pytest.mark.parametrize(
|
|
"missing_attribute",
|
|
["predictions_class_id", "targets_class_id", "predictions_confidence"],
|
|
)
|
|
def test_compute_value_error_for_missing_required_fields(
|
|
self, missing_attribute
|
|
) -> None:
|
|
"""Test compute raises ValueError when required fields are missing."""
|
|
metric = Recall()
|
|
boxes = np.array([[10, 10, 50, 50]], dtype=np.float32)
|
|
class_id = np.array([0], dtype=np.int32)
|
|
confidence = np.array([0.9], dtype=np.float32)
|
|
|
|
predictions = Detections(
|
|
xyxy=boxes,
|
|
confidence=confidence,
|
|
class_id=class_id,
|
|
)
|
|
targets = Detections(
|
|
xyxy=boxes,
|
|
class_id=class_id,
|
|
)
|
|
|
|
if missing_attribute == "predictions_class_id":
|
|
predictions = Detections(
|
|
xyxy=boxes,
|
|
confidence=confidence,
|
|
)
|
|
elif missing_attribute == "targets_class_id":
|
|
targets = Detections(xyxy=boxes)
|
|
else:
|
|
predictions = Detections(
|
|
xyxy=boxes,
|
|
class_id=class_id,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Recall metric requires"):
|
|
metric.update(predictions, targets).compute()
|
|
|
|
@pytest.mark.parametrize(
|
|
"averaging_method",
|
|
[AveragingMethod.MACRO, AveragingMethod.MICRO, AveragingMethod.WEIGHTED],
|
|
)
|
|
def test_averaging_methods(self, averaging_method, detections_50_50, targets_50_50):
|
|
"""Test different averaging methods"""
|
|
metric = Recall(averaging_method=averaging_method)
|
|
result = metric.update(detections_50_50, targets_50_50).compute()
|
|
|
|
# Perfect match should give 1.0 regardless of averaging method
|
|
assert result.recall_at_50 == 1.0
|
|
assert result.averaging_method == averaging_method
|
|
|
|
def test_macro_averaging(self):
|
|
"""Test MACRO averaging with specific example"""
|
|
# Class 0: 1/2 targets matched -> recall = 0.5
|
|
# Class 1: 1/1 targets matched -> recall = 1.0
|
|
# Macro average: (0.5 + 1.0) / 2 = 0.75
|
|
|
|
predictions = Detections(
|
|
xyxy=np.array(
|
|
[
|
|
[10, 10, 50, 50], # matches class 0 target 1
|
|
[200, 200, 240, 240], # matches class 1 target
|
|
],
|
|
dtype=np.float32,
|
|
),
|
|
confidence=np.array([0.9, 0.8]),
|
|
class_id=np.array([0, 1]),
|
|
)
|
|
|
|
targets = Detections(
|
|
xyxy=np.array(
|
|
[
|
|
[10, 10, 50, 50], # class 0, matched
|
|
[60, 60, 100, 100], # class 0, missed
|
|
[200, 200, 240, 240], # class 1, matched
|
|
],
|
|
dtype=np.float32,
|
|
),
|
|
class_id=np.array([0, 0, 1]),
|
|
)
|
|
|
|
metric = Recall(averaging_method=AveragingMethod.MACRO)
|
|
result = metric.update(predictions, targets).compute()
|
|
|
|
# Macro average: (0.5 + 1.0) / 2 = 0.75
|
|
assert result.recall_at_50 == 0.75
|
|
|
|
def test_greedy_matching_two_valid_pairs(self):
|
|
"""Greedy matching finds both TPs; np.unique style missed the second pair.
|
|
|
|
IoU matrix: [[1.0, 0.667], [0.333, 0.538]]. At iou>=0.5 the optimal
|
|
assignment is T0<->P0 and T1<->P1 (2 TPs, recall=1.0).
|
|
"""
|
|
preds = Detections(
|
|
xyxy=np.array([[40, 60, 380, 470], [108, 60, 448, 470]], dtype=np.float32),
|
|
confidence=np.array([0.95, 0.90]),
|
|
class_id=np.array([0, 0]),
|
|
)
|
|
targets = Detections(
|
|
xyxy=np.array([[40, 60, 380, 470], [210, 60, 550, 470]], dtype=np.float32),
|
|
class_id=np.array([0, 0]),
|
|
)
|
|
|
|
result = Recall().update(preds, targets).compute()
|
|
|
|
assert result.recall_at_50 == 1.0
|