Search before asking
Description
#2378 pointed out that ConfusionMatrix and Recall/F1Score/MeanAverageRecall were matching predictions to targets with different algorithms, and #2380 fixed it the same day by unifying everything onto one greedy matcher in supervision/metrics/utils/matching.py (_greedy_match + _match_detection_batch_with_target_indices). All five metric modules now delegate to it.
That matcher is exactly the primitive users keep needing outside metrics — "given two Detections, which instances correspond?" — but it's private and shaped for metric internals (pre-computed IoU matrix, class-index arrays, per-IoU-threshold correctness matrix, COCO-style scored/unscored rounds). There is no public API that takes two Detections and returns matched pairs. box_iou_batch is public, so today everyone hand-rolls the assignment step on top of it — with the same one-to-many/ordering bugs #2378 was about — or sidesteps geometry entirely with VLM calls (e.g. the planogram-compliance guide pairs an RF-DETR detector with two Gemini VQA calls, where the "which detection corresponds to which reference slot" half of the problem is a matching problem: https://blog.roboflow.com/planogram-compliance-detection/).
Proposed API
matched_pairs, unmatched_a, unmatched_b = sv.match_detections(
detections_a, # sv.Detections
detections_b, # sv.Detections
iou_threshold=0.5,
class_agnostic=False,
)
Returns index arrays, not new Detections, so it composes with existing slicing:
matched_pairs: np.ndarray of shape (M, 2) — column 0 indexes into detections_a, column 1 into detections_b
unmatched_a: (len(detections_a) - M,) indices into detections_a
unmatched_b: (len(detections_b) - M,) indices into detections_b
detections_a[unmatched_a], detections_b[matched_pairs[:, 1]], etc. all just work.
Semantics
| Case |
Behavior |
| Assignment |
one-to-one, greedy, highest-IoU-first — identical to _greedy_match from #2380 |
| IoU ties |
stable order (np.argsort(-iou, kind="stable")), matching the internals |
class_agnostic=False (default) |
a pair additionally requires equal class_id — mirrors the correct_class mask in _match_detection_batch_with_target_indices |
class_agnostic=True |
IoU-only matching |
IoU below iou_threshold |
never paired, regardless of class |
detections_a or detections_b empty |
matched_pairs.shape == (0, 2); every index of the other side is unmatched |
confidence |
ignored — the primitive is pure geometry+class; callers who want metric-style score ordering sort upstream |
| Symmetry |
greedy-on-IoU makes match_detections(a, b) equal match_detections(b, a) with columns swapped (up to ordering among exactly-tied IoU pairs) |
Single scalar iou_threshold only — the per-threshold sweep and the scored/unscored two-round COCO behavior stay in the metrics layer where they belong.
Use cases
- Eval tooling beyond aggregate numbers. The metrics give mAP/F1; users routinely want the instances — per-image TP/FP/FN lists to render with annotators, mine hard examples, or debug a confusion pair. Today that means reimplementing the matcher the library already contains.
- Before/after change detection. My production use: a litter-mapping app that photo-verifies cleanups by detecting on a before and an after photo of the same scene and diffing — unmatched-in-before = removed, unmatched-in-after = new debris. The matching step currently lives outside supervision because there's nothing to call.
- Tracker bootstrap / cross-source association.
ByteTrack is sequential and stateful; a stateless pairwise matcher covers the two-frame case, detector-A-vs-detector-B comparison, and re-association after a tracker reset.
- Annotation QA. Agreement between two annotators, or human labels vs model suggestions, is the same primitive with ground truth on both sides.
Demand honesty: I found only a couple of forum threads asking for this, so I'm pitching it as a missing primitive the library already contains privately — not as a heavily-requested feature.
Implementation offer
This is a thin wrapper: box_iou_batch → candidate mask (iou >= threshold, optionally class_id equality) → reuse _greedy_match → collect indices. No behavior change to any metric; ~40 lines plus tests and docs. I'm happy to submit the PR — after 0.30.0 ships, so this adds no review load to the release. Also fine with a different spelling if maintainers prefer (Detections.match(other) method, or a home in supervision.detection.utils next to box_iou_batch).
Search before asking
Description
#2378 pointed out that
ConfusionMatrixandRecall/F1Score/MeanAverageRecallwere matching predictions to targets with different algorithms, and #2380 fixed it the same day by unifying everything onto one greedy matcher insupervision/metrics/utils/matching.py(_greedy_match+_match_detection_batch_with_target_indices). All five metric modules now delegate to it.That matcher is exactly the primitive users keep needing outside metrics — "given two
Detections, which instances correspond?" — but it's private and shaped for metric internals (pre-computed IoU matrix, class-index arrays, per-IoU-threshold correctness matrix, COCO-style scored/unscored rounds). There is no public API that takes twoDetectionsand returns matched pairs.box_iou_batchis public, so today everyone hand-rolls the assignment step on top of it — with the same one-to-many/ordering bugs #2378 was about — or sidesteps geometry entirely with VLM calls (e.g. the planogram-compliance guide pairs an RF-DETR detector with two Gemini VQA calls, where the "which detection corresponds to which reference slot" half of the problem is a matching problem: https://blog.roboflow.com/planogram-compliance-detection/).Proposed API
Returns index arrays, not new
Detections, so it composes with existing slicing:matched_pairs:np.ndarrayof shape(M, 2)— column 0 indexes intodetections_a, column 1 intodetections_bunmatched_a:(len(detections_a) - M,)indices intodetections_aunmatched_b:(len(detections_b) - M,)indices intodetections_bdetections_a[unmatched_a],detections_b[matched_pairs[:, 1]], etc. all just work.Semantics
_greedy_matchfrom #2380np.argsort(-iou, kind="stable")), matching the internalsclass_agnostic=False(default)class_id— mirrors thecorrect_classmask in_match_detection_batch_with_target_indicesclass_agnostic=Trueiou_thresholddetections_aordetections_bemptymatched_pairs.shape == (0, 2); every index of the other side is unmatchedconfidencematch_detections(a, b)equalmatch_detections(b, a)with columns swapped (up to ordering among exactly-tied IoU pairs)Single scalar
iou_thresholdonly — the per-threshold sweep and the scored/unscored two-round COCO behavior stay in the metrics layer where they belong.Use cases
ByteTrackis sequential and stateful; a stateless pairwise matcher covers the two-frame case, detector-A-vs-detector-B comparison, and re-association after a tracker reset.Demand honesty: I found only a couple of forum threads asking for this, so I'm pitching it as a missing primitive the library already contains privately — not as a heavily-requested feature.
Implementation offer
This is a thin wrapper:
box_iou_batch→ candidate mask (iou >= threshold, optionallyclass_idequality) → reuse_greedy_match→ collect indices. No behavior change to any metric; ~40 lines plus tests and docs. I'm happy to submit the PR — after 0.30.0 ships, so this adds no review load to the release. Also fine with a different spelling if maintainers prefer (Detections.match(other)method, or a home insupervision.detection.utilsnext tobox_iou_batch).