Skip to content

match_detections(): expose the unified greedy matcher as a public cross-Detections primitive #2476

Description

@adawgwats

Search before asking

  • I have searched the supervision issues and found no similar feature request.

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

  1. 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.
  2. 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.
  3. 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.
  4. 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions