Skip to content

primitives

Matching kernels — deciding what pairs with what.

from hotcoco import primitives
use hotcoco::primitives;

The layer underneath metrics: similarity between two sets, and the assignment that turns similarity into pairs.

from hotcoco import primitives, metrics

sim = primitives.bbox_iou(detections, ground_truths, iscrowd=[False] * len(ground_truths))
rows, cols = primitives.lsap(sim, maximize=True)

Nothing here scores — feed the pairs to metrics for that.

primitives.bbox_iou and primitives.mask_iou are the same functions hotcoco.mask exposes under their pycocotools names; both call one implementation, so they cannot disagree.

These functions are additive-change-only through 1.x. The IoU kernels are the exception — they are frozen, since pycocotools parity depends on them.


Functions

lsap

Optimal one-to-one assignment on a rectangular cost matrix.

lsap(
    cost: numpy.ndarray | Sequence[Sequence[float]], maximize: bool = False
) -> tuple[numpy.ndarray, numpy.ndarray]
Parameter Type Description
cost numpy.ndarray \| Sequence[Sequence[float]] 2-D costs; cost[i][j] for row i, column j. A float64 array is read in one pass; anything else is converted element by element
maximize bool Maximize value instead of minimizing cost. Pass True for similarities
primitives::assign::lsap(
    cost: &[f64], nr: usize, nc: usize, maximize: bool,
) -> (Vec<usize>, Vec<usize>)

Solves the linear sum assignment problem — the same thing scipy.optimize.linear_sum_assignment solves, and a semantic port of it, so results agree including on ties.

Use it when greedy matching is not good enough. Tracking associates detections to tracks this way, and it is what HOTA and MOTA are defined against. COCO detection deliberately does not use it: pycocotools matches greedily by score rank, and matching optimally would change the numbers.

Returns (row_ind, col_ind), the matched pairs, with len(row_ind) == min(n_rows, n_cols).

>>> rows, cols = primitives.lsap([[4, 1, 3], [2, 0, 5], [3, 2, 2]])
>>> list(zip(rows.tolist(), cols.tolist()))   # total cost 1 + 2 + 2 = 5
[(0, 1), (1, 0), (2, 2)]

Raises ValueError if cost is ragged or holds a NaN — an unsolvable matrix is an error, not grounds for an arbitrary assignment.


bbox_iou

Pairwise IoU between two sets of boxes.

bbox_iou(
    dt: Sequence[Sequence[float]],
    gt: Sequence[Sequence[float]],
    iscrowd: Sequence[bool],
) -> numpy.ndarray
primitives::sim::bbox_iou(dt: &[[f64; 4]], gt: &[[f64; 4]], iscrowd: &[bool]) -> Vec<Vec<f64>>

Boxes are [x, y, width, height]. Returns shape (len(dt), len(gt)).

Where iscrowd[j] is true, the denominator is the detection area alone rather than the union — a detection fully inside a crowd region scores 1.0. That is pycocotools' convention, and it is why iscrowd is required rather than optional: silently defaulting it would change crowd-region numbers.

See mask.bbox_iou for the same function under its pycocotools-compatible name.


mask_iou

Pairwise IoU between two sets of RLE masks.

mask_iou(dt, gt, iscrowd: Sequence[bool]) -> numpy.ndarray
primitives::sim::mask_iou(dt: &[Rle], gt: &[Rle], iscrowd: &[bool]) -> Vec<Vec<f64>>

Same crowd convention as bbox_iou. Inputs are RLE dicts as produced by mask.encode. Exposed as mask.iou under the pycocotools name.


Rust-only

Greedy matching (primitives::greedy::greedy_match_masked) is COCO's rank-ordered assignment — the reason hotcoco matches pycocotools detection-for-detection. Its GtMasks argument carries the crowd and ignore semantics that parity depends on. COCOeval.evaluate() uses it today; a Python binding is on the roadmap.