import numpy as np
def iou_matrix(boxes_a, boxes_b):
"Pairwise IoU between two sets of [x0, y0, x1, y1] boxes -> array (len(a), len(b))."
a = np.asarray(boxes_a, dtype=np.float64).reshape(-1, 4)
b = np.asarray(boxes_b, dtype=np.float64).reshape(-1, 4)
if len(a) == 0 or len(b) == 0:
return np.zeros((len(a), len(b)))
x0 = np.maximum(a[:, None, 0], b[None, :, 0])
y0 = np.maximum(a[:, None, 1], b[None, :, 1])
x1 = np.minimum(a[:, None, 2], b[None, :, 2])
y1 = np.minimum(a[:, None, 3], b[None, :, 3])
inter = np.clip(x1 - x0, 0, None) * np.clip(y1 - y0, 0, None)
area_a = (a[:, 2] - a[:, 0]) * (a[:, 3] - a[:, 1])
area_b = (b[:, 2] - b[:, 0]) * (b[:, 3] - b[:, 1])
return inter / (area_a[:, None] + area_b[None, :] - inter + 1e-9)
def average_precision(preds, gts, iou_thr=0.5):
"""All-point-interpolated AP for ONE class.
preds: list of (image_id, score, box) - box is [x0, y0, x1, y1]
gts: dict image_id -> list of boxes
Returns nan when the class has no ground truth (so np.nanmean skips it).
"""
n_gt = sum(len(v) for v in gts.values())
if n_gt == 0:
return float("nan")
preds = sorted(preds, key=lambda p: -p[1])
matched = {k: np.zeros(len(v), dtype=bool) for k, v in gts.items()}
tp, fp = np.zeros(len(preds)), np.zeros(len(preds))
for i, (img, _score, box) in enumerate(preds):
boxes = gts.get(img, [])
if len(boxes) == 0:
fp[i] = 1 # a box on an image with none of this class: pure false positive
continue
ious = iou_matrix([box], boxes)[0]
j = int(np.argmax(ious))
if ious[j] >= iou_thr and not matched[img][j]:
tp[i], matched[img][j] = 1, True
else:
fp[i] = 1 # bad localisation, or a duplicate of an already-matched GT
tp_c, fp_c = np.cumsum(tp), np.cumsum(fp)
recall = tp_c / n_gt
precision = tp_c / np.maximum(tp_c + fp_c, 1e-9)
# Integrate under the monotone-decreasing precision envelope.
mrec = np.concatenate([[0.0], recall, [1.0]])
mpre = np.concatenate([[0.0], precision, [0.0]])
for i in range(len(mpre) - 2, -1, -1):
mpre[i] = max(mpre[i], mpre[i + 1])
idx = np.where(mrec[1:] != mrec[:-1])[0]
return float(np.sum((mrec[idx + 1] - mrec[idx]) * mpre[idx + 1]))
print("IoU of two nearly-identical boxes:", round(float(iou_matrix([[10, 10, 50, 90]], [[12, 12, 52, 92]])[0, 0]), 3))
print("IoU of two disjoint boxes: ", round(float(iou_matrix([[10, 10, 50, 90]], [[60, 10, 90, 90]])[0, 0]), 3))
# Toy detector on 2 images. Two BASE classes (seen with boxes in training) and two
# NOVEL classes (named only at inference). The detector is good at the base ones.
BASE, NOVEL = ["person", "car"], ["manhole cover", "cargo pallet"]
gt = {
"person": {0: [[10, 10, 50, 90], [60, 20, 100, 95]], 1: [[30, 40, 70, 100]]},
"car": {0: [[120, 30, 200, 90]], 1: [[100, 50, 180, 110]]},
"manhole cover": {0: [[20, 150, 60, 180]], 1: [[80, 160, 120, 190]]},
"cargo pallet": {0: [[140, 140, 190, 190]], 1: [[10, 120, 60, 175]]},
}
pred = {
"person": [(0, 0.95, [12, 12, 52, 92]), (0, 0.90, [58, 18, 102, 96]),
(1, 0.85, [31, 42, 72, 102]), (1, 0.30, [0, 0, 20, 20])],
"car": [(0, 0.92, [118, 28, 198, 92]), (1, 0.88, [102, 52, 182, 112]),
(0, 0.40, [0, 100, 40, 140])],
# Novel classes: one hit, several confident boxes on nothing, one object missed entirely.
"manhole cover": [(0, 0.55, [15, 148, 70, 190]), (1, 0.50, [0, 0, 30, 30]),
(0, 0.45, [100, 20, 140, 60])],
"cargo pallet": [(1, 0.60, [12, 122, 58, 178]), (0, 0.58, [30, 30, 80, 80]),
(0, 0.35, [141, 141, 189, 189])],
}
aps = {c: average_precision(pred[c], gt[c]) for c in BASE + NOVEL}
for c, ap in aps.items():
tag = "base " if c in BASE else "NOVEL"
print(f"{tag} AP50 {c:15s} {ap:.3f}")
ap_base = float(np.nanmean([aps[c] for c in BASE]))
ap_novel = float(np.nanmean([aps[c] for c in NOVEL]))
ap_all = float(np.nanmean(list(aps.values())))
print(f"\nAP_base {ap_base:.3f} AP_novel {ap_novel:.3f} mAP (all classes) {ap_all:.3f}")
print("The headline mAP sits between the two and hides the novel-class collapse -")
print("this is precisely why LVIS reports AP_r separately.")