Skip to content

Commit f836e2d

Browse files
authored
Merge pull request #402 from iocalangiu/tests-for-detection-metrics
implement detection metrics suite and reorganize test files
2 parents 1eed11a + bb4ddb1 commit f836e2d

2 files changed

Lines changed: 196 additions & 0 deletions

File tree

tests/test_detection_metrics.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import pytest
2+
import numpy as np
3+
from perceptionmetrics.utils.detection_metrics import DetectionMetricsFactory
4+
from perceptionmetrics.utils.detection_metrics import compute_iou_matrix
5+
6+
@pytest.fixture
7+
def metrics_factory():
8+
"""Fixture to create a DetectionMetricsFactory instance (IoU=0.5)"""
9+
return DetectionMetricsFactory(iou_threshold=0.5)
10+
11+
def test_match_predictions_logic(metrics_factory):
12+
"""Test matches are correctly assigned TP/FP according to overlap"""
13+
gt_boxes = np.array([[0, 0, 10, 10]])
14+
gt_labels = [1]
15+
16+
pred_boxes = np.array([
17+
[0, 0, 9, 9], # High overlap (Should be TP)
18+
[20, 20, 30, 30] # No overlap (Should be FP)
19+
])
20+
pred_labels = [1, 1]
21+
pred_scores = [0.95, 0.6]
22+
23+
# Call the internal matching method
24+
matches = metrics_factory._match_predictions(
25+
gt_boxes, gt_labels, pred_boxes, pred_labels, pred_scores
26+
)
27+
28+
results = matches[1]
29+
assert (0.95, 1) in results, "High overlap prediction should be a True Positive"
30+
assert (0.6, 0) in results, "Zero overlap prediction should be a False Positive"
31+
assert (None, -1) not in results, "GT was matched, so there should be no False Negative"
32+
33+
34+
def test_compute_metrics(metrics_factory):
35+
"""Test that multiple detections of the same object result in 1 TP and 1 FP"""
36+
gt_boxes = np.array([[10, 10, 50, 50]])
37+
gt_labels = [1]
38+
39+
pred_boxes = np.array([
40+
[12, 12, 48, 48], # Pred A (High score)
41+
[11, 11, 49, 49] # Pred B (Low score - the 'double')
42+
])
43+
pred_labels = [1, 1]
44+
pred_scores = [0.90, 0.40]
45+
46+
metrics_factory.update(gt_boxes, gt_labels, pred_boxes, pred_labels, pred_scores)
47+
all_metrics = metrics_factory.compute_metrics()
48+
49+
cat_metrics = all_metrics[1]
50+
51+
52+
assert cat_metrics["TP"] == 1, "Should only have 1 True Positive"
53+
assert cat_metrics["FP"] == 1, "The second box should be a False Positive"
54+
assert cat_metrics["FN"] == 0, "The object was found, so FN should be 0"
55+
56+
# Recall = TP / (TP + FN) = 1 / (1 + 0) = 1.0
57+
assert cat_metrics["Recall"] == 1.0
58+
59+
# Precision = TP / (TP + FP) = 1 / (1 + 1) = 0.5
60+
assert cat_metrics["Precision"] == 0.5
61+
62+
# AP
63+
assert cat_metrics["AP"] == 1
64+
65+
def test_compute_iou_matrix_basic():
66+
"""Verify that the matrix correctly maps N predictions to M ground truths."""
67+
pred_boxes = np.array([
68+
[0, 0, 10, 10], # Pred 0
69+
[20, 20, 30, 30] # Pred 1
70+
])
71+
gt_boxes = np.array([
72+
[0, 0, 10, 10], # GT 0: Exact match with Pred 0
73+
[0, 0, 5, 5], # GT 1: Partial match with Pred 0
74+
[100, 100, 110, 110] # GT 2: No match
75+
])
76+
77+
matrix = compute_iou_matrix(pred_boxes, gt_boxes)
78+
79+
assert matrix.shape == (2, 3) # (num_pred, num_gt)
80+
81+
assert matrix[0, 0] == 1.0, "Pred 0 vs GT 0 should be a perfect 1.0"
82+
assert 0 < matrix[0, 1] < 1.0, "Pred 0 vs GT 1 should be a partial overlap"
83+
assert matrix[1, 2] == 0.0, "Pred 1 vs GT 2 should have zero overlap"
84+
85+
86+
def test_compute_coco_map_sensitivity(metrics_factory):
87+
"""
88+
With IoU ≈ 0.68, the prediction is a TP for the first 4 thresholds
89+
(0.50 to 0.65) and an FP for the remaining 6 thresholds (0.70 to 0.95),
90+
resulting in mAP=4/10=0.4.
91+
"""
92+
gt_boxes = np.array([[10, 10, 110, 110]])
93+
gt_labels = [1]
94+
95+
pred_boxes = np.array([[20, 20, 120, 120]])
96+
pred_labels = [1]
97+
pred_scores = [0.99]
98+
99+
metrics_factory.update(gt_boxes, gt_labels, pred_boxes, pred_labels, pred_scores)
100+
coco_map = metrics_factory.compute_coco_map()
101+
102+
assert np.isclose(coco_map, 0.4)
103+
assert coco_map < 1.0, "mAP should not be perfect for a shifted box"
104+
105+
def test_compute_coco_map_perfect_match(metrics_factory):
106+
"""Test that a perfect overlap results in a 1.0 COCO mAP."""
107+
gt_boxes = np.array([[0, 0, 100, 100]])
108+
pred_boxes = np.array([[0, 0, 100, 100]])
109+
110+
metrics_factory.update(gt_boxes, [1], pred_boxes, [1], [0.9])
111+
coco_map = metrics_factory.compute_coco_map()
112+
113+
assert coco_map == 1.0, "Perfect overlap must yield perfect mAP across all thresholds"
114+
115+
def test_compute_coco_map_complex_multi_class(metrics_factory):
116+
"""
117+
Verifies multi-class mAP by testing IoU threshold sensitivity (0.5:0.95)
118+
and the correct penalization (0.0 AP) for classes present in ground truth
119+
but missing from predictions.
120+
"""
121+
# Class 1: 2 GTs, 2 Preds
122+
gt_boxes_c1 = np.array([[0, 0, 10, 8], [0, 12, 4, 22]])
123+
pred_boxes_c1 = np.array([[-0.5, -0.5, 10.5, 8.5], [0, 18, 3.5, 21.5]])
124+
pred_scores_c1 = [0.9, 0.65] # High score is TP (IoU 0.81), Low is FP (IoU 0.31)
125+
126+
# Class 2: 3 GTs, 3 Preds
127+
gt_boxes_c2 = np.array([[15, 0, 20, 5], [15, 8, 19, 13], [15, 16, 21, 22]])
128+
pred_boxes_c2 = np.array([[14.8, 15.8, 21.2, 22.2], [14.5, 0.5, 19.5, 5.5], [16, 9, 18, 11]])
129+
pred_scores_c2 = [0.95, 0.80, 0.71] # 0.95=TP(0.88), 0.80=TP(0.68), 0.71=FP(0.20)
130+
131+
gt_boxes = np.concatenate([gt_boxes_c1, gt_boxes_c2])
132+
gt_labels = [1, 1, 2, 2, 2]
133+
pred_boxes = np.concatenate([pred_boxes_c1, pred_boxes_c2])
134+
pred_labels = [1, 1, 2, 2, 2]
135+
pred_scores = pred_scores_c1 + pred_scores_c2
136+
137+
metrics_factory.update(gt_boxes, gt_labels, pred_boxes, pred_labels, pred_scores)
138+
139+
mAP_coco = metrics_factory.compute_coco_map()
140+
141+
assert 0.38 <= mAP_coco <= 0.42, f"Expected mAP ~0.39, got {mAP_coco}"
142+
assert mAP_coco < 0.6, "mAP should be penalized for boxes with IoU < 0.95"
143+
144+
metrics_factory.update(
145+
np.array([[100, 100, 110, 110]]), [3], # New GT for class 3
146+
np.empty((0, 4)), [], [] # No predictions
147+
)
148+
mAP_with_empty_class = metrics_factory.compute_coco_map()
149+
150+
# The new mean should be (AP_c1 + AP_c2 + 0.0) / 3
151+
assert mAP_with_empty_class < mAP_coco
152+
assert np.isclose(mAP_with_empty_class, (mAP_coco * 2) / 3)
153+
154+
def test_coco_map_missing_class_logic(metrics_factory):
155+
"""
156+
Test if mAP calculation correctly handles a class that exists in the
157+
dataset (gt_counts) but received zero predictions in the raw_data.
158+
"""
159+
metrics_factory.gt_counts = {1: 1}
160+
161+
metrics_factory.raw_data = [
162+
(
163+
np.array([[10, 10, 20, 20]]), # gt_boxes
164+
np.array([1]), # gt_labels
165+
np.array([]), # pred_boxes (Empty)
166+
np.array([]), # pred_labels
167+
np.array([]), # pred_scores
168+
)
169+
]
170+
171+
mAP = metrics_factory.compute_coco_map()
172+
assert mAP == 0.0, f"Expected mAP 0.0 for a missed class, but got {mAP}"
173+
174+
def test_coco_map_empty_vs_non_empty_class(metrics_factory):
175+
"""
176+
Verify the mean is calculated correctly across multiple classes
177+
when one is a perfect match and one is a total miss.
178+
"""
179+
# Class 1: 1 GT, 1 Perfect Match (AP = 1.0)
180+
# Class 2: 1 GT, 0 Matches (AP = 0.0)
181+
metrics_factory.gt_counts = {1: 1, 2: 1}
182+
183+
metrics_factory.raw_data = [
184+
(
185+
np.array([[0, 0, 10, 10], [50, 50, 60, 60]]), # GTs
186+
np.array([1, 2]), # Labels
187+
np.array([[0, 0, 10, 10]]), # Only one Pred
188+
np.array([1]), # Label for Pred
189+
np.array([0.99]), # Score
190+
)
191+
]
192+
193+
mAP = metrics_factory.compute_coco_map()
194+
195+
# (AP_Class1 + AP_Class2) / 2 => (1.0 + 0.0) / 2 = 0.5
196+
assert np.isclose(mAP, 0.5), f"Expected mAP of 0.5, but got {mAP}"

0 commit comments

Comments
 (0)