Skip to content

Match Detections

Match two sv.Detections objects into one-to-one pairs by greedy, highest-IoU-first assignment. The function returns index arrays, so the result composes with sv.Detections slicing.

import supervision as sv

matched_pairs, unmatched_a, unmatched_b = sv.match_detections(
    detections_a,
    detections_b,
    iou_threshold=0.5,
    class_agnostic=False,
)

matched_pairs has shape (M, 2): column 0 indexes detections_a, column 1 indexes detections_b. unmatched_a and unmatched_b hold the indices of the remaining detections on each side.

Matching is greedy, highest-IoU-first, and one-to-one; it is not a globally optimal assignment. With class_agnostic=False (the default), both inputs must provide class_id. Set class_agnostic=True to match on geometry only.

supervision.detection.utils.matching.match_detections(detections_a: Detections, detections_b: Detections, iou_threshold: float = 0.5, class_agnostic: bool = False) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.int64], npt.NDArray[np.int64]]

Match detections from two sources into one-to-one pairs.

The assignment is greedy and highest-IoU-first, identical to the matcher used by the metrics modules: each detection from detections_a can match at most one detection from detections_b, and vice versa. It does not compute a globally optimal assignment. Pairs below iou_threshold are never matched, and by default a pair also requires equal class_id values. confidence is ignored; callers that want metric-style score ordering should sort their detections first.

Parameters:

Name Type Description Default

detections_a

Detections

First set of detections.

required

detections_b

Detections

Second set of detections.

required

iou_threshold

float

Minimum IoU required for a pair. Defaults to 0.5.

0.5

class_agnostic

bool

When True, matching ignores class_id and uses IoU only. Defaults to False.

False

Raises:

Type Description
ValueError

If iou_threshold is outside [0, 1], or class-aware matching is requested without class IDs on both inputs.

Returns:

Type Description
NDArray[int64]

tuple[np.ndarray, np.ndarray, np.ndarray]: A tuple of

NDArray[int64]

(matched_pairs, unmatched_a, unmatched_b) where matched_pairs

NDArray[int64]

has shape (M, 2) with column 0 indexing detections_a and

tuple[NDArray[int64], NDArray[int64], NDArray[int64]]

column 1 indexing detections_b; unmatched_a and

tuple[NDArray[int64], NDArray[int64], NDArray[int64]]

unmatched_b hold the remaining indices of each side.

Examples:

>>> import numpy as np
>>> from supervision.detection.core import Detections
>>> a = Detections(
...     xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
...     class_id=np.array([0]),
... )
>>> b = Detections(
...     xyxy=np.array([[0, 0, 10, 10], [50, 50, 60, 60]], dtype=np.float32),
...     class_id=np.array([0, 1]),
... )
>>> matched_pairs, unmatched_a, unmatched_b = match_detections(a, b)
>>> matched_pairs.tolist()
[[0, 0]]
>>> unmatched_a.tolist()
[]
>>> unmatched_b.tolist()
[1]
Source code in src/supervision/detection/utils/matching.py
def match_detections(
    detections_a: Detections,
    detections_b: Detections,
    iou_threshold: float = 0.5,
    class_agnostic: bool = False,
) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.int64], npt.NDArray[np.int64]]:
    """Match detections from two sources into one-to-one pairs.

    The assignment is greedy and highest-IoU-first, identical to the matcher
    used by the metrics modules: each detection from ``detections_a`` can match
    at most one detection from ``detections_b``, and vice versa. It does not
    compute a globally optimal assignment. Pairs below ``iou_threshold`` are
    never matched, and by default a pair also requires equal ``class_id``
    values. ``confidence`` is ignored; callers that want metric-style score
    ordering should sort their detections first.

    Args:
        detections_a (Detections): First set of detections.
        detections_b (Detections): Second set of detections.
        iou_threshold (float, optional): Minimum IoU required for a pair.
            Defaults to 0.5.
        class_agnostic (bool, optional): When True, matching ignores
            ``class_id`` and uses IoU only. Defaults to False.

    Raises:
        ValueError: If `iou_threshold` is outside `[0, 1]`, or class-aware
            matching is requested without class IDs on both inputs.

    Returns:
        tuple[np.ndarray, np.ndarray, np.ndarray]: A tuple of
        ``(matched_pairs, unmatched_a, unmatched_b)`` where ``matched_pairs``
        has shape ``(M, 2)`` with column 0 indexing ``detections_a`` and
        column 1 indexing ``detections_b``; ``unmatched_a`` and
        ``unmatched_b`` hold the remaining indices of each side.

    Examples:
        ```pycon
        >>> import numpy as np
        >>> from supervision.detection.core import Detections
        >>> a = Detections(
        ...     xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
        ...     class_id=np.array([0]),
        ... )
        >>> b = Detections(
        ...     xyxy=np.array([[0, 0, 10, 10], [50, 50, 60, 60]], dtype=np.float32),
        ...     class_id=np.array([0, 1]),
        ... )
        >>> matched_pairs, unmatched_a, unmatched_b = match_detections(a, b)
        >>> matched_pairs.tolist()
        [[0, 0]]
        >>> unmatched_a.tolist()
        []
        >>> unmatched_b.tolist()
        [1]

        ```
    """
    _validate_iou_threshold(iou_threshold)
    if not class_agnostic and (
        detections_a.class_id is None or detections_b.class_id is None
    ):
        raise ValueError(
            "Both detections must provide class_id when class_agnostic is False."
        )

    if len(detections_a) == 0 or len(detections_b) == 0:
        matched_pairs = np.empty((0, 2), dtype=np.int64)
        unmatched_a = np.arange(len(detections_a), dtype=np.int64)
        unmatched_b = np.arange(len(detections_b), dtype=np.int64)
        return matched_pairs, unmatched_a, unmatched_b

    iou = box_iou_batch(detections_a.xyxy, detections_b.xyxy)
    candidates = iou >= iou_threshold
    if not class_agnostic:
        class_ids_a = detections_a.class_id
        class_ids_b = detections_b.class_id
        assert class_ids_a is not None
        assert class_ids_b is not None
        candidates &= class_ids_a[:, None] == class_ids_b[None, :]

    matched_indices = np.where(candidates)
    pairs = list(_greedy_match(iou, matched_indices))
    matched_pairs = np.asarray(pairs, dtype=np.int64).reshape(-1, 2)
    if matched_pairs.shape[0] == 0:
        matched_a = np.empty(0, dtype=np.int64)
        matched_b = np.empty(0, dtype=np.int64)
    else:
        matched_a = matched_pairs[:, 0]
        matched_b = matched_pairs[:, 1]

    unmatched_a = np.setdiff1d(np.arange(len(detections_a), dtype=np.int64), matched_a)
    unmatched_b = np.setdiff1d(np.arange(len(detections_b), dtype=np.int64), matched_b)
    return matched_pairs, unmatched_a, unmatched_b

Comments