perf(evaluation): share bbox IoU per image (#1373)
* perf(evaluation): share bbox IoU per image * fix(evaluation): unify tie-break contract and preserve score precision * refactor(evaluation): collapse dead bbox branch and hoist iou_type dispatch * test(evaluation): add bbox differential oracle and matcher unit tests * perf(evaluation): add C=1 and no-crowd fast paths, reduce per-class dispatch * style(evaluation): align empty-GT sort to kind="stable" --------- Co-authored-by: Codex <codex@openai.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from typing import Any, cast
|
||||
from typing import Any, TypeAlias, cast
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -28,6 +28,27 @@ from torchvision.ops import box_iou
|
||||
|
||||
from rfdetr.utilities import all_gather
|
||||
|
||||
#: One class's contribution to the accumulator: its detection scores in descending order, their
|
||||
#: true-positive flags, their crowd-ignore flags, and the class's non-crowd GT count.
|
||||
_ClassContribution: TypeAlias = tuple[
|
||||
np.ndarray[Any, np.dtype[np.float32]],
|
||||
np.ndarray[Any, np.dtype[np.int64]],
|
||||
np.ndarray[Any, np.dtype[np.bool_]],
|
||||
int,
|
||||
]
|
||||
|
||||
#: Positions of one class inside an image's prediction and GT arrays: the rows and the columns it
|
||||
#: owns in the image-wide ``box_iou`` matrix. ``None`` instead means the image holds a single class,
|
||||
#: which owns the whole matrix and needs no positions at all.
|
||||
_ClassSlice: TypeAlias = tuple[
|
||||
np.ndarray[Any, np.dtype[np.intp]],
|
||||
np.ndarray[Any, np.dtype[np.intp]],
|
||||
]
|
||||
|
||||
#: Column positions of a class that has predictions but no GT of its own. Shared, so the per-class
|
||||
#: lookup that misses can hand back an empty selection without allocating one every time.
|
||||
_NO_INDICES: np.ndarray[Any, np.dtype[np.intp]] = np.zeros(0, dtype=np.intp)
|
||||
|
||||
|
||||
def _compute_mask_iou(pred_masks: Tensor, gt_masks: Tensor) -> Tensor:
|
||||
"""Compute pairwise boolean-mask IoU between N predictions and M ground truths.
|
||||
@@ -53,31 +74,223 @@ def _compute_mask_iou(pred_masks: Tensor, gt_masks: Tensor) -> Tensor:
|
||||
return torch.where(union > 0, inter / union, torch.zeros_like(inter))
|
||||
|
||||
|
||||
def _match_single_class(
|
||||
pred_scores: Tensor,
|
||||
pred_items: Tensor,
|
||||
gt_items: Tensor,
|
||||
gt_crowd: Tensor,
|
||||
def _match_sorted_iou_matrix(
|
||||
iou_matrix_sorted: np.ndarray[Any, np.dtype[np.float32]],
|
||||
gt_crowd_np: np.ndarray[Any, np.dtype[np.bool_]],
|
||||
iou_threshold: float,
|
||||
iou_type: str,
|
||||
) -> tuple[
|
||||
np.ndarray[Any, np.dtype[np.float32]],
|
||||
np.ndarray[Any, np.dtype[np.int64]],
|
||||
np.ndarray[Any, np.dtype[np.bool_]],
|
||||
int,
|
||||
]:
|
||||
"""Greedy highest-score-first matching for one class in one image.
|
||||
"""Apply COCO greedy matching to score-ordered NumPy IoUs for one class.
|
||||
|
||||
Implements the COCO matching algorithm: each GT is matched at most once; detections are processed in descending
|
||||
score order; detections matched to crowd GTs are marked as ignored rather than false positives.
|
||||
Dispatches on whether the class has any crowd GT. Both loops below are per-detection Python
|
||||
loops whose cost is dominated by the number of NumPy calls each iteration makes, so the common
|
||||
crowd-free case gets its own loop rather than paying for masks that can only be no-ops.
|
||||
|
||||
Args:
|
||||
iou_matrix_sorted: Pairwise IoUs whose rows are in descending detection-score order. Must not
|
||||
be modified here: the bbox caller derives it from the one ``box_iou`` matrix shared by
|
||||
every class of an image, so masking rows in place instead of copying them would leak one
|
||||
class's matching state into the classes matched after it.
|
||||
gt_crowd_np: Boolean crowd mask aligned to IoU columns.
|
||||
iou_threshold: Minimum IoU to count as a positive match.
|
||||
|
||||
Returns:
|
||||
Tuple of true-positive flags, crowd-ignore flags, and the number of non-crowd ground truths.
|
||||
The scores the rows were ordered by are not returned — every caller already holds them.
|
||||
"""
|
||||
if not gt_crowd_np.any():
|
||||
return _match_rows_without_crowd(iou_matrix_sorted, iou_threshold)
|
||||
return _match_rows_with_crowd(iou_matrix_sorted, gt_crowd_np, iou_threshold)
|
||||
|
||||
|
||||
def _match_rows_without_crowd(
|
||||
iou_matrix_sorted: np.ndarray[Any, np.dtype[np.float32]],
|
||||
iou_threshold: float,
|
||||
) -> tuple[
|
||||
np.ndarray[Any, np.dtype[np.int64]],
|
||||
np.ndarray[Any, np.dtype[np.bool_]],
|
||||
int,
|
||||
]:
|
||||
"""Greedily match score-ordered IoUs for a class whose ground truths are all non-crowd.
|
||||
|
||||
Behaviourally a special case of ``_match_rows_with_crowd()`` — every GT is claimable, none can
|
||||
be ignored, and ``total_gt`` is simply the column count. What makes it worth its own loop is
|
||||
that the per-detection cost here is NumPy call overhead, not array work: at a few dozen GTs a
|
||||
call costs more than the elements it touches, so the win comes from making calls disappear.
|
||||
|
||||
Two whole-matrix reductions do that for the two cases that need no search at all: a detection
|
||||
whose best IoU is below the threshold cannot match any GT, and one whose best GT is still free
|
||||
matches exactly that GT (masking can only lower other columns, so it cannot promote a different
|
||||
one, and ``argmax`` already resolved ties to the lowest column index). Only a detection whose
|
||||
best GT was claimed by a higher-scoring detection falls back to a masked search.
|
||||
|
||||
Args:
|
||||
iou_matrix_sorted: Pairwise IoUs whose rows are in descending detection-score order. Read
|
||||
only, for the reason given on ``_match_sorted_iou_matrix()``.
|
||||
iou_threshold: Minimum IoU to count as a positive match.
|
||||
|
||||
Returns:
|
||||
Tuple of true-positive flags, all-False crowd-ignore flags, and the ground-truth count.
|
||||
"""
|
||||
n, m = iou_matrix_sorted.shape
|
||||
gt_matched_np = np.zeros(m, dtype=np.bool_)
|
||||
pred_match_np = np.zeros(n, dtype=np.int64)
|
||||
# argmax before max, so that a matrix with no GT columns still fails with the same "argmax of
|
||||
# an empty sequence" ValueError the per-detection search raises.
|
||||
best_columns = cast(list[int], iou_matrix_sorted.argmax(axis=1).tolist())
|
||||
best_ious = cast(list[float], iou_matrix_sorted.max(axis=1).tolist())
|
||||
|
||||
for index in range(n):
|
||||
if best_ious[index] < iou_threshold:
|
||||
continue
|
||||
best_column = best_columns[index]
|
||||
if not gt_matched_np[best_column]:
|
||||
pred_match_np[index] = 1
|
||||
gt_matched_np[best_column] = True
|
||||
continue
|
||||
|
||||
# Copy-then-mask rather than np.where(): measured cheaper, and it leaves the source matrix
|
||||
# — shared across the classes of an image on the bbox path — untouched either way.
|
||||
free_ious = cast(np.ndarray[Any, np.dtype[np.float32]], iou_matrix_sorted[index].copy())
|
||||
free_ious[gt_matched_np] = -1.0
|
||||
best_index = int(free_ious.argmax())
|
||||
if free_ious[best_index] >= iou_threshold:
|
||||
pred_match_np[index] = 1
|
||||
gt_matched_np[best_index] = True
|
||||
|
||||
return pred_match_np, np.zeros(n, dtype=np.bool_), m
|
||||
|
||||
|
||||
def _match_rows_with_crowd(
|
||||
iou_matrix_sorted: np.ndarray[Any, np.dtype[np.float32]],
|
||||
gt_crowd_np: np.ndarray[Any, np.dtype[np.bool_]],
|
||||
iou_threshold: float,
|
||||
) -> tuple[
|
||||
np.ndarray[Any, np.dtype[np.int64]],
|
||||
np.ndarray[Any, np.dtype[np.bool_]],
|
||||
int,
|
||||
]:
|
||||
"""Greedily match score-ordered IoUs for a class that may have crowd ground truths.
|
||||
|
||||
Handles crowd GTs in full generality, so it stays correct for an all-non-crowd input too — that
|
||||
is what makes it the reference ``_match_rows_without_crowd()`` is differentially tested against.
|
||||
|
||||
Args:
|
||||
iou_matrix_sorted: Pairwise IoUs whose rows are in descending detection-score order. Read
|
||||
only, for the reason given on ``_match_sorted_iou_matrix()``.
|
||||
gt_crowd_np: Boolean crowd mask aligned to IoU columns.
|
||||
iou_threshold: Minimum IoU to count as a positive match.
|
||||
|
||||
Returns:
|
||||
Tuple of true-positive flags, crowd-ignore flags, and the number of non-crowd ground truths.
|
||||
"""
|
||||
n, m = iou_matrix_sorted.shape
|
||||
gt_matched_np = np.zeros(m, dtype=np.bool_)
|
||||
pred_match_np = np.zeros(n, dtype=np.int64)
|
||||
pred_ignore_np = np.zeros(n, dtype=np.bool_)
|
||||
any_crowd = bool(gt_crowd_np.any())
|
||||
not_crowd_np = ~gt_crowd_np
|
||||
|
||||
# Each detection can claim at most one non-crowd target, so score order
|
||||
# remains sequential even though the IoU matrix is already vectorized.
|
||||
for index in range(len(iou_matrix_sorted)):
|
||||
ious = cast(np.ndarray[Any, np.dtype[np.float32]], iou_matrix_sorted[index])
|
||||
noncrowd_ious = ious.copy()
|
||||
noncrowd_ious[gt_crowd_np] = -1.0
|
||||
noncrowd_ious[gt_matched_np & not_crowd_np] = -1.0
|
||||
|
||||
# The bound method, not np.argmax(): the module-level function re-dispatches on its
|
||||
# argument, which costs more per call than the search over a few dozen GTs.
|
||||
best_noncrowd_index = int(noncrowd_ious.argmax())
|
||||
if noncrowd_ious[best_noncrowd_index] >= iou_threshold:
|
||||
pred_match_np[index] = 1
|
||||
gt_matched_np[best_noncrowd_index] = True
|
||||
elif any_crowd:
|
||||
crowd_ious = ious.copy()
|
||||
crowd_ious[not_crowd_np] = -1.0
|
||||
if crowd_ious.max() >= iou_threshold:
|
||||
pred_ignore_np[index] = True
|
||||
|
||||
return pred_match_np, pred_ignore_np, int(not_crowd_np.sum())
|
||||
|
||||
|
||||
def _group_indices_by_label(
|
||||
label_ids_np: np.ndarray[Any, np.dtype[np.int64]],
|
||||
) -> dict[int, np.ndarray[Any, np.dtype[np.intp]]]:
|
||||
"""Group array positions by label in one pass over an image's labels.
|
||||
|
||||
Replaces one ``label_ids_np == class_id`` scan per class — O(N) per class, O(N*C) over an
|
||||
image — with a single stable sort. Within each group the positions stay ascending, which is the
|
||||
order a per-class scan produces and the order the greedy matcher's stable score sort then
|
||||
breaks ties on, so the grouping is a pure speedup and not a reordering.
|
||||
|
||||
Args:
|
||||
label_ids_np: Label of every detection (or of every GT) of one image.
|
||||
|
||||
Returns:
|
||||
Dict mapping each label present to its ascending positions in *label_ids_np*; empty for an
|
||||
empty input.
|
||||
"""
|
||||
if label_ids_np.size == 0:
|
||||
return {}
|
||||
order = np.argsort(label_ids_np, kind="stable")
|
||||
sorted_labels = label_ids_np[order]
|
||||
boundaries = np.flatnonzero(sorted_labels[1:] != sorted_labels[:-1]) + 1
|
||||
return {int(label_ids_np[group[0]]): group for group in np.split(order, boundaries)}
|
||||
|
||||
|
||||
def _unmatched_contribution(
|
||||
scores_np: np.ndarray[Any, np.dtype[np.floating[Any]]],
|
||||
) -> _ClassContribution:
|
||||
"""Build the contribution of a class that has detections but no ground truth.
|
||||
|
||||
Both ``iou_type`` paths reach this case and must report it identically: every detection is a
|
||||
false positive, none is crowd-ignored, and the class adds nothing to the GT denominator.
|
||||
|
||||
Args:
|
||||
scores_np: Detection scores of one class, in the caller's own float dtype so that near-tied
|
||||
scores are ordered at full precision before the float32 cast.
|
||||
|
||||
Returns:
|
||||
Tuple ``(scores_np, matches_np, ignore_np, total_gt)`` with the scores in descending order,
|
||||
all-zero matches, all-False ignore flags, and a ``total_gt`` of 0.
|
||||
"""
|
||||
n = len(scores_np)
|
||||
# kind="stable" costs nothing here — every match is 0 and every ignore is False regardless of
|
||||
# tie order, since there is no GT to match against — but it keeps this the only unstable sort
|
||||
# site in the file, instead of a silent third tie-break convention alongside the other two.
|
||||
order = np.argsort(-scores_np, kind="stable")
|
||||
return (
|
||||
scores_np[order].astype(np.float32, copy=False),
|
||||
np.zeros(n, dtype=np.int64),
|
||||
np.zeros(n, dtype=np.bool_),
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
def _match_single_class_segm(
|
||||
pred_scores: Tensor,
|
||||
pred_masks: Tensor,
|
||||
gt_masks: Tensor,
|
||||
gt_crowd: Tensor,
|
||||
iou_threshold: float,
|
||||
) -> _ClassContribution:
|
||||
"""Greedy highest-score-first mask matching for one class in one image.
|
||||
|
||||
Implements the COCO matching algorithm over boolean-mask IoU: each GT is matched at most once; detections are
|
||||
processed in descending score order; detections matched to crowd GTs are marked as ignored rather than false
|
||||
positives. This is the segmentation path only — the box path never reaches here, because it shares one image-wide
|
||||
``box_iou`` matrix and calls ``_match_sorted_iou_matrix`` directly.
|
||||
|
||||
Args:
|
||||
pred_scores: Float tensor of shape [N] with detection confidences.
|
||||
pred_items: Predictions — boxes [N, 4] in xyxy coords or masks [N, H, W].
|
||||
gt_items: Ground truths — boxes [M, 4] in xyxy coords or masks [M, H, W].
|
||||
pred_masks: Boolean prediction masks of shape [N, H, W].
|
||||
gt_masks: Boolean ground-truth masks of shape [M, H, W].
|
||||
gt_crowd: Bool tensor of shape [M], True for crowd instances.
|
||||
iou_threshold: Minimum IoU to count as a positive match.
|
||||
iou_type: ``"bbox"`` for box IoU or ``"segm"`` for mask IoU.
|
||||
|
||||
Returns:
|
||||
Tuple ``(scores_np, matches_np, ignore_np, total_gt)`` where:
|
||||
@@ -86,17 +299,15 @@ def _match_single_class(
|
||||
- ignore_np: bool array [N], True if matched to a crowd GT.
|
||||
- total_gt: number of non-crowd GT instances.
|
||||
"""
|
||||
n = pred_scores.shape[0]
|
||||
m = gt_items.shape[0]
|
||||
|
||||
sort_idx = torch.argsort(pred_scores, descending=True)
|
||||
# stable=True keeps tied scores in input order, which is the tie rule the bbox path gets from
|
||||
# np.argsort(kind="stable") and PostProcess._select_topk gets from the same flag. torch's
|
||||
# default sort is unstable past its ~32-element cutoff, so without it the greedy winner of a
|
||||
# tie -- and with it the TP/FP split -- would depend on the sort backend rather than the input.
|
||||
sort_idx = torch.argsort(pred_scores, descending=True, stable=True)
|
||||
pred_scores_sorted = pred_scores[sort_idx]
|
||||
pred_sorted = pred_items[sort_idx]
|
||||
pred_sorted = pred_masks[sort_idx]
|
||||
|
||||
if iou_type == "bbox":
|
||||
iou_matrix = box_iou(pred_sorted, gt_items) # [N, M]
|
||||
else:
|
||||
iou_matrix = _compute_mask_iou(pred_sorted, gt_items) # [N, M]
|
||||
iou_matrix = _compute_mask_iou(pred_sorted, gt_masks) # [N, M]
|
||||
|
||||
# The greedy matching below is inherently sequential (each GT can only be claimed
|
||||
# once, so iteration i depends on the outcome of i-1) — it cannot be vectorized away.
|
||||
@@ -108,42 +319,113 @@ def _match_single_class(
|
||||
iou_matrix_np = iou_matrix.detach().float().cpu().numpy() # [N, M] -- float() guards bf16/fp16 (no numpy dtype)
|
||||
gt_crowd_np = gt_crowd.detach().cpu().numpy() # [M]
|
||||
|
||||
gt_matched_np = np.zeros(m, dtype=np.bool_)
|
||||
pred_match_np = np.zeros(n, dtype=np.int64)
|
||||
pred_ignore_np = np.zeros(n, dtype=np.bool_)
|
||||
any_crowd = bool(gt_crowd_np.any())
|
||||
not_crowd_np = ~gt_crowd_np # crowd mask is loop-invariant — compute the negation once
|
||||
|
||||
for i in range(n):
|
||||
ious = iou_matrix_np[i] # [M]
|
||||
|
||||
# Try to match to a non-crowd GT (each non-crowd GT matched at most once).
|
||||
nc_ious = ious.copy()
|
||||
nc_ious[gt_crowd_np] = -1.0
|
||||
nc_ious[gt_matched_np & not_crowd_np] = -1.0 # already claimed
|
||||
|
||||
best_nc_idx = int(np.argmax(nc_ious))
|
||||
best_nc_iou = nc_ious[best_nc_idx]
|
||||
if best_nc_iou >= iou_threshold:
|
||||
pred_match_np[i] = 1
|
||||
gt_matched_np[best_nc_idx] = True
|
||||
# A detection matched to a crowd GT is ignored (not a false positive).
|
||||
elif any_crowd:
|
||||
crowd_ious = ious.copy()
|
||||
crowd_ious[not_crowd_np] = -1.0
|
||||
if crowd_ious.max() >= iou_threshold:
|
||||
pred_ignore_np[i] = True
|
||||
# else: false positive — pred_match_np stays 0
|
||||
|
||||
total_gt = int((~gt_crowd_np).sum())
|
||||
matches_np, ignore_np, total_gt = _match_sorted_iou_matrix(iou_matrix_np, gt_crowd_np, iou_threshold)
|
||||
return (
|
||||
np.asarray(pred_scores_sorted.float().cpu().numpy(), dtype=np.float32),
|
||||
pred_match_np,
|
||||
pred_ignore_np,
|
||||
np.asarray(pred_scores_sorted.detach().float().cpu().numpy(), dtype=np.float32),
|
||||
matches_np,
|
||||
ignore_np,
|
||||
total_gt,
|
||||
)
|
||||
|
||||
|
||||
def _accumulate_bbox_class(
|
||||
pred_scores_np: np.ndarray[Any, np.dtype[np.floating[Any]]],
|
||||
class_slice: _ClassSlice | None,
|
||||
gt_crowd_np: np.ndarray[Any, np.dtype[np.bool_]],
|
||||
bbox_iou_matrix_np: np.ndarray[Any, np.dtype[np.float32]] | None,
|
||||
iou_threshold: float,
|
||||
) -> _ClassContribution:
|
||||
"""Accumulate one class of one image against the image's shared box-IoU matrix.
|
||||
|
||||
Everything needed here is already on the host, so the class is selected by slicing the per-image
|
||||
NumPy arrays instead of by indexing device tensors. A *class_slice* of ``None`` says the image
|
||||
holds one class only: it then owns every row and column of *bbox_iou_matrix_np*, and the
|
||||
two-axis fancy-index copy that isolates a class becomes a copy of the whole matrix onto itself,
|
||||
so only the rows are reordered and the crowd mask is taken as it stands.
|
||||
|
||||
Args:
|
||||
pred_scores_np: Every detection score of the image, in the caller's own float dtype.
|
||||
class_slice: The class's prediction rows and GT columns, or ``None`` when the image holds a
|
||||
single class. A class with predictions but no GT gets an empty column selection, never
|
||||
``None``.
|
||||
gt_crowd_np: Crowd flag per GT of the image.
|
||||
bbox_iou_matrix_np: The image's ``box_iou`` matrix, or ``None`` when the image has no
|
||||
detections or no GT at all. A class without GT returns before it is read.
|
||||
iou_threshold: Minimum IoU to count as a positive match.
|
||||
|
||||
Returns:
|
||||
Tuple ``(scores_np, matches_np, ignore_np, total_gt)`` contributed by the class.
|
||||
"""
|
||||
p_scores_np = pred_scores_np if class_slice is None else pred_scores_np[class_slice[0]]
|
||||
n_gt = gt_crowd_np.size if class_slice is None else class_slice[1].size
|
||||
|
||||
if n_gt == 0:
|
||||
return _unmatched_contribution(p_scores_np)
|
||||
|
||||
assert bbox_iou_matrix_np is not None, (
|
||||
"the image-wide box_iou matrix is built whenever the image has both detections and GTs, "
|
||||
"and this class has at least one of each"
|
||||
)
|
||||
order = np.argsort(-p_scores_np, kind="stable")
|
||||
if class_slice is None:
|
||||
iou_matrix_sorted, class_crowd_np = bbox_iou_matrix_np[order], gt_crowd_np
|
||||
else:
|
||||
pred_indices, gt_indices = class_slice
|
||||
iou_matrix_sorted = bbox_iou_matrix_np[np.ix_(pred_indices[order], gt_indices)]
|
||||
class_crowd_np = gt_crowd_np[gt_indices]
|
||||
|
||||
matches_np, ignore_np, total_gt = _match_sorted_iou_matrix(iou_matrix_sorted, class_crowd_np, iou_threshold)
|
||||
return p_scores_np[order].astype(np.float32, copy=False), matches_np, ignore_np, total_gt
|
||||
|
||||
|
||||
def _accumulate_segm_class(
|
||||
preds: dict[str, Tensor],
|
||||
targets: dict[str, Tensor],
|
||||
gt_crowd: Tensor,
|
||||
class_id: int,
|
||||
n_gt: int,
|
||||
iou_threshold: float,
|
||||
) -> _ClassContribution:
|
||||
"""Accumulate one class of one image on boolean-mask IoU.
|
||||
|
||||
Mask IoU stays class-local rather than being shared image-wide like the box path: an all-pairs
|
||||
mask matrix is much larger, and most of it would cover pairs that cannot match.
|
||||
|
||||
Args:
|
||||
preds: One image's predictions, keyed as documented on ``build_matching_data()``.
|
||||
targets: One image's ground truths, keyed as documented on ``build_matching_data()``.
|
||||
gt_crowd: Bool tensor [M] aligned to ``targets["labels"]``, already validated by the caller.
|
||||
class_id: Class to accumulate.
|
||||
n_gt: Number of GTs of *class_id* in this image. Taken from the caller's host-side label
|
||||
counts rather than read back out of ``targets["labels"]``, which would cost one
|
||||
device-to-host sync per class.
|
||||
iou_threshold: Minimum IoU to count as a positive match.
|
||||
|
||||
Returns:
|
||||
Tuple ``(scores_np, matches_np, ignore_np, total_gt)`` contributed by *class_id*.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``masks`` is missing from *preds* or *targets*. A class with no GT of its own
|
||||
returns before that lookup, so a one-sided class is never reported.
|
||||
"""
|
||||
pred_mask_c = preds["labels"] == class_id
|
||||
p_scores = preds["scores"][pred_mask_c]
|
||||
|
||||
if n_gt == 0:
|
||||
# TODO: support bfloat16 natively once numpy adds bf16 dtype
|
||||
return _unmatched_contribution(np.asarray(p_scores.detach().float().cpu().numpy(), dtype=np.float32))
|
||||
|
||||
pred_masks = preds.get("masks")
|
||||
gt_masks = targets.get("masks")
|
||||
if pred_masks is None or gt_masks is None:
|
||||
raise ValueError("iou_type='segm' requires 'masks' in both preds and targets")
|
||||
|
||||
gt_mask_c = targets["labels"] == class_id
|
||||
return _match_single_class_segm(
|
||||
p_scores, pred_masks[pred_mask_c], gt_masks[gt_mask_c], gt_crowd[gt_mask_c], iou_threshold
|
||||
)
|
||||
|
||||
|
||||
def build_matching_data(
|
||||
preds_list: list[dict[str, Tensor]],
|
||||
targets_list: list[dict[str, Tensor]],
|
||||
@@ -156,6 +438,11 @@ def build_matching_data(
|
||||
directly to ``merge_matching_data()`` and ultimately consumed by ``sweep_confidence_thresholds()`` after conversion
|
||||
to list form.
|
||||
|
||||
Detections are ranked in the dtype of ``preds["scores"]``, so a ``float64`` input keeps the full precision that
|
||||
separates near-tied scores, and detections that really are tied keep their input order. Both ``iou_type`` paths
|
||||
share that rule, which makes the TP/FP split reproducible across devices and dtypes. The returned scores are
|
||||
float32 either way.
|
||||
|
||||
Args:
|
||||
preds_list: Per-image predictions. Each dict must contain:
|
||||
|
||||
@@ -195,11 +482,9 @@ def build_matching_data(
|
||||
pred_boxes = preds["boxes"] # [N, 4]
|
||||
pred_scores = preds["scores"] # [N]
|
||||
pred_labels = preds["labels"] # [N]
|
||||
pred_masks = preds.get("masks") # [N, H, W] | None
|
||||
|
||||
gt_boxes = targets["boxes"] # [M, 4]
|
||||
gt_labels = targets["labels"] # [M]
|
||||
gt_masks = targets.get("masks") # [M, H, W] | None
|
||||
raw_crowd = targets.get(
|
||||
"iscrowd",
|
||||
torch.zeros(len(gt_labels), dtype=torch.long, device=gt_labels.device),
|
||||
@@ -225,52 +510,69 @@ def build_matching_data(
|
||||
gt_noncrowd_count = Counter(label for label, crowd in zip(gt_label_ids, gt_crowd_ids) if not crowd)
|
||||
all_class_ids: set[int] = set(gt_count) | set(pred_count)
|
||||
|
||||
for class_id in all_class_ids:
|
||||
n_pred = pred_count.get(class_id, 0)
|
||||
n_gt = gt_count.get(class_id, 0)
|
||||
# One image whose detections and GTs are all of the same class needs no per-class positions
|
||||
# at all: that class owns every row and column of the IoU matrix below.
|
||||
single_class = len(all_class_ids) == 1
|
||||
pred_groups: dict[int, np.ndarray[Any, np.dtype[np.intp]]] = {}
|
||||
gt_groups: dict[int, np.ndarray[Any, np.dtype[np.intp]]] = {}
|
||||
if iou_type == "bbox" and pred_count and not single_class:
|
||||
pred_groups = _group_indices_by_label(np.asarray(pred_label_ids, dtype=np.int64))
|
||||
gt_groups = _group_indices_by_label(np.asarray(gt_label_ids, dtype=np.int64))
|
||||
|
||||
gt_crowd_np = np.asarray(gt_crowd_ids, dtype=np.bool_)
|
||||
bbox_iou_matrix_np: np.ndarray[Any, np.dtype[np.float32]] | None = None
|
||||
# Left in the caller's own dtype: this array orders the greedy loop, and a float32 cast
|
||||
# taken here would collapse near-tied float64 scores onto one value and hand the GT to
|
||||
# whichever detection happened to come first instead of to the higher-scoring one. Only
|
||||
# bfloat16 is cast, because numpy has no bfloat16 dtype. The scores that are handed back
|
||||
# are cast to float32 after the ordering is fixed, so the returned dtype is unchanged.
|
||||
pred_scores_np: np.ndarray[Any, np.dtype[np.floating[Any]]] | None = None
|
||||
if iou_type == "bbox" and pred_count:
|
||||
pred_scores_cpu = pred_scores.detach().cpu()
|
||||
if pred_scores_cpu.dtype == torch.bfloat16:
|
||||
pred_scores_cpu = pred_scores_cpu.float()
|
||||
pred_scores_np = pred_scores_cpu.numpy()
|
||||
if iou_type == "bbox" and pred_count and gt_count:
|
||||
# One image-wide GPU IoU operation and one matrix transfer replace the
|
||||
# class-local launches/transfers. Host slicing below retains class isolation.
|
||||
bbox_iou_matrix_np = np.asarray(
|
||||
box_iou(pred_boxes, gt_boxes).detach().float().cpu().numpy(), dtype=np.float32
|
||||
)
|
||||
|
||||
for class_id in all_class_ids:
|
||||
entry = acc.setdefault(
|
||||
class_id,
|
||||
{"scores": [], "matches": [], "ignore": [], "total_gt": 0},
|
||||
)
|
||||
|
||||
if n_pred == 0:
|
||||
if pred_count.get(class_id, 0) == 0:
|
||||
entry["total_gt"] = cast(int, entry["total_gt"]) + gt_noncrowd_count.get(class_id, 0)
|
||||
continue
|
||||
|
||||
# Only materialize the boolean mask / gather predictions for classes that actually
|
||||
# have detections — gt_mask_c below is deferred further, to classes that also matter.
|
||||
pred_mask_c = pred_labels == class_id
|
||||
p_scores = pred_scores[pred_mask_c]
|
||||
|
||||
if n_gt == 0:
|
||||
# TODO: support bfloat16 natively once numpy adds bf16 dtype
|
||||
sc = np.asarray(p_scores.float().cpu().numpy(), dtype=np.float32)
|
||||
order = np.argsort(-sc)
|
||||
cast(list[float], entry["scores"]).extend(sc[order].tolist())
|
||||
cast(list[int], entry["matches"]).extend([0] * n_pred)
|
||||
cast(list[bool], entry["ignore"]).extend([False] * n_pred)
|
||||
continue
|
||||
|
||||
gt_mask_c = gt_labels == class_id
|
||||
gt_crowd_c = gt_crowd[gt_mask_c]
|
||||
|
||||
# The one place ``iou_type`` is branched on per class: each helper owns its own scores
|
||||
# variable and its own empty-GT case, so the two paths cannot drift out of agreement.
|
||||
if iou_type == "bbox":
|
||||
p_items: Tensor = pred_boxes[pred_mask_c] # [n_pred, 4]
|
||||
gt_items: Tensor = gt_boxes[gt_mask_c] # [n_gt, 4]
|
||||
assert pred_scores_np is not None, (
|
||||
"the image's scores are moved to host whenever it has detections, and this class has one"
|
||||
)
|
||||
class_slice = None if single_class else (pred_groups[class_id], gt_groups.get(class_id, _NO_INDICES))
|
||||
scores_np, matches_np, ignore_np, total_gt = _accumulate_bbox_class(
|
||||
pred_scores_np,
|
||||
class_slice,
|
||||
gt_crowd_np,
|
||||
bbox_iou_matrix_np,
|
||||
iou_threshold,
|
||||
)
|
||||
else:
|
||||
if pred_masks is None or gt_masks is None:
|
||||
raise ValueError("iou_type='segm' requires 'masks' in both preds and targets")
|
||||
p_items = pred_masks[pred_mask_c] # [n_pred, H, W]
|
||||
gt_items = gt_masks[gt_mask_c] # [n_gt, H, W]
|
||||
scores_np, matches_np, ignore_np, total_gt = _accumulate_segm_class(
|
||||
preds, targets, gt_crowd, class_id, gt_count.get(class_id, 0), iou_threshold
|
||||
)
|
||||
|
||||
scores_np, matches_np, ignore_np, total_gt = _match_single_class(
|
||||
p_scores, p_items, gt_items, gt_crowd_c, iou_threshold, iou_type
|
||||
)
|
||||
|
||||
cast(list[float], entry["scores"]).extend(float(score) for score in scores_np)
|
||||
cast(list[int], entry["matches"]).extend(int(match) for match in matches_np)
|
||||
cast(list[bool], entry["ignore"]).extend(bool(ignore) for ignore in ignore_np)
|
||||
# ndarray.tolist() converts in C and yields the same Python scalars a per-element
|
||||
# float()/int()/bool() generator would, one interpreter round-trip instead of N.
|
||||
cast(list[float], entry["scores"]).extend(cast(list[float], scores_np.tolist()))
|
||||
cast(list[int], entry["matches"]).extend(cast(list[int], matches_np.tolist()))
|
||||
cast(list[bool], entry["ignore"]).extend(cast(list[bool], ignore_np.tolist()))
|
||||
entry["total_gt"] = cast(int, entry["total_gt"]) + total_gt
|
||||
|
||||
return {
|
||||
|
||||
+588
-102
@@ -15,7 +15,10 @@ from torchvision.ops import box_iou
|
||||
|
||||
from rfdetr.evaluation.matching import (
|
||||
_compute_mask_iou,
|
||||
_match_single_class,
|
||||
_match_rows_with_crowd,
|
||||
_match_rows_without_crowd,
|
||||
_match_single_class_segm,
|
||||
_match_sorted_iou_matrix,
|
||||
build_matching_data,
|
||||
distributed_merge_matching_data,
|
||||
init_matching_accumulator,
|
||||
@@ -76,50 +79,255 @@ class TestComputeMaskIou:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _match_single_class
|
||||
# _match_sorted_iou_matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMatchSingleClass:
|
||||
"""Unit tests for the private _match_single_class helper."""
|
||||
class TestMatchSortedIouMatrix:
|
||||
"""Direct unit tests for the private _match_sorted_iou_matrix helper.
|
||||
|
||||
Every other test in this file drives this function indirectly, through ``_match_single_class_segm``,
|
||||
``_accumulate_bbox_class``, or ``build_matching_data``. These cases call it directly with small hand-built numpy IoU
|
||||
matrices, so a regression here is localized to this one function instead of surfacing only as a mismatch several
|
||||
call frames up.
|
||||
"""
|
||||
|
||||
def test_disjoint_and_matched_gts_split_into_tp_and_fp(self) -> None:
|
||||
"""One detection with sufficient IoU is a TP; one with insufficient IoU is a FP.
|
||||
|
||||
Two detections, each with its own GT (no competition between them), isolates the
|
||||
threshold comparison from the greedy claiming logic: row 0's IoU (0.9) clears the 0.5
|
||||
threshold against its own GT, row 1's IoU (0.1) does not clear it against its own GT.
|
||||
"""
|
||||
iou_matrix_sorted = np.array([[0.9, 0.0], [0.0, 0.1]], dtype=np.float32)
|
||||
gt_crowd_np = np.zeros(2, dtype=np.bool_)
|
||||
|
||||
matches, ignore, total_gt = _match_sorted_iou_matrix(iou_matrix_sorted, gt_crowd_np, iou_threshold=0.5)
|
||||
|
||||
np.testing.assert_array_equal(matches, [1, 0])
|
||||
np.testing.assert_array_equal(ignore, [False, False])
|
||||
assert total_gt == 2
|
||||
|
||||
def test_detection_matched_only_to_crowd_gt_is_ignored_not_fp(self) -> None:
|
||||
"""A detection whose only sufficient-IoU GT is a crowd instance is ignored, not a FP.
|
||||
|
||||
One detection, one crowd GT, IoU above threshold: the crowd-ignore branch must fire instead of the false-
|
||||
positive branch, and the crowd GT must not inflate total_gt.
|
||||
"""
|
||||
iou_matrix_sorted = np.array([[0.9]], dtype=np.float32)
|
||||
gt_crowd_np = np.array([True], dtype=np.bool_)
|
||||
|
||||
matches, ignore, total_gt = _match_sorted_iou_matrix(iou_matrix_sorted, gt_crowd_np, iou_threshold=0.5)
|
||||
|
||||
np.testing.assert_array_equal(matches, [0])
|
||||
np.testing.assert_array_equal(ignore, [True])
|
||||
assert total_gt == 0
|
||||
|
||||
def test_zero_gt_columns_raises_value_error(self) -> None:
|
||||
"""An IoU matrix with no GT columns (m=0) raises ValueError, not a silent empty result.
|
||||
|
||||
``np.argmax`` on the per-detection IoU row is unconditional, so a matrix with zero columns fails inside the loop
|
||||
instead of returning early. Both call sites (``_accumulate_bbox_class``, ``_match_single_class_segm``) guard
|
||||
against calling this function when a class has no GT, so this test documents/locks the current contract rather
|
||||
than exercising a path either caller actually reaches.
|
||||
"""
|
||||
iou_matrix_sorted = np.zeros((2, 0), dtype=np.float32)
|
||||
gt_crowd_np = np.zeros(0, dtype=np.bool_)
|
||||
|
||||
with pytest.raises(ValueError, match="argmax of an empty sequence"):
|
||||
_match_sorted_iou_matrix(iou_matrix_sorted, gt_crowd_np, iou_threshold=0.5)
|
||||
|
||||
def test_zero_pred_rows_returns_empty_arrays_without_error(self) -> None:
|
||||
"""An IoU matrix with no prediction rows (n=0) returns empty arrays, unlike the m=0 case.
|
||||
|
||||
The greedy loop iterates ``range(len(iou_matrix_sorted))``, which is 0 when there are no predictions, so the
|
||||
loop body — and the ``np.argmax`` call that raises for m=0 — never executes. ``total_gt`` is still computed from
|
||||
``gt_crowd_np`` alone, so it is asserted against a non-trivial mix of crowd and non-crowd GTs rather than an
|
||||
all-non-crowd matrix.
|
||||
"""
|
||||
iou_matrix_sorted = np.zeros((0, 2), dtype=np.float32)
|
||||
gt_crowd_np = np.array([False, True], dtype=np.bool_)
|
||||
|
||||
matches, ignore, total_gt = _match_sorted_iou_matrix(iou_matrix_sorted, gt_crowd_np, iou_threshold=0.5)
|
||||
|
||||
assert matches.shape == (0,)
|
||||
assert ignore.shape == (0,)
|
||||
assert total_gt == 1
|
||||
|
||||
@pytest.mark.parametrize("seed", [pytest.param(seed, id=f"seed-{seed}") for seed in range(20)])
|
||||
def test_crowd_free_loop_agrees_with_the_general_loop(self, seed: int) -> None:
|
||||
"""``_match_rows_without_crowd`` returns exactly what ``_match_rows_with_crowd`` returns on a crowd-free class.
|
||||
|
||||
``_match_sorted_iou_matrix`` picks between the two loops on ``gt_crowd_np.any()``, so no other test in this
|
||||
suite can observe the equivalence they are built on: a crowd-free input never reaches the general loop again.
|
||||
Each seed draws a dense IoU matrix with six times as many detections as GTs and matches at a 0.5 threshold, so
|
||||
GTs are contested and the crowd-free loop's fallback (its best GT already claimed by a higher-scoring
|
||||
detection) is reached instead of only its two shortcut branches — the assertion that every GT ends up claimed
|
||||
is what pins that contention down.
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
iou_matrix_sorted = rng.uniform(0.0, 1.0, size=(24, 4)).astype(np.float32)
|
||||
gt_crowd_np = np.zeros(4, dtype=np.bool_)
|
||||
|
||||
crowd_free = _match_rows_without_crowd(iou_matrix_sorted, iou_threshold=0.5)
|
||||
general = _match_rows_with_crowd(iou_matrix_sorted, gt_crowd_np, iou_threshold=0.5)
|
||||
|
||||
np.testing.assert_array_equal(crowd_free[0], general[0])
|
||||
np.testing.assert_array_equal(crowd_free[1], general[1])
|
||||
assert crowd_free[2] == general[2]
|
||||
assert crowd_free[0].sum() == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mask rasterisation shared by the segm matcher tests and build_matching_data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _masks_from_boxes(boxes: list[list[float]], height: int, width: int) -> torch.Tensor:
|
||||
"""Rasterise integer-aligned xyxy boxes into a boolean mask stack.
|
||||
|
||||
Lets the ``iou_type="segm"`` path be driven by the same geometry as the ``"bbox"`` path: for
|
||||
integer-aligned boxes the pixel-count mask IoU equals the box-area IoU exactly, so both paths
|
||||
see the same IoU matrix and any difference in the result comes from the ranking alone.
|
||||
|
||||
Args:
|
||||
boxes: Rows of ``[x1, y1, x2, y2]`` in pixel coordinates.
|
||||
height: Height of each rasterised mask.
|
||||
width: Width of each rasterised mask.
|
||||
|
||||
Returns:
|
||||
Boolean tensor of shape ``[len(boxes), height, width]``.
|
||||
|
||||
Examples:
|
||||
>>> _masks_from_boxes([[0, 0, 2, 1]], height=1, width=4).int().tolist()
|
||||
[[[1, 1, 0, 0]]]
|
||||
"""
|
||||
masks = torch.zeros(len(boxes), height, width, dtype=torch.bool)
|
||||
for index, (x1, y1, x2, y2) in enumerate(boxes):
|
||||
masks[index, int(y1) : int(y2), int(x1) : int(x2)] = True
|
||||
return masks
|
||||
|
||||
|
||||
def _reference_greedy_match(
|
||||
order: list[int],
|
||||
iou_matrix: torch.Tensor,
|
||||
gt_crowd: torch.Tensor,
|
||||
iou_threshold: float,
|
||||
) -> tuple[np.ndarray, np.ndarray, int]:
|
||||
"""Naive, independently-written greedy matcher used as a ground-truth oracle.
|
||||
|
||||
Reimplements the COCO greedy-matching contract directly from spec with plain Python loops and
|
||||
no shared code with ``_match_single_class_segm``/``_match_sorted_iou_matrix`` (no numpy
|
||||
vectorization, no hoisted crowd mask) — a divergence in the optimized/numpy-ported
|
||||
implementation (e.g. a tie-break or crowd-handling regression) will disagree with this
|
||||
reference instead of silently agreeing with itself. Module-level because it is shared by
|
||||
``TestMatchSingleClassSegm`` (segm path, one class at a time) and
|
||||
``TestBuildMatchingDataBboxDifferential`` (bbox path, one class-slice at a time).
|
||||
|
||||
Args:
|
||||
order: Detection indices into ``iou_matrix``'s rows, in descending-score order.
|
||||
iou_matrix: Pairwise IoU, ``[n_preds, n_gt]``, rows in original (unsorted) order.
|
||||
gt_crowd: Bool tensor ``[n_gt]``, ``True`` for crowd instances.
|
||||
iou_threshold: Minimum IoU to count as a positive match.
|
||||
|
||||
Returns:
|
||||
Tuple ``(matches, ignore, total_gt)`` aligned to ``order``.
|
||||
|
||||
Examples:
|
||||
>>> order = [0, 1]
|
||||
>>> iou_matrix = torch.tensor([[0.9, 0.0], [0.0, 0.9]])
|
||||
>>> gt_crowd = torch.zeros(2, dtype=torch.bool)
|
||||
>>> matches, ignore, total_gt = _reference_greedy_match(order, iou_matrix, gt_crowd, 0.5)
|
||||
>>> matches.tolist()
|
||||
[1, 1]
|
||||
>>> total_gt
|
||||
2
|
||||
"""
|
||||
n_gt = iou_matrix.shape[1]
|
||||
gt_matched = [False] * n_gt
|
||||
matches = np.zeros(len(order), dtype=np.int64)
|
||||
ignore = np.zeros(len(order), dtype=np.bool_)
|
||||
for out_i, orig_i in enumerate(order):
|
||||
best_iou, best_gt = -1.0, -1
|
||||
for j in range(n_gt):
|
||||
if gt_crowd[j] or gt_matched[j]:
|
||||
continue
|
||||
iou = float(iou_matrix[orig_i, j])
|
||||
if iou > best_iou:
|
||||
best_iou, best_gt = iou, j
|
||||
if best_gt != -1 and best_iou >= iou_threshold:
|
||||
matches[out_i] = 1
|
||||
gt_matched[best_gt] = True
|
||||
continue
|
||||
for j in range(n_gt):
|
||||
if gt_crowd[j] and float(iou_matrix[orig_i, j]) >= iou_threshold:
|
||||
ignore[out_i] = True
|
||||
break
|
||||
total_gt = int((~gt_crowd.numpy()).sum())
|
||||
return matches, ignore, total_gt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _match_single_class_segm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMatchSingleClassSegm:
|
||||
"""Unit tests for the private _match_single_class_segm helper.
|
||||
|
||||
Cases are written as xyxy boxes and rasterised with ``_masks_from_boxes``, because for integer-aligned boxes the
|
||||
pixel-count mask IoU equals the box-area IoU exactly — the expected IoU of each case can be read straight off the
|
||||
coordinates. ``test_uses_mask_overlap_not_bounding_box`` is the deliberate exception, and builds masks that no box
|
||||
could describe.
|
||||
"""
|
||||
|
||||
#: Side of the square canvas every box below is rasterised onto — larger than any coordinate used.
|
||||
_CANVAS = 64
|
||||
|
||||
@classmethod
|
||||
def _mask(cls, *coords: float) -> torch.Tensor:
|
||||
"""Return a [1, H, W] boolean mask covering the single xyxy box *coords*."""
|
||||
return _masks_from_boxes([list(coords)], cls._CANVAS, cls._CANVAS)
|
||||
|
||||
@classmethod
|
||||
def _masks(cls, *rows: list[float]) -> torch.Tensor:
|
||||
"""Return an [N, H, W] boolean mask stack from a sequence of [x1,y1,x2,y2] rows."""
|
||||
return _masks_from_boxes(list(rows), cls._CANVAS, cls._CANVAS)
|
||||
|
||||
@staticmethod
|
||||
def _box(*coords: float) -> torch.Tensor:
|
||||
"""Return a [1, 4] float32 box tensor from (x1, y1, x2, y2)."""
|
||||
return torch.tensor([list(coords)], dtype=torch.float32)
|
||||
|
||||
@staticmethod
|
||||
def _boxes(*rows: list[float]) -> torch.Tensor:
|
||||
"""Return an [N, 4] float32 tensor from a sequence of [x1,y1,x2,y2] rows."""
|
||||
return torch.tensor(list(rows), dtype=torch.float32)
|
||||
def _random_boxes(count: int, canvas: int) -> torch.Tensor:
|
||||
"""Return *count* random integer-aligned xyxy boxes that fit inside a *canvas*-square image."""
|
||||
top_left = torch.randint(0, canvas // 2, (count, 2))
|
||||
extent = torch.randint(1, canvas // 2 + 1, (count, 2))
|
||||
return torch.cat([top_left, top_left + extent], dim=1).float()
|
||||
|
||||
def _run(
|
||||
self,
|
||||
pred_scores: torch.Tensor,
|
||||
pred_items: torch.Tensor,
|
||||
gt_items: torch.Tensor,
|
||||
pred_masks: torch.Tensor,
|
||||
gt_masks: torch.Tensor,
|
||||
gt_crowd: torch.Tensor | None = None,
|
||||
iou_threshold: float = 0.5,
|
||||
iou_type: str = "bbox",
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, int]:
|
||||
"""Run the matcher, defaulting every GT to non-crowd."""
|
||||
if gt_crowd is None:
|
||||
gt_crowd = torch.zeros(len(gt_items), dtype=torch.bool)
|
||||
return _match_single_class(pred_scores, pred_items, gt_items, gt_crowd, iou_threshold, iou_type)
|
||||
gt_crowd = torch.zeros(len(gt_masks), dtype=torch.bool)
|
||||
return _match_single_class_segm(pred_scores, pred_masks, gt_masks, gt_crowd, iou_threshold)
|
||||
|
||||
def test_perfect_overlap_is_tp(self) -> None:
|
||||
"""A prediction that perfectly overlaps the GT box is a true positive."""
|
||||
"""A prediction whose mask is identical to the GT mask is a true positive."""
|
||||
scores = torch.tensor([0.9])
|
||||
box = self._box(0, 0, 10, 10)
|
||||
_, matches, ignore, total_gt = self._run(scores, box, box)
|
||||
mask = self._mask(0, 0, 10, 10)
|
||||
_, matches, ignore, total_gt = self._run(scores, mask, mask)
|
||||
assert matches[0] == 1
|
||||
assert not ignore[0]
|
||||
assert total_gt == 1
|
||||
|
||||
def test_disjoint_box_is_fp(self) -> None:
|
||||
"""A prediction with no overlap with the GT box is a false positive."""
|
||||
def test_disjoint_mask_is_fp(self) -> None:
|
||||
"""A prediction with no overlap with the GT mask is a false positive."""
|
||||
scores = torch.tensor([0.9])
|
||||
pred = self._box(0, 0, 10, 10)
|
||||
gt = self._box(50, 50, 60, 60)
|
||||
pred = self._mask(0, 0, 10, 10)
|
||||
gt = self._mask(50, 50, 60, 60)
|
||||
_, matches, ignore, total_gt = self._run(scores, pred, gt)
|
||||
assert matches[0] == 0
|
||||
assert not ignore[0]
|
||||
@@ -128,8 +336,8 @@ class TestMatchSingleClass:
|
||||
def test_iou_below_threshold_is_fp(self) -> None:
|
||||
"""A detection with IoU < threshold must be marked as FP."""
|
||||
scores = torch.tensor([0.9])
|
||||
pred = self._box(0, 0, 5, 10) # area = 50
|
||||
gt = self._box(6, 0, 10, 10) # area = 40 — no overlap
|
||||
pred = self._mask(0, 0, 5, 10) # area = 50
|
||||
gt = self._mask(6, 0, 10, 10) # area = 40 — no overlap
|
||||
_, matches, _, _ = self._run(scores, pred, gt, iou_threshold=0.5)
|
||||
assert matches[0] == 0
|
||||
|
||||
@@ -137,8 +345,8 @@ class TestMatchSingleClass:
|
||||
"""When two predictions compete for one GT, the higher-score pred wins."""
|
||||
# Sorted descending: [0.9, 0.5] -> first gets TP, second gets FP.
|
||||
scores = torch.tensor([0.5, 0.9])
|
||||
preds = self._boxes([0, 0, 10, 10], [0, 0, 10, 10])
|
||||
gt = self._box(0, 0, 10, 10)
|
||||
preds = self._masks([0, 0, 10, 10], [0, 0, 10, 10])
|
||||
gt = self._mask(0, 0, 10, 10)
|
||||
scores_out, matches, _, _ = self._run(scores, preds, gt)
|
||||
assert list(scores_out) == pytest.approx([0.9, 0.5])
|
||||
assert matches[0] == 1 # highest score -> TP
|
||||
@@ -147,9 +355,9 @@ class TestMatchSingleClass:
|
||||
def test_crowd_gt_match_is_ignored_not_fp(self) -> None:
|
||||
"""A detection matched to a crowd GT is ignored, not a false positive."""
|
||||
scores = torch.tensor([0.9])
|
||||
box = self._box(0, 0, 10, 10)
|
||||
mask = self._mask(0, 0, 10, 10)
|
||||
gt_crowd = torch.tensor([True])
|
||||
_, matches, ignore, total_gt = self._run(scores, box, box, gt_crowd=gt_crowd)
|
||||
_, matches, ignore, total_gt = self._run(scores, mask, mask, gt_crowd=gt_crowd)
|
||||
assert matches[0] == 0 # not TP
|
||||
assert ignore[0] # ignored -> not counted as FP
|
||||
assert total_gt == 0 # crowd GT excluded from denominator
|
||||
@@ -157,95 +365,60 @@ class TestMatchSingleClass:
|
||||
def test_non_crowd_gt_counts_in_total_gt(self) -> None:
|
||||
"""Non-crowd GTs are counted in total_gt."""
|
||||
scores = torch.tensor([0.9])
|
||||
box = self._box(0, 0, 10, 10)
|
||||
mask = self._mask(0, 0, 10, 10)
|
||||
gt_crowd = torch.tensor([False])
|
||||
_, _, _, total_gt = self._run(scores, box, box, gt_crowd=gt_crowd)
|
||||
_, _, _, total_gt = self._run(scores, mask, mask, gt_crowd=gt_crowd)
|
||||
assert total_gt == 1
|
||||
|
||||
def test_mixed_crowd_only_non_crowd_in_total_gt(self) -> None:
|
||||
"""Only non-crowd instances contribute to total_gt."""
|
||||
scores = torch.tensor([0.9])
|
||||
pred = self._box(0, 0, 5, 5) # overlaps neither GT significantly
|
||||
gt_boxes = self._boxes([0, 0, 10, 10], [20, 20, 30, 30])
|
||||
pred = self._mask(0, 0, 5, 5) # overlaps neither GT significantly
|
||||
gt_masks = self._masks([0, 0, 10, 10], [20, 20, 30, 30])
|
||||
gt_crowd = torch.tensor([False, True]) # second GT is crowd
|
||||
_, _, _, total_gt = self._run(scores, pred, gt_boxes, gt_crowd=gt_crowd)
|
||||
_, _, _, total_gt = self._run(scores, pred, gt_masks, gt_crowd=gt_crowd)
|
||||
assert total_gt == 1
|
||||
|
||||
def test_scores_returned_in_descending_order(self) -> None:
|
||||
"""Output scores must be sorted in descending order."""
|
||||
scores = torch.tensor([0.3, 0.9, 0.6])
|
||||
preds = self._boxes([0, 0, 10, 10], [20, 20, 30, 30], [40, 40, 50, 50])
|
||||
gt = self._box(20, 20, 30, 30)
|
||||
preds = self._masks([0, 0, 10, 10], [20, 20, 30, 30], [40, 40, 50, 50])
|
||||
gt = self._mask(20, 20, 30, 30)
|
||||
scores_out, _, _, _ = self._run(scores, preds, gt)
|
||||
assert list(scores_out) == pytest.approx([0.9, 0.6, 0.3])
|
||||
|
||||
def test_segm_iou_type_identical_masks_is_tp(self) -> None:
|
||||
"""Identical masks with iou_type='segm' should yield a TP."""
|
||||
mask = torch.ones(1, 4, 4, dtype=torch.bool)
|
||||
scores = torch.tensor([0.9])
|
||||
gt_crowd = torch.tensor([False])
|
||||
_, matches, _, total_gt = _match_single_class(scores, mask, mask, gt_crowd, 0.5, "segm")
|
||||
assert matches[0] == 1
|
||||
assert total_gt == 1
|
||||
def test_uses_mask_overlap_not_bounding_box(self) -> None:
|
||||
"""Matching is driven by pixel overlap, not by the region the masks span.
|
||||
|
||||
@staticmethod
|
||||
def _reference_greedy_match(
|
||||
order: list[int],
|
||||
iou_matrix: torch.Tensor,
|
||||
gt_crowd: torch.Tensor,
|
||||
iou_threshold: float,
|
||||
) -> tuple[np.ndarray, np.ndarray, int]:
|
||||
"""Naive, independently-written greedy matcher used as a ground-truth oracle.
|
||||
|
||||
Reimplements the COCO greedy-matching contract directly from spec with plain Python
|
||||
loops and no shared code with ``_match_single_class`` (no numpy vectorization, no
|
||||
hoisted crowd mask) — a divergence in the optimized/numpy-ported implementation (e.g. a
|
||||
tie-break or crowd-handling regression) will disagree with this reference instead of
|
||||
silently agreeing with itself.
|
||||
|
||||
Args:
|
||||
order: Detection indices into ``iou_matrix``'s rows, in descending-score order.
|
||||
iou_matrix: Pairwise IoU, ``[n_preds, n_gt]``, rows in original (unsorted) order.
|
||||
gt_crowd: Bool tensor ``[n_gt]``, ``True`` for crowd instances.
|
||||
iou_threshold: Minimum IoU to count as a positive match.
|
||||
|
||||
Returns:
|
||||
Tuple ``(matches, ignore, total_gt)`` aligned to ``order``.
|
||||
Two interleaved comb masks share no pixel at all (mask IoU 0.0), yet the boxes bounding them overlap at exactly
|
||||
the 0.5 threshold — geometry that a box-area proxy would score as a TP and true mask overlap scores as a FP.
|
||||
This is the one case in the class whose masks are built directly rather than rasterised from boxes, because no
|
||||
box can describe them.
|
||||
"""
|
||||
n_gt = iou_matrix.shape[1]
|
||||
gt_matched = [False] * n_gt
|
||||
matches = np.zeros(len(order), dtype=np.int64)
|
||||
ignore = np.zeros(len(order), dtype=np.bool_)
|
||||
for out_i, orig_i in enumerate(order):
|
||||
best_iou, best_gt = -1.0, -1
|
||||
for j in range(n_gt):
|
||||
if gt_crowd[j] or gt_matched[j]:
|
||||
continue
|
||||
iou = float(iou_matrix[orig_i, j])
|
||||
if iou > best_iou:
|
||||
best_iou, best_gt = iou, j
|
||||
if best_gt != -1 and best_iou >= iou_threshold:
|
||||
matches[out_i] = 1
|
||||
gt_matched[best_gt] = True
|
||||
continue
|
||||
for j in range(n_gt):
|
||||
if gt_crowd[j] and float(iou_matrix[orig_i, j]) >= iou_threshold:
|
||||
ignore[out_i] = True
|
||||
break
|
||||
total_gt = int((~gt_crowd.numpy()).sum())
|
||||
return matches, ignore, total_gt
|
||||
pred = torch.zeros(1, 4, 4, dtype=torch.bool)
|
||||
pred[0, :, ::2] = True # columns 0 and 2 -> bounding box (0, 0, 3, 4)
|
||||
gt = ~pred # columns 1 and 3 -> bounding box (1, 0, 4, 4), box IoU 8/16 = 0.5
|
||||
scores = torch.tensor([0.9])
|
||||
_, matches, ignore, total_gt = self._run(scores, pred, gt)
|
||||
assert matches[0] == 0
|
||||
assert not ignore[0]
|
||||
assert total_gt == 1
|
||||
|
||||
def test_greedy_loop_does_not_sync_a_tensor_per_detection(self) -> None:
|
||||
"""Regression test for #416: the per-detection greedy loop must not force one device-to-host tensor sync
|
||||
(``Tensor.__bool__``) per detection, and the numpy-ported matching output must agree with an independent
|
||||
reference implementation of the same algorithm at scale (matches/ignore/total_gt) — the sync-count assert alone
|
||||
cannot catch a logic regression in the torch->numpy port (e.g. a tie-break or crowd-handling change)."""
|
||||
n, m = 50, 10
|
||||
cannot catch a logic regression in the torch->numpy port (e.g. a tie-break or crowd-handling change).
|
||||
|
||||
The geometry is random but integer-aligned, so ``box_iou`` over the source boxes reproduces the mask IoU the
|
||||
matcher computes exactly, and the oracle stays independent of ``_compute_mask_iou``.
|
||||
"""
|
||||
n, m, canvas = 50, 10, 32
|
||||
scores = torch.rand(n)
|
||||
preds = torch.rand(n, 4) * 100
|
||||
preds[:, 2:] += preds[:, :2] + 1.0
|
||||
gts = torch.rand(m, 4) * 100
|
||||
gts[:, 2:] += gts[:, :2] + 1.0
|
||||
pred_boxes = self._random_boxes(n, canvas)
|
||||
gt_boxes = self._random_boxes(m, canvas)
|
||||
pred_masks = _masks_from_boxes(pred_boxes.tolist(), canvas, canvas)
|
||||
gt_masks = _masks_from_boxes(gt_boxes.tolist(), canvas, canvas)
|
||||
gt_crowd = torch.zeros(m, dtype=torch.bool)
|
||||
gt_crowd[:3] = True # exercise the crowd/ignore branch at scale, not just n<=3 cases
|
||||
|
||||
@@ -258,17 +431,17 @@ class TestMatchSingleClass:
|
||||
return orig_bool(self)
|
||||
|
||||
with patch.object(torch.Tensor, "__bool__", counting_bool):
|
||||
scores_out, matches, ignore, total_gt = self._run(scores, preds, gts, gt_crowd=gt_crowd)
|
||||
scores_out, matches, ignore, total_gt = self._run(scores, pred_masks, gt_masks, gt_crowd=gt_crowd)
|
||||
|
||||
assert call_count < n, (
|
||||
f"_match_single_class triggered {call_count} tensor->bool syncs for n={n} "
|
||||
f"_match_single_class_segm triggered {call_count} tensor->bool syncs for n={n} "
|
||||
"detections; expected O(1) syncs, not O(n) — the greedy loop should operate "
|
||||
"on host data, not per-iteration device tensor comparisons"
|
||||
)
|
||||
|
||||
order = torch.argsort(scores, descending=True).tolist()
|
||||
iou_matrix = box_iou(preds, gts)
|
||||
ref_matches, ref_ignore, ref_total_gt = self._reference_greedy_match(order, iou_matrix, gt_crowd, 0.5)
|
||||
iou_matrix = box_iou(pred_boxes, gt_boxes)
|
||||
ref_matches, ref_ignore, ref_total_gt = _reference_greedy_match(order, iou_matrix, gt_crowd, 0.5)
|
||||
assert list(scores_out) == pytest.approx(scores[order].tolist())
|
||||
assert np.array_equal(matches, ref_matches)
|
||||
assert np.array_equal(ignore, ref_ignore)
|
||||
@@ -279,6 +452,24 @@ class TestMatchSingleClass:
|
||||
# build_matching_data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Geometry whose greedy matching outcome depends on the order the two detections are ranked in.
|
||||
# The two GTs overlap; the narrow detection can only reach the first, the wide one can reach either:
|
||||
#
|
||||
# IoU(narrow, GT-A) = 0.700 IoU(narrow, GT-B) = 0.133
|
||||
# IoU(wide, GT-A) = 0.667 IoU(wide, GT-B) = 0.538
|
||||
#
|
||||
# Ranking the narrow detection first yields two TPs (it claims GT-A, the wide one falls back to
|
||||
# GT-B); ranking the wide one first yields one (it claims GT-A, and GT-B is out of the narrow
|
||||
# detection's reach).
|
||||
_ORDER_SENSITIVE_GT_BOXES = [[0, 0, 100, 10], [50, 0, 150, 10]]
|
||||
_NARROW_PRED_BOX = [0, 0, 70, 10]
|
||||
_WIDE_PRED_BOX = [20, 0, 120, 10]
|
||||
# Padding that overlaps neither GT, so it cannot change the outcome, and pushes the detection count
|
||||
# past the ~32-element cutoff above which torch's default (unstable) sort permutes tied scores.
|
||||
_FILLER_PRED_BOX = [200, 0, 210, 10]
|
||||
_NUM_FILLER_PREDS = 38
|
||||
_ORDER_SENSITIVE_MASK_SIZE = (10, 210)
|
||||
|
||||
|
||||
class TestBuildMatchingData:
|
||||
"""Unit tests for build_matching_data()."""
|
||||
@@ -289,10 +480,11 @@ class TestBuildMatchingData:
|
||||
scores: list,
|
||||
labels: list,
|
||||
masks: torch.Tensor | None = None,
|
||||
scores_dtype: torch.dtype = torch.float32,
|
||||
) -> dict[str, torch.Tensor]:
|
||||
d: dict[str, torch.Tensor] = {
|
||||
"boxes": torch.tensor(boxes, dtype=torch.float32).reshape(-1, 4),
|
||||
"scores": torch.tensor(scores, dtype=torch.float32),
|
||||
"scores": torch.tensor(scores, dtype=scores_dtype),
|
||||
"labels": torch.tensor(labels, dtype=torch.int64),
|
||||
}
|
||||
if masks is not None:
|
||||
@@ -366,6 +558,78 @@ class TestBuildMatchingData:
|
||||
assert result[0]["total_gt"] == 1
|
||||
assert result[1]["total_gt"] == 1
|
||||
|
||||
def test_bbox_iou_is_computed_once_per_image(self) -> None:
|
||||
"""BBox matching shares one image-wide IoU matrix while preserving class-local matches."""
|
||||
pred = self._make_pred(
|
||||
[[0, 0, 10, 10], [0, 0, 10, 10], [40, 40, 50, 50]],
|
||||
[0.8, 0.95, 0.9],
|
||||
[0, 1, 0],
|
||||
)
|
||||
target = self._make_target([[0, 0, 10, 10], [20, 20, 30, 30]], [0, 1])
|
||||
|
||||
with patch("rfdetr.evaluation.matching.box_iou", wraps=box_iou) as box_iou_spy:
|
||||
result = build_matching_data([pred], [target])
|
||||
|
||||
box_iou_spy.assert_called_once_with(pred["boxes"], target["boxes"])
|
||||
np.testing.assert_allclose(result[0]["scores"], [0.9, 0.8], rtol=1e-6)
|
||||
np.testing.assert_array_equal(result[0]["matches"], [0, 1])
|
||||
np.testing.assert_array_equal(result[1]["matches"], [0])
|
||||
assert result[0]["total_gt"] == 1
|
||||
assert result[1]["total_gt"] == 1
|
||||
|
||||
@pytest.mark.parametrize("iou_type", [pytest.param("bbox", id="bbox"), pytest.param("segm", id="segm")])
|
||||
def test_tied_scores_are_ranked_in_input_order_on_both_iou_types(self, iou_type: str) -> None:
|
||||
"""Detections tied on score are ranked in input order on the bbox path and the segm path alike.
|
||||
|
||||
The two paths ran through one matcher before the per-image IoU matrix was introduced, so
|
||||
they broke ties identically; they must still agree afterwards. This is the scenario that
|
||||
makes a divergence visible: 40 same-class detections, the two order-sensitive ones tied at
|
||||
0.5 and the padding scoring above them, which places the tie group where ``torch.argsort``'s
|
||||
default unstable sort reverses it and ``np.argsort(kind="stable")`` does not. Ranked in
|
||||
input order the narrow detection claims GT-A and the wide one falls back to GT-B; reversed,
|
||||
the wide detection takes GT-A and the narrow one is left with a GT it cannot reach.
|
||||
"""
|
||||
boxes = [_NARROW_PRED_BOX, _WIDE_PRED_BOX, *([_FILLER_PRED_BOX] * _NUM_FILLER_PREDS)]
|
||||
scores = [0.5, 0.5, *([0.9] * _NUM_FILLER_PREDS)]
|
||||
pred = self._make_pred(
|
||||
boxes,
|
||||
scores,
|
||||
[0] * len(scores),
|
||||
masks=_masks_from_boxes(boxes, *_ORDER_SENSITIVE_MASK_SIZE),
|
||||
)
|
||||
target = self._make_target(
|
||||
_ORDER_SENSITIVE_GT_BOXES,
|
||||
[0, 0],
|
||||
masks=_masks_from_boxes(_ORDER_SENSITIVE_GT_BOXES, *_ORDER_SENSITIVE_MASK_SIZE),
|
||||
)
|
||||
|
||||
result = build_matching_data([pred], [target], iou_type=iou_type)
|
||||
|
||||
# Output is in descending-score order, so the higher-scoring padding comes first.
|
||||
np.testing.assert_array_equal(result[0]["matches"], [0] * _NUM_FILLER_PREDS + [1, 1])
|
||||
assert result[0]["total_gt"] == 2
|
||||
|
||||
def test_near_tied_float64_scores_are_ranked_at_full_precision(self) -> None:
|
||||
"""Float64 scores that collapse onto one float32 value still rank by their full precision.
|
||||
|
||||
``0.50000001`` and ``0.50000002`` are distinct in float64 and the same number in float32, so
|
||||
casting to float32 before the ranking hands greedy priority to whichever detection came
|
||||
first in the input instead of to the higher-scoring one. Here the wide detection is first in
|
||||
the input but scores lower: ranked at full precision the narrow detection claims GT-A and
|
||||
the wide one falls back to GT-B, while a collapsed ranking gives GT-A to the wide detection
|
||||
and leaves the narrow one nothing to match. The returned scores stay float32 either way —
|
||||
only the ordering is allowed to see the caller's dtype.
|
||||
"""
|
||||
boxes = [_WIDE_PRED_BOX, _NARROW_PRED_BOX, *([_FILLER_PRED_BOX] * _NUM_FILLER_PREDS)]
|
||||
scores = [0.50000001, 0.50000002, *([0.5] * _NUM_FILLER_PREDS)]
|
||||
pred = self._make_pred(boxes, scores, [0] * len(scores), scores_dtype=torch.float64)
|
||||
target = self._make_target(_ORDER_SENSITIVE_GT_BOXES, [0, 0])
|
||||
|
||||
result = build_matching_data([pred], [target])
|
||||
|
||||
np.testing.assert_array_equal(result[0]["matches"], [1, 1, *([0] * _NUM_FILLER_PREDS)])
|
||||
assert result[0]["scores"].dtype == np.float32
|
||||
|
||||
def test_multi_image_batch_accumulates(self) -> None:
|
||||
"""Two-image batch must concatenate scores and sum total_gt."""
|
||||
pred1 = self._make_pred([[0, 0, 10, 10]], [0.9], [0])
|
||||
@@ -504,7 +768,7 @@ class TestBuildMatchingData:
|
||||
2. the bulk reads that remain (``tolist``) are exactly three per image — the pred labels, the
|
||||
GT labels and ``iscrowd`` — and do not grow with the class count.
|
||||
|
||||
Not covered: ``_match_single_class`` still moves its own scores and IoUs to host with
|
||||
Not covered: ``_match_single_class_segm`` still moves its own scores and IoUs to host with
|
||||
``.cpu()/.numpy()`` once per class that has detections. That is pre-existing and untouched
|
||||
here; this test fixes the cost of the classes that never reach the matcher.
|
||||
"""
|
||||
@@ -550,6 +814,228 @@ class TestBuildMatchingData:
|
||||
assert result[class_id]["total_gt"] == expected_total_gt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Randomized differential oracle for build_matching_data(iou_type="bbox")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _random_bbox_batch(
|
||||
rng: np.random.Generator,
|
||||
num_shared_preds: int,
|
||||
num_shared_gts: int,
|
||||
num_shared_classes: int,
|
||||
canvas: int,
|
||||
) -> tuple[dict[str, torch.Tensor], dict[str, torch.Tensor]]:
|
||||
"""Build one random single-image bbox pred/target pair for the differential oracle.
|
||||
|
||||
*num_shared_classes* random detections and GTs are drawn from a shared label pool, so a class
|
||||
can end up with predictions and GTs, only predictions, only GTs, or neither (if unlucky) — the
|
||||
common case ``_accumulate_bbox_class`` must handle via ``np.ix_`` slicing of the shared
|
||||
image-wide IoU matrix. On top of that pool, one extra prediction is always given a
|
||||
class id of its own (``num_shared_classes``) with no matching GT, and one extra GT is always
|
||||
given a class id of its own (``num_shared_classes + 1``) with no matching prediction — this
|
||||
guarantees the two empty-class edges (`gt_indices.size == 0`, and a GT-only class) are
|
||||
exercised on every trial rather than only probabilistically.
|
||||
|
||||
Args:
|
||||
rng: Seeded NumPy random generator, local to the caller (not the global RNG state).
|
||||
num_shared_preds: Number of predictions drawn from the shared class pool.
|
||||
num_shared_gts: Number of ground truths drawn from the shared class pool.
|
||||
num_shared_classes: Size of the shared class-id pool.
|
||||
canvas: Boxes are drawn with coordinates inside a ``canvas``-square area.
|
||||
|
||||
Returns:
|
||||
A ``(pred, target)`` pair shaped like one ``build_matching_data()`` batch element, with a
|
||||
random ``iscrowd`` flag on every GT.
|
||||
|
||||
Examples:
|
||||
>>> rng = np.random.default_rng(0)
|
||||
>>> pred, target = _random_bbox_batch(
|
||||
... rng, num_shared_preds=3, num_shared_gts=2, num_shared_classes=2, canvas=16
|
||||
... )
|
||||
>>> pred["boxes"].shape, pred["scores"].shape, pred["labels"].shape
|
||||
(torch.Size([4, 4]), torch.Size([4]), torch.Size([4]))
|
||||
>>> target["boxes"].shape, target["labels"].shape, target["iscrowd"].shape
|
||||
(torch.Size([3, 4]), torch.Size([3]), torch.Size([3]))
|
||||
"""
|
||||
|
||||
def _random_boxes(count: int) -> np.ndarray:
|
||||
top_left = rng.uniform(0, canvas / 2, size=(count, 2))
|
||||
extent = rng.uniform(1, canvas / 2, size=(count, 2))
|
||||
return np.concatenate([top_left, top_left + extent], axis=1)
|
||||
|
||||
pred_only_class = num_shared_classes
|
||||
gt_only_class = num_shared_classes + 1
|
||||
|
||||
pred_boxes = np.concatenate([_random_boxes(num_shared_preds), _random_boxes(1)], axis=0)
|
||||
pred_labels = np.append(rng.integers(0, num_shared_classes, size=num_shared_preds), pred_only_class)
|
||||
pred_scores = rng.uniform(0.01, 1.0, size=num_shared_preds + 1)
|
||||
|
||||
gt_boxes = np.concatenate([_random_boxes(num_shared_gts), _random_boxes(1)], axis=0)
|
||||
gt_labels = np.append(rng.integers(0, num_shared_classes, size=num_shared_gts), gt_only_class)
|
||||
gt_crowd = rng.integers(0, 2, size=num_shared_gts + 1)
|
||||
|
||||
pred = {
|
||||
"boxes": torch.tensor(pred_boxes, dtype=torch.float32),
|
||||
"scores": torch.tensor(pred_scores, dtype=torch.float32),
|
||||
"labels": torch.tensor(pred_labels, dtype=torch.int64),
|
||||
}
|
||||
target = {
|
||||
"boxes": torch.tensor(gt_boxes, dtype=torch.float32),
|
||||
"labels": torch.tensor(gt_labels, dtype=torch.int64),
|
||||
"iscrowd": torch.tensor(gt_crowd, dtype=torch.int64),
|
||||
}
|
||||
return pred, target
|
||||
|
||||
|
||||
def _reference_bbox_matching(
|
||||
pred: dict[str, torch.Tensor],
|
||||
target: dict[str, torch.Tensor],
|
||||
iou_threshold: float,
|
||||
) -> dict[int, tuple[np.ndarray, np.ndarray, int]]:
|
||||
"""Independently compute build_matching_data's bbox-path output, per class, as an oracle.
|
||||
|
||||
Mirrors the sharing strategy ``_accumulate_bbox_class`` uses (one image-wide ``box_iou``
|
||||
matrix, sliced per class) so that a per-class slicing bug — e.g. a wrong ``np.ix_`` index —
|
||||
disagrees with this independent per-class computation instead of silently agreeing with
|
||||
itself. The actual greedy matching for each class is delegated to
|
||||
``_reference_greedy_match``, which reimplements the matching contract from spec rather than
|
||||
reusing any of ``rfdetr.evaluation.matching``.
|
||||
|
||||
Args:
|
||||
pred: One image's predictions with ``boxes``, ``scores``, ``labels``.
|
||||
target: One image's ground truths with ``boxes``, ``labels``, and optional ``iscrowd``.
|
||||
iou_threshold: Minimum IoU to count as a positive match.
|
||||
|
||||
Returns:
|
||||
Dict mapping ``class_id`` to ``(matches, ignore, total_gt)``, with ``matches``/``ignore``
|
||||
in the same per-class, descending-score order ``build_matching_data()`` returns.
|
||||
|
||||
Examples:
|
||||
>>> pred = {
|
||||
... "boxes": torch.tensor([[0.0, 0.0, 10.0, 10.0]]),
|
||||
... "scores": torch.tensor([0.9]),
|
||||
... "labels": torch.tensor([0]),
|
||||
... }
|
||||
>>> target = {"boxes": torch.tensor([[0.0, 0.0, 10.0, 10.0]]), "labels": torch.tensor([0])}
|
||||
>>> reference = _reference_bbox_matching(pred, target, iou_threshold=0.5)
|
||||
>>> reference[0][0].tolist(), reference[0][2]
|
||||
([1], 1)
|
||||
"""
|
||||
pred_labels_np = pred["labels"].numpy()
|
||||
gt_labels_np = target["labels"].numpy()
|
||||
scores_np = pred["scores"].numpy()
|
||||
gt_crowd = target.get("iscrowd", torch.zeros(len(gt_labels_np), dtype=torch.int64)).bool()
|
||||
iou_matrix = box_iou(pred["boxes"], target["boxes"])
|
||||
|
||||
reference: dict[int, tuple[np.ndarray, np.ndarray, int]] = {}
|
||||
for class_id in sorted(set(pred_labels_np.tolist()) | set(gt_labels_np.tolist())):
|
||||
pred_idx = np.flatnonzero(pred_labels_np == class_id)
|
||||
gt_idx = np.flatnonzero(gt_labels_np == class_id)
|
||||
|
||||
if pred_idx.size == 0:
|
||||
reference[class_id] = (
|
||||
np.zeros(0, dtype=np.int64),
|
||||
np.zeros(0, dtype=np.bool_),
|
||||
int((~gt_crowd[gt_idx]).sum()),
|
||||
)
|
||||
continue
|
||||
if gt_idx.size == 0:
|
||||
reference[class_id] = (
|
||||
np.zeros(pred_idx.size, dtype=np.int64),
|
||||
np.zeros(pred_idx.size, dtype=np.bool_),
|
||||
0,
|
||||
)
|
||||
continue
|
||||
|
||||
order = np.argsort(-scores_np[pred_idx], kind="stable")
|
||||
sub_iou = iou_matrix[np.ix_(pred_idx[order], gt_idx)]
|
||||
matches, ignore, total_gt = _reference_greedy_match(
|
||||
list(range(len(order))), sub_iou, gt_crowd[gt_idx], iou_threshold
|
||||
)
|
||||
reference[class_id] = (matches, ignore, total_gt)
|
||||
return reference
|
||||
|
||||
|
||||
def _assert_matching_equals_reference(
|
||||
result: dict[int, dict[str, np.ndarray | int]],
|
||||
reference: dict[int, tuple[np.ndarray, np.ndarray, int]],
|
||||
) -> None:
|
||||
"""Assert build_matching_data's per-class output equals a (matches, ignore, total_gt) reference.
|
||||
|
||||
Both ``build_matching_data()`` and ``_reference_bbox_matching()`` key on ``class_id`` and keep
|
||||
detections in per-image descending-score order, so comparing arrays directly is valid without
|
||||
re-sorting either side.
|
||||
|
||||
Args:
|
||||
result: Output of ``build_matching_data()``.
|
||||
reference: ``class_id -> (matches, ignore, total_gt)`` from ``_reference_bbox_matching()``.
|
||||
|
||||
Examples:
|
||||
>>> result = {0: {"matches": np.array([1]), "ignore": np.array([False]), "total_gt": 1}}
|
||||
>>> reference = {0: (np.array([1]), np.array([False]), 1)}
|
||||
>>> _assert_matching_equals_reference(result, reference)
|
||||
"""
|
||||
assert set(result) == set(reference)
|
||||
for class_id, (ref_matches, ref_ignore, ref_total_gt) in reference.items():
|
||||
np.testing.assert_array_equal(result[class_id]["matches"], ref_matches)
|
||||
np.testing.assert_array_equal(result[class_id]["ignore"], ref_ignore)
|
||||
assert result[class_id]["total_gt"] == ref_total_gt
|
||||
|
||||
|
||||
class TestBuildMatchingDataBboxDifferential:
|
||||
"""Randomized differential-oracle coverage for build_matching_data(iou_type="bbox").
|
||||
|
||||
``test_greedy_loop_does_not_sync_a_tensor_per_detection`` (see ``TestMatchSingleClassSegm``) is
|
||||
this suite's only other randomized differential oracle, and it exercises the segm path only.
|
||||
The bbox path shares one image-wide ``box_iou`` matrix across classes and slices it per class
|
||||
via ``np.ix_`` (``_accumulate_bbox_class``); every other bbox-path test in
|
||||
``TestBuildMatchingData`` hand-writes 2-3 detections, none of them drives this class-slicing
|
||||
with more than one populated class at a time.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("seed", [pytest.param(seed, id=f"seed-{seed}") for seed in range(50)])
|
||||
def test_matches_independent_reference_across_random_batches(self, seed: int) -> None:
|
||||
"""build_matching_data(iou_type="bbox") agrees with an independent per-class oracle.
|
||||
|
||||
Each of the 50 independently-seeded trials builds one random single-image batch with several shared classes (so
|
||||
the ``np.ix_`` per-class slice of the one image-wide ``box_iou`` matrix is exercised for more than one class per
|
||||
trial), a random ``iscrowd`` flag on every GT, one prediction-only class, and one GT-only class (see
|
||||
``_random_bbox_batch``). Many small, cheap trials are used instead of one large trial: each trial covers only a
|
||||
handful of classes/detections, so the seed sweep covers far more of the class/crowd/empty combinations across 50
|
||||
trials than a single larger draw would, for a comparable total detection count.
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
pred, target = _random_bbox_batch(rng, num_shared_preds=8, num_shared_gts=8, num_shared_classes=4, canvas=32)
|
||||
|
||||
result = build_matching_data([pred], [target], iou_type="bbox")
|
||||
|
||||
reference = _reference_bbox_matching(pred, target, iou_threshold=0.5)
|
||||
_assert_matching_equals_reference(result, reference)
|
||||
|
||||
@pytest.mark.parametrize("crowd_flag_scale", [pytest.param(1, id="with-crowd"), pytest.param(0, id="crowd-free")])
|
||||
@pytest.mark.parametrize("seed", [pytest.param(seed, id=f"seed-{seed}") for seed in range(10)])
|
||||
def test_single_class_image_matches_independent_reference(self, seed: int, crowd_flag_scale: int) -> None:
|
||||
"""An image whose detections and GTs are all one class agrees with the same independent oracle.
|
||||
|
||||
``_random_bbox_batch`` always adds a prediction-only class and a GT-only class, so the trials above never
|
||||
produce a single-class image. Relabelling every detection and GT to one class does, and that image takes the
|
||||
path which skips per-class selection altogether — the one class owns every row and column of the image-wide
|
||||
``box_iou`` matrix, so there is nothing to select and only the row order changes. Scaling ``iscrowd`` to zero
|
||||
pairs that with the crowd-free matching loop, which together are what a single-class evaluation actually runs.
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
pred, target = _random_bbox_batch(rng, num_shared_preds=8, num_shared_gts=6, num_shared_classes=3, canvas=32)
|
||||
pred["labels"] = torch.zeros_like(pred["labels"])
|
||||
target["labels"] = torch.zeros_like(target["labels"])
|
||||
target["iscrowd"] = target["iscrowd"] * crowd_flag_scale
|
||||
|
||||
result = build_matching_data([pred], [target], iou_type="bbox")
|
||||
|
||||
reference = _reference_bbox_matching(pred, target, iou_threshold=0.5)
|
||||
_assert_matching_equals_reference(result, reference)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper shared by TestMergeMatchingData and TestDistributedMergeMatchingData
|
||||
# (used by multiple classes, so module-level rather than a staticmethod)
|
||||
|
||||
Reference in New Issue
Block a user