chore: import upstream snapshot with attribution
CPU tests Workflow / Testing (ubuntu-latest, 3.12) (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.13) (push) Failing after 0s
Mypy Type Check / Type Check (push) Failing after 0s
Docs/Test WorkFlow / Test docs build (push) Failing after 1s
PR Conflict Labeler / labeling (push) Failing after 1s
Dependency resolution / Resolve [tflite] extra — Python 3.12 (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.10) (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.13) (push) Failing after 1s
CPU tests Workflow / build-pkg (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.10) (push) Failing after 0s
CPU tests Workflow / Testing (ubuntu-latest, 3.11) (push) Failing after 0s
Smoke Tests / try-all-models (macos-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (macos-latest, 3.13) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / testing-guardian (push) Has been cancelled
GPU tests Workflow / Testing (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:26:24 +08:00
commit 16031aae96
343 changed files with 88674 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
+501
View File
@@ -0,0 +1,501 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Regression tests for the local COCO evaluator wrapper."""
import json
from pathlib import Path
from unittest.mock import patch
import numpy as np
import pycocotools.coco as pycoco
import pytest
import torch
from faster_coco_eval import COCO
from rfdetr.evaluation import coco_eval as coco_eval_module
from rfdetr.evaluation.coco_eval import CocoEvaluator
def _write_person_keypoint_coco(path: Path, *, include_num_keypoints: bool = True, keypoint_count: int = 17) -> None:
"""Write a minimal COCO keypoint annotation file."""
if keypoint_count == 17:
keypoints = [
"nose",
"left_eye",
"right_eye",
"left_ear",
"right_ear",
"left_shoulder",
"right_shoulder",
"left_elbow",
"right_elbow",
"left_wrist",
"right_wrist",
"left_hip",
"right_hip",
"left_knee",
"right_knee",
"left_ankle",
"right_ankle",
]
else:
keypoints = [f"point_{idx}" for idx in range(keypoint_count)]
coords = []
for idx in range(len(keypoints)):
coords.extend([20.0 + idx, 30.0 + idx, 2.0])
annotation = {
"id": 1,
"image_id": 1,
"category_id": 1,
"bbox": [10.0, 20.0, 50.0, 60.0],
"area": 3000.0,
"iscrowd": 0,
"keypoints": coords,
}
if include_num_keypoints:
annotation["num_keypoints"] = len(keypoints)
payload = {
"images": [{"id": 1, "width": 100, "height": 100, "file_name": "image.jpg"}],
"annotations": [annotation],
"categories": [
{
"id": 1,
"name": "person",
"supercategory": "person",
"keypoints": keypoints,
"skeleton": [],
}
],
}
path.write_text(json.dumps(payload), encoding="utf-8")
def _write_mixed_keypoint_coco(path: Path) -> None:
"""Write a COCO keypoint file with two categories using different keypoint counts."""
categories = [
{
"id": 1,
"name": "dart",
"supercategory": "object",
"keypoints": [f"dart_{idx}" for idx in range(4)],
"skeleton": [],
},
{
"id": 2,
"name": "person",
"supercategory": "person",
"keypoints": [f"person_{idx}" for idx in range(21)],
"skeleton": [],
},
]
annotations = []
for annotation_id, (category_id, keypoint_count, x0, y0) in enumerate(
[(1, 4, 10.0, 20.0), (2, 21, 50.0, 60.0)],
start=1,
):
keypoints = []
for idx in range(keypoint_count):
keypoints.extend([x0 + idx, y0 + idx, 2.0])
annotations.append(
{
"id": annotation_id,
"image_id": 1,
"category_id": category_id,
"bbox": [x0, y0, 20.0, 20.0],
"area": 400.0,
"iscrowd": 0,
"keypoints": keypoints,
"num_keypoints": keypoint_count,
}
)
payload = {
"images": [{"id": 1, "width": 100, "height": 100, "file_name": "image.jpg"}],
"annotations": annotations,
"categories": categories,
}
path.write_text(json.dumps(payload), encoding="utf-8")
def test_coco_evaluator_keypoints_uses_faster_evaluate_without_deprecated_evaluate_img(tmp_path: Path) -> None:
"""Keypoint evaluation should not call faster-coco-eval's deprecated ``evaluateImg`` shim."""
annotation_path = tmp_path / "person_keypoints_val2017.json"
_write_person_keypoint_coco(annotation_path)
coco_gt = COCO(str(annotation_path))
coco_gt.label2cat = {0: 1}
evaluator = CocoEvaluator(coco_gt, ["keypoints"])
keypoints = np.asarray(coco_gt.anns[1]["keypoints"], dtype=np.float32).reshape(1, 17, 3)
evaluator.update(
{
1: {
"boxes": torch.tensor([[10.0, 20.0, 60.0, 80.0]], dtype=torch.float32),
"scores": torch.tensor([0.99], dtype=torch.float32),
"labels": torch.tensor([0], dtype=torch.int64),
"keypoints": torch.as_tensor(keypoints, dtype=torch.float32),
}
}
)
evaluator.synchronize_between_processes()
evaluator.accumulate()
stats = evaluator.coco_eval["keypoints"].stats
assert np.isfinite(stats[0])
def test_coco_evaluator_keypoints_log_summary_false_suppresses_summary_rows(tmp_path: Path) -> None:
"""Keypoint accumulation should compute stats without AP/AR logger spam when summaries are disabled."""
annotation_path = tmp_path / "person_keypoints_val2017.json"
_write_person_keypoint_coco(annotation_path)
coco_gt = COCO(str(annotation_path))
coco_gt.label2cat = {0: 1}
evaluator = CocoEvaluator(coco_gt, ["keypoints"], log_summary=False)
keypoints = np.asarray(coco_gt.anns[1]["keypoints"], dtype=np.float32).reshape(1, 17, 3)
evaluator.update(
{
1: {
"boxes": torch.tensor([[10.0, 20.0, 60.0, 80.0]], dtype=torch.float32),
"scores": torch.tensor([0.99], dtype=torch.float32),
"labels": torch.tensor([0], dtype=torch.int64),
"keypoints": torch.as_tensor(keypoints, dtype=torch.float32),
}
}
)
with patch.object(coco_eval_module.logger, "info") as info:
evaluator.synchronize_between_processes()
evaluator.accumulate()
info.assert_not_called()
stats = evaluator.coco_eval["keypoints"].stats
assert np.isfinite(stats[0])
def test_coco_evaluator_keypoints_accepts_pycocotools_coco_api(tmp_path: Path) -> None:
"""Keypoint evaluation should accept COCO APIs returned by torchvision datasets."""
annotation_path = tmp_path / "person_keypoints_val2017.json"
_write_person_keypoint_coco(annotation_path)
coco_gt = pycoco.COCO(str(annotation_path))
coco_gt.label2cat = {0: 1}
evaluator = CocoEvaluator(coco_gt, ["keypoints"])
keypoints = np.asarray(coco_gt.anns[1]["keypoints"], dtype=np.float32).reshape(1, 17, 3)
evaluator.update(
{
1: {
"boxes": torch.tensor([[10.0, 20.0, 60.0, 80.0]], dtype=torch.float32),
"scores": torch.tensor([0.99], dtype=torch.float32),
"labels": torch.tensor([0], dtype=torch.int64),
"keypoints": torch.as_tensor(keypoints, dtype=torch.float32),
}
}
)
evaluator.synchronize_between_processes()
evaluator.accumulate()
stats = evaluator.coco_eval["keypoints"].stats
assert np.isfinite(stats[0])
def test_coco_evaluator_keypoints_infers_custom_oks_sigmas(tmp_path: Path) -> None:
"""Custom keypoint-count datasets should not use COCO's fixed 17-keypoint OKS sigmas."""
annotation_path = tmp_path / "custom_keypoints_val.json"
_write_person_keypoint_coco(annotation_path, keypoint_count=25)
coco_gt = COCO(str(annotation_path))
coco_gt.label2cat = {0: 1}
evaluator = CocoEvaluator(coco_gt, ["keypoints"])
keypoints = np.asarray(coco_gt.anns[1]["keypoints"], dtype=np.float32).reshape(1, 25, 3)
evaluator.update(
{
1: {
"boxes": torch.tensor([[10.0, 20.0, 60.0, 80.0]], dtype=torch.float32),
"scores": torch.tensor([0.99], dtype=torch.float32),
"labels": torch.tensor([0], dtype=torch.int64),
"keypoints": torch.as_tensor(keypoints, dtype=torch.float32),
}
}
)
evaluator.synchronize_between_processes()
evaluator.accumulate()
stats = evaluator.coco_eval["keypoints"].stats
assert np.isfinite(stats[0])
def test_coco_evaluator_warns_once_per_custom_keypoint_count(tmp_path: Path) -> None:
"""Repeated evaluator construction should not spam the same custom OKS fallback warning."""
annotation_path = tmp_path / "custom_keypoints_val.json"
_write_person_keypoint_coco(annotation_path, keypoint_count=25)
coco_gt = COCO(str(annotation_path))
coco_eval_module._WARNED_CUSTOM_KEYPOINT_OKS_COUNTS.clear()
try:
with patch.object(coco_eval_module.logger, "warning") as warning:
CocoEvaluator(coco_gt, ["keypoints"])
CocoEvaluator(coco_gt, ["keypoints"])
finally:
coco_eval_module._WARNED_CUSTOM_KEYPOINT_OKS_COUNTS.clear()
warning.assert_called_once()
def test_coco_evaluator_rejects_mismatched_custom_oks_sigmas(tmp_path: Path) -> None:
"""Explicit OKS sigmas must match the dataset keypoint count."""
annotation_path = tmp_path / "custom_keypoints_val.json"
_write_person_keypoint_coco(annotation_path, keypoint_count=25)
coco_gt = COCO(str(annotation_path))
with pytest.raises(ValueError, match="keypoint_oks_sigmas length 17 does not match dataset keypoint count 25"):
CocoEvaluator(coco_gt, ["keypoints"], keypoint_oks_sigmas=[0.05] * 17)
def test_coco_evaluator_keypoints_handles_mixed_counts_and_multi_instance_image(tmp_path: Path) -> None:
"""Mixed keypoint-count categories should evaluate by group instead of being skipped."""
annotation_path = tmp_path / "mixed_keypoints_val.json"
_write_mixed_keypoint_coco(annotation_path)
coco_gt = COCO(str(annotation_path))
coco_gt.label2cat = {0: 1, 1: 2}
evaluator = CocoEvaluator(coco_gt, ["keypoints"], keypoint_oks_sigmas=[0.05] * 21)
padded_keypoints = np.zeros((2, 21, 3), dtype=np.float32)
for detection_idx, annotation in enumerate(coco_gt.dataset["annotations"]):
keypoints = np.asarray(annotation["keypoints"], dtype=np.float32).reshape(-1, 3)
padded_keypoints[detection_idx, : keypoints.shape[0]] = keypoints
evaluator.update(
{
1: {
"boxes": torch.tensor([[10.0, 20.0, 30.0, 40.0], [50.0, 60.0, 70.0, 80.0]], dtype=torch.float32),
"scores": torch.tensor([0.99, 0.98], dtype=torch.float32),
"labels": torch.tensor([0, 1], dtype=torch.int64),
"keypoints": torch.as_tensor(padded_keypoints, dtype=torch.float32),
}
}
)
results = evaluator.coco_results["keypoints"]
assert len(results) == 2
assert len(results[0]["keypoints"]) == 4 * 3
assert len(results[1]["keypoints"]) == 21 * 3
evaluator.synchronize_between_processes()
evaluator.accumulate()
grouped_eval = evaluator.coco_eval["keypoints"]
assert len(grouped_eval.evals) == 2
stats = grouped_eval.stats
assert stats.shape == (10,)
assert np.isfinite(stats[0])
def test_coco_evaluator_backfills_missing_num_keypoints(tmp_path: Path) -> None:
"""Keypoint GT without `num_keypoints` should not be ignored during OKS evaluation."""
annotation_path = tmp_path / "person_keypoints_val2017.json"
_write_person_keypoint_coco(annotation_path, include_num_keypoints=False)
coco_gt = COCO(str(annotation_path))
assert "num_keypoints" not in coco_gt.anns[1]
evaluator = CocoEvaluator(coco_gt, ["keypoints"])
assert evaluator.coco_gt.anns[1]["num_keypoints"] == 17
def test_coco_evaluator_handles_empty_keypoint_predictions(tmp_path: Path) -> None:
"""Keypoint evaluation should handle images with no detections."""
annotation_path = tmp_path / "person_keypoints_val2017.json"
_write_person_keypoint_coco(annotation_path)
coco_gt = COCO(str(annotation_path))
evaluator = CocoEvaluator(coco_gt, ["keypoints"])
evaluator.update(
{
1: {
"boxes": torch.zeros((0, 4), dtype=torch.float32),
"scores": torch.zeros((0,), dtype=torch.float32),
"labels": torch.zeros((0,), dtype=torch.int64),
"keypoints": torch.zeros((0, 17, 3), dtype=torch.float32),
}
}
)
evaluator.synchronize_between_processes()
evaluator.accumulate()
stats = evaluator.coco_eval["keypoints"].stats
assert stats.shape == (10,)
class TestSynchronizeBetweenProcesses:
"""synchronize_between_processes() deduplicates DT when DDP padding repeats image_ids."""
def _make_evaluator(self, tmp_path: Path) -> CocoEvaluator:
"""Return a single-annotation evaluator with label2cat identity mapping."""
annotation_path = tmp_path / "kp.json"
_write_person_keypoint_coco(annotation_path)
coco_gt = COCO(str(annotation_path))
coco_gt.label2cat = {0: 1}
return CocoEvaluator(coco_gt, ["keypoints"])
def _pred(self, image_id: int, score: float = 0.99) -> dict:
"""Single detection prediction dict for image_id."""
kp = np.zeros((1, 17, 3), dtype=np.float32)
return {
image_id: {
"boxes": torch.tensor([[10.0, 20.0, 60.0, 80.0]]),
"scores": torch.tensor([score]),
"labels": torch.tensor([0], dtype=torch.long),
"keypoints": torch.as_tensor(kp),
}
}
def test_single_gpu_no_dedup_needed(self, tmp_path: Path) -> None:
"""Single-GPU path (world_size=1): all_gather returns one-element list; all results preserved."""
ev = self._make_evaluator(tmp_path)
ev.update(self._pred(1))
with patch("rfdetr.evaluation.coco_eval.all_gather", side_effect=lambda x: [x]):
ev.synchronize_between_processes()
assert ev.img_ids == [1]
assert len(ev.coco_results["keypoints"]) == 1
def test_no_overlap_across_ranks_all_results_kept(self, tmp_path: Path) -> None:
"""When image_ids are disjoint across ranks, all predictions are preserved."""
ev = self._make_evaluator(tmp_path)
# Simulate rank 0 has already called update() with image_id=1
ev.img_ids = [1]
ev.coco_results["keypoints"] = [{"image_id": 1, "category_id": 1, "keypoints": [], "score": 0.9}]
# all_gather returns rank-0 list + rank-1 list (no overlap)
rank1_ids = [2]
rank1_results = [{"image_id": 2, "category_id": 1, "keypoints": [], "score": 0.8}]
call_count = [0]
def _all_gather(x: list) -> list:
call_count[0] += 1
if call_count[0] == 1:
return [x, rank1_ids]
return [x, rank1_results]
with patch("rfdetr.evaluation.coco_eval.all_gather", side_effect=_all_gather):
ev.synchronize_between_processes()
assert sorted(ev.img_ids) == [1, 2]
image_ids_in_results = [r["image_id"] for r in ev.coco_results["keypoints"]]
assert sorted(image_ids_in_results) == [1, 2]
def test_ddp_padding_duplicate_image_id_deduped(self, tmp_path: Path) -> None:
"""DDP DistributedSampler padding: same image_id on two ranks → only rank-0 results kept."""
ev = self._make_evaluator(tmp_path)
# image_id=1 on BOTH ranks (padding), image_id=2 only on rank-1
rank0_ids = [1]
rank1_ids = [1, 2]
rank0_results = [{"image_id": 1, "category_id": 1, "keypoints": [0.9], "score": 0.9}]
rank1_results = [
{"image_id": 1, "category_id": 1, "keypoints": [0.9], "score": 0.9}, # duplicate
{"image_id": 2, "category_id": 1, "keypoints": [0.5], "score": 0.8},
]
call_count = [0]
def _all_gather(x: list) -> list:
call_count[0] += 1
if call_count[0] == 1:
return [rank0_ids, rank1_ids]
return [rank0_results, rank1_results]
with patch("rfdetr.evaluation.coco_eval.all_gather", side_effect=_all_gather):
ev.synchronize_between_processes()
assert sorted(ev.img_ids) == [1, 2]
image_ids_in_results = [r["image_id"] for r in ev.coco_results["keypoints"]]
# image_id=1 from rank-0 only (not duplicated), image_id=2 from rank-1
assert image_ids_in_results.count(1) == 1, "image_id=1 must appear exactly once (no DDP duplicate)"
assert image_ids_in_results.count(2) == 1
def test_ddp_padding_rank0_predictions_chosen_over_rank1(self, tmp_path: Path) -> None:
"""When image_id appears on rank-0 and rank-1, rank-0's prediction is kept (first-wins)."""
ev = self._make_evaluator(tmp_path)
rank0_ids = [1]
rank1_ids = [1]
rank0_results = [{"image_id": 1, "category_id": 1, "keypoints": [], "score": 0.9}]
rank1_results = [{"image_id": 1, "category_id": 1, "keypoints": [], "score": 0.5}]
call_count = [0]
def _all_gather(x: list) -> list:
call_count[0] += 1
if call_count[0] == 1:
return [rank0_ids, rank1_ids]
return [rank0_results, rank1_results]
with patch("rfdetr.evaluation.coco_eval.all_gather", side_effect=_all_gather):
ev.synchronize_between_processes()
assert len(ev.coco_results["keypoints"]) == 1
assert ev.coco_results["keypoints"][0]["score"] == pytest.approx(0.9), "rank-0 prediction must win"
def test_multiple_detections_same_image_all_kept(self, tmp_path: Path) -> None:
"""Multiple DT per image (multi-instance) on the owning rank are all preserved."""
ev = self._make_evaluator(tmp_path)
# rank-0 has 3 detections for image_id=1 (3 distinct instances)
rank0_ids = [1]
rank0_results = [
{"image_id": 1, "category_id": 1, "keypoints": [], "score": 0.9},
{"image_id": 1, "category_id": 1, "keypoints": [], "score": 0.8},
{"image_id": 1, "category_id": 1, "keypoints": [], "score": 0.7},
]
call_count = [0]
def _all_gather(x: list) -> list:
call_count[0] += 1
if call_count[0] == 1:
return [rank0_ids]
return [rank0_results]
with patch("rfdetr.evaluation.coco_eval.all_gather", side_effect=_all_gather):
ev.synchronize_between_processes()
assert len(ev.coco_results["keypoints"]) == 3, "all 3 per-image detections must be kept"
def test_coco_evaluator_skips_unmapped_labels_when_label2cat_is_present(tmp_path: Path) -> None:
"""A non-identity label2cat map should not fall back to raw category IDs for unmapped labels."""
annotation_path = tmp_path / "person_keypoints_val2017.json"
_write_person_keypoint_coco(annotation_path)
coco_gt = COCO(str(annotation_path))
coco_gt.label2cat = {1: 1}
evaluator = CocoEvaluator(coco_gt, ["keypoints"])
keypoints = np.asarray(coco_gt.anns[1]["keypoints"], dtype=np.float32).reshape(1, 17, 3)
evaluator.update(
{
1: {
"boxes": torch.tensor(
[[10.0, 20.0, 60.0, 80.0], [10.0, 20.0, 60.0, 80.0]],
dtype=torch.float32,
),
"scores": torch.tensor([0.99, 0.98], dtype=torch.float32),
"labels": torch.tensor([0, 1], dtype=torch.int64),
"keypoints": torch.as_tensor(np.concatenate([keypoints, keypoints], axis=0), dtype=torch.float32),
}
}
)
results = evaluator.coco_results["keypoints"]
assert len(results) == 1
assert results[0]["category_id"] == 1
def test_patched_pycocotools_summarize_raises_on_unknown_iou_type() -> None:
"""patched_pycocotools_summarize raises ValueError for an unrecognised iouType."""
from unittest.mock import MagicMock
mock_eval = MagicMock()
mock_eval.eval = {"precision": np.zeros((1, 1, 1, 1, 1)), "recall": np.zeros((1, 1, 1, 1))}
mock_eval.params.iouType = "custom"
with pytest.raises(ValueError, match="Unknown iou type custom"):
coco_eval_module.patched_pycocotools_summarize(mock_eval)
+471
View File
@@ -0,0 +1,471 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
from unittest.mock import patch
import numpy as np
import pytest
import torch
from rfdetr.evaluation.matching import (
_compute_mask_iou,
_match_single_class,
build_matching_data,
distributed_merge_matching_data,
init_matching_accumulator,
merge_matching_data,
)
# ---------------------------------------------------------------------------
# _compute_mask_iou
# ---------------------------------------------------------------------------
class TestComputeMaskIou:
"""Unit tests for the private _compute_mask_iou helper."""
@staticmethod
def _bool_mask(h: int, w: int, rows: slice, cols: slice) -> torch.Tensor:
"""Return a [1, h, w] boolean mask with the specified region set to True."""
m = torch.zeros(h, w, dtype=torch.bool)
m[rows, cols] = True
return m.unsqueeze(0)
def test_identical_masks_give_iou_one(self) -> None:
"""Masks that are identical should produce IoU of exactly 1.0."""
mask = self._bool_mask(4, 4, slice(0, 2), slice(0, 2)) # [1, 4, 4]
result = _compute_mask_iou(mask, mask)
assert result.shape == (1, 1)
assert float(result[0, 0]) == pytest.approx(1.0)
def test_disjoint_masks_give_iou_zero(self) -> None:
"""Non-overlapping masks should produce IoU of 0.0."""
pred = self._bool_mask(4, 4, slice(0, 2), slice(0, 2))
gt = self._bool_mask(4, 4, slice(2, 4), slice(2, 4))
result = _compute_mask_iou(pred, gt)
assert float(result[0, 0]) == pytest.approx(0.0)
def test_known_partial_overlap(self) -> None:
"""50% row overlap on a 4x4 grid: inter=4, union=12, IoU=1/3."""
pred = torch.zeros(1, 4, 4, dtype=torch.bool)
pred[0, :2, :] = True # rows 0-1: 8 px
gt = torch.zeros(1, 4, 4, dtype=torch.bool)
gt[0, 1:3, :] = True # rows 1-2: 8 px — 4 px of overlap at row 1
result = _compute_mask_iou(pred, gt)
assert float(result[0, 0]) == pytest.approx(4.0 / 12.0)
def test_empty_masks_return_zero_without_error(self) -> None:
"""All-zero masks must yield IoU 0.0 (no divide-by-zero)."""
pred = torch.zeros(1, 4, 4, dtype=torch.bool)
gt = torch.zeros(1, 4, 4, dtype=torch.bool)
result = _compute_mask_iou(pred, gt)
assert float(result[0, 0]) == pytest.approx(0.0)
def test_output_shape_is_n_by_m(self) -> None:
"""Output shape must be [N, M] for N predictions and M ground truths."""
pred = torch.zeros(3, 4, 4, dtype=torch.bool)
gt = torch.zeros(5, 4, 4, dtype=torch.bool)
result = _compute_mask_iou(pred, gt)
assert result.shape == (3, 5)
# ---------------------------------------------------------------------------
# _match_single_class
# ---------------------------------------------------------------------------
class TestMatchSingleClass:
"""Unit tests for the private _match_single_class helper."""
@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 _run(
self,
pred_scores: torch.Tensor,
pred_items: torch.Tensor,
gt_items: 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]:
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)
def test_perfect_overlap_is_tp(self) -> None:
"""A prediction that perfectly overlaps the GT box 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)
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."""
scores = torch.tensor([0.9])
pred = self._box(0, 0, 10, 10)
gt = self._box(50, 50, 60, 60)
_, matches, ignore, total_gt = self._run(scores, pred, gt)
assert matches[0] == 0
assert not ignore[0]
assert total_gt == 1
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
_, matches, _, _ = self._run(scores, pred, gt, iou_threshold=0.5)
assert matches[0] == 0
def test_greedy_matching_higher_score_wins(self) -> None:
"""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)
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
assert matches[1] == 0 # lower score -> FP
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)
gt_crowd = torch.tensor([True])
_, matches, ignore, total_gt = self._run(scores, box, box, 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
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)
gt_crowd = torch.tensor([False])
_, _, _, total_gt = self._run(scores, box, box, 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])
gt_crowd = torch.tensor([False, True]) # second GT is crowd
_, _, _, total_gt = self._run(scores, pred, gt_boxes, 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)
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
# ---------------------------------------------------------------------------
# build_matching_data
# ---------------------------------------------------------------------------
class TestBuildMatchingData:
"""Unit tests for build_matching_data()."""
@staticmethod
def _make_pred(
boxes: list,
scores: list,
labels: list,
masks: torch.Tensor | None = None,
) -> 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),
"labels": torch.tensor(labels, dtype=torch.int64),
}
if masks is not None:
d["masks"] = masks
return d
@staticmethod
def _make_target(
boxes: list,
labels: list,
iscrowd: list | None = None,
masks: torch.Tensor | None = None,
) -> dict[str, torch.Tensor]:
d: dict[str, torch.Tensor] = {
"boxes": torch.tensor(boxes, dtype=torch.float32).reshape(-1, 4),
"labels": torch.tensor(labels, dtype=torch.int64),
}
if iscrowd is not None:
d["iscrowd"] = torch.tensor(iscrowd, dtype=torch.int64)
if masks is not None:
d["masks"] = masks
return d
def test_output_has_required_keys(self) -> None:
"""Every class entry must contain scores, matches, ignore, total_gt."""
pred = self._make_pred([[0, 0, 10, 10]], [0.9], [0])
target = self._make_target([[0, 0, 10, 10]], [0])
result = build_matching_data([pred], [target])
assert 0 in result
assert set(result[0].keys()) == {"scores", "matches", "ignore", "total_gt"}
def test_perfect_detection_is_tp(self) -> None:
"""A pred box identical to the GT box must be a TP."""
pred = self._make_pred([[0, 0, 10, 10]], [0.9], [0])
target = self._make_target([[0, 0, 10, 10]], [0])
result = build_matching_data([pred], [target])
assert result[0]["matches"][0] == 1
assert result[0]["total_gt"] == 1
def test_disjoint_box_is_fp(self) -> None:
"""A pred box with no overlap against any GT must be a FP."""
pred = self._make_pred([[0, 0, 10, 10]], [0.9], [0])
target = self._make_target([[50, 50, 60, 60]], [0])
result = build_matching_data([pred], [target])
assert result[0]["matches"][0] == 0
assert result[0]["total_gt"] == 1
def test_no_predictions_records_total_gt_only(self) -> None:
"""With no preds for a class, total_gt is recorded but scores list is empty."""
pred = self._make_pred([], [], [])
target = self._make_target([[0, 0, 10, 10]], [0])
result = build_matching_data([pred], [target])
assert result[0]["total_gt"] == 1
assert len(result[0]["scores"]) == 0
def test_no_gts_all_predictions_are_fp(self) -> None:
"""With no GTs for a class, all predictions are FP and total_gt is 0."""
pred = self._make_pred([[0, 0, 10, 10]], [0.9], [0])
target = self._make_target([], [])
result = build_matching_data([pred], [target])
assert result[0]["matches"][0] == 0
assert result[0]["total_gt"] == 0
def test_multi_class_results_are_separated(self) -> None:
"""Two classes in the same image must be tracked independently."""
pred = self._make_pred([[0, 0, 10, 10], [20, 20, 30, 30]], [0.9, 0.8], [0, 1])
target = self._make_target([[0, 0, 10, 10], [20, 20, 30, 30]], [0, 1])
result = build_matching_data([pred], [target])
assert result[0]["matches"][0] == 1
assert result[1]["matches"][0] == 1
assert result[0]["total_gt"] == 1
assert result[1]["total_gt"] == 1
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])
target1 = self._make_target([[0, 0, 10, 10]], [0])
pred2 = self._make_pred([[50, 50, 60, 60]], [0.8], [0])
target2 = self._make_target([[50, 50, 60, 60]], [0])
result = build_matching_data([pred1, pred2], [target1, target2])
assert len(result[0]["scores"]) == 2
assert result[0]["total_gt"] == 2
def test_crowd_gt_excluded_from_total_and_detection_ignored(self) -> None:
"""A pred matched to a crowd GT must be ignored; crowd GT not counted."""
pred = self._make_pred([[0, 0, 10, 10]], [0.9], [0])
target = self._make_target([[0, 0, 10, 10]], [0], iscrowd=[1])
result = build_matching_data([pred], [target])
assert result[0]["total_gt"] == 0
assert result[0]["ignore"][0]
assert result[0]["matches"][0] == 0
def test_mixed_crowd_non_crowd_gts(self) -> None:
"""Pred matched to non-crowd GT is TP; crowd GT not counted in total_gt."""
pred = self._make_pred([[0, 0, 10, 10]], [0.9], [0])
target = self._make_target([[0, 0, 10, 10], [20, 20, 30, 30]], [0, 0], iscrowd=[0, 1])
result = build_matching_data([pred], [target])
assert result[0]["total_gt"] == 1
assert result[0]["matches"][0] == 1
assert not result[0]["ignore"][0]
def test_segmentation_iou_type_identical_masks(self) -> None:
"""iou_type='segm' path with identical masks must yield a TP."""
mask = torch.ones(1, 8, 8, dtype=torch.bool)
pred = {
"boxes": torch.zeros(1, 4),
"scores": torch.tensor([0.9]),
"labels": torch.tensor([0]),
"masks": mask,
}
target = {
"boxes": torch.zeros(1, 4),
"labels": torch.tensor([0]),
"masks": mask,
}
result = build_matching_data([pred], [target], iou_type="segm")
assert result[0]["matches"][0] == 1
assert result[0]["total_gt"] == 1
def test_segmentation_missing_masks_raises_value_error(self) -> None:
"""iou_type='segm' without masks must raise ValueError."""
pred = self._make_pred([[0, 0, 10, 10]], [0.9], [0])
target = self._make_target([[0, 0, 10, 10]], [0])
with pytest.raises(ValueError, match="masks"):
build_matching_data([pred], [target], iou_type="segm")
def test_class_only_in_predictions_is_tracked_as_fp(self) -> None:
"""A class seen only in predictions (no GT) must appear in output as FP."""
pred = self._make_pred([[0, 0, 10, 10]], [0.9], [99])
target = self._make_target([[0, 0, 10, 10]], [0])
result = build_matching_data([pred], [target])
assert 99 in result
assert result[99]["total_gt"] == 0
assert result[99]["matches"][0] == 0
# ---------------------------------------------------------------------------
# Helper shared by TestMergeMatchingData and TestDistributedMergeMatchingData
# (used by multiple classes, so module-level rather than a staticmethod)
# ---------------------------------------------------------------------------
def _make_matching_entry(
scores: list,
matches: list,
ignore: list,
total_gt: int,
) -> dict:
"""Return a compact matching dict as produced by ``build_matching_data()``."""
return {
"scores": np.array(scores, dtype=np.float32),
"matches": np.array(matches, dtype=np.int64),
"ignore": np.array(ignore, dtype=bool),
"total_gt": total_gt,
}
class TestInitMatchingAccumulator:
"""init_matching_accumulator() returns a correct empty accumulator."""
def test_returns_empty_dict(self) -> None:
"""Returns an empty dict."""
assert init_matching_accumulator() == {}
def test_returned_dict_is_mutable_via_merge(self) -> None:
"""The returned dict can be populated by merge_matching_data."""
acc = init_matching_accumulator()
merge_matching_data(acc, {0: _make_matching_entry([0.9], [1], [False], 1)})
assert 0 in acc
class TestMergeMatchingData:
"""merge_matching_data() correctly accumulates per-class matching dicts."""
def test_empty_accumulator_copies_new_data(self) -> None:
"""First merge populates the accumulator with the batch data."""
data = _make_matching_entry([0.9, 0.8], [1, 0], [False, False], 1)
acc = merge_matching_data({}, {0: data})
np.testing.assert_allclose(acc[0]["scores"], [0.9, 0.8], rtol=1e-6)
np.testing.assert_array_equal(acc[0]["matches"], [1, 0])
assert acc[0]["total_gt"] == 1
def test_second_merge_concatenates_arrays_and_sums_total_gt(self) -> None:
"""Merging a second batch appends scores/matches/ignore and sums total_gt."""
acc: dict = {}
merge_matching_data(acc, {0: _make_matching_entry([0.9], [1], [False], 2)})
merge_matching_data(acc, {0: _make_matching_entry([0.7], [0], [False], 3)})
np.testing.assert_allclose(acc[0]["scores"], [0.9, 0.7], rtol=1e-6)
np.testing.assert_array_equal(acc[0]["matches"], [1, 0])
assert acc[0]["total_gt"] == 5
def test_new_class_added_independently(self) -> None:
"""A class not yet in the accumulator is added without touching others."""
acc = {0: _make_matching_entry([0.9], [1], [False], 1)}
merge_matching_data(acc, {1: _make_matching_entry([0.5], [0], [False], 2)})
assert acc[0]["total_gt"] == 1
assert acc[1]["total_gt"] == 2
def test_returns_same_accumulator_object(self) -> None:
"""merge_matching_data returns the same dict it was given (in-place)."""
acc: dict = {}
result = merge_matching_data(acc, {})
assert result is acc
def test_no_op_when_new_data_is_empty(self) -> None:
"""Merging an empty batch leaves the accumulator unchanged."""
acc = {0: _make_matching_entry([0.9], [1], [False], 1)}
merge_matching_data(acc, {})
assert len(acc) == 1
assert acc[0]["total_gt"] == 1
def test_copied_arrays_are_independent_of_source(self) -> None:
"""Mutating the source entry after the first merge must not corrupt acc."""
data = _make_matching_entry([0.9], [1], [False], 1)
acc: dict = {}
merge_matching_data(acc, {0: data})
data["scores"][0] = 0.0
assert acc[0]["scores"][0] == pytest.approx(0.9)
def test_multiple_classes_in_single_batch_all_added(self) -> None:
"""All classes present in a single batch are merged into the accumulator."""
batch = {
0: _make_matching_entry([0.9], [1], [False], 1),
1: _make_matching_entry([0.8], [0], [False], 2),
}
acc = merge_matching_data({}, batch)
assert set(acc.keys()) == {0, 1}
assert acc[0]["total_gt"] == 1
assert acc[1]["total_gt"] == 2
class TestDistributedMergeMatchingData:
"""distributed_merge_matching_data() gathers and merges across DDP ranks."""
def test_single_rank_returns_same_content(self) -> None:
"""In single-process mode (world_size=1), data passes through unchanged."""
local_data = {0: _make_matching_entry([0.9], [1], [False], 1)}
result = distributed_merge_matching_data(local_data)
np.testing.assert_allclose(result[0]["scores"], [0.9], rtol=1e-6)
assert result[0]["total_gt"] == 1
def test_two_ranks_disjoint_classes(self) -> None:
"""Two ranks with disjoint classes -> merged result contains both."""
rank0 = {0: _make_matching_entry([0.9], [1], [False], 1)}
rank1 = {1: _make_matching_entry([0.7], [0], [False], 2)}
with patch("rfdetr.evaluation.matching.all_gather", return_value=[rank0, rank1]):
result = distributed_merge_matching_data(rank0)
assert set(result.keys()) == {0, 1}
assert result[0]["total_gt"] == 1
assert result[1]["total_gt"] == 2
def test_two_ranks_overlapping_class_concatenates(self) -> None:
"""Two ranks sharing class 0 -> arrays concatenated, total_gt summed."""
rank0 = {0: _make_matching_entry([0.9], [1], [False], 2)}
rank1 = {0: _make_matching_entry([0.7, 0.5], [0, 1], [False, False], 3)}
with patch("rfdetr.evaluation.matching.all_gather", return_value=[rank0, rank1]):
result = distributed_merge_matching_data(rank0)
np.testing.assert_allclose(result[0]["scores"], [0.9, 0.7, 0.5], rtol=1e-6)
assert result[0]["total_gt"] == 5
def test_returns_new_dict_not_input(self) -> None:
"""Result is a new dict, not a reference to the local input."""
local_data = {0: _make_matching_entry([0.9], [1], [False], 1)}
result = distributed_merge_matching_data(local_data)
assert result is not local_data
+504
View File
@@ -0,0 +1,504 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Unit and integration tests for MetricKeypointOKS.
Integration tests (class TestOKSValues) build a minimal COCO ground-truth object and feed known predictions so that
expected mAP values can be derived by hand without running model inference. They are the first line of defence against
silent metric-computation regressions.
"""
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
import torch
from faster_coco_eval import COCO
from rfdetr.evaluation.keypoint_oks import MetricKeypointOKS
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _make_coco_gt() -> MagicMock:
"""Return a minimal COCO ground-truth mock (for unit tests that patch evaluator)."""
return MagicMock(name="coco_gt")
def _make_predictions(image_id: int = 1, num_dets: int = 1, num_keypoints: int = 3) -> dict:
"""Return a single-image prediction dict with zero-valued tensors."""
return {
image_id: {
"boxes": torch.zeros(num_dets, 4),
"scores": torch.ones(num_dets),
"labels": torch.zeros(num_dets, dtype=torch.long),
"keypoints": torch.zeros(num_dets, num_keypoints, 3),
}
}
def _make_evaluator_mock(stats: list[float]) -> MagicMock:
"""Return a CocoEvaluator mock that returns the given stats array.
Stats list must have exactly 10 elements matching _summarizeKps() output shape.
"""
assert len(stats) == 10, f"_make_evaluator_mock: expected 10 stats, got {len(stats)}"
evaluator = MagicMock(name="evaluator")
evaluator.coco_eval = {"keypoints": MagicMock(stats=np.array(stats, dtype=np.float32))}
return evaluator
def _build_coco_gt(
num_keypoints: int,
gt_keypoints: list[float],
area: float = 2500.0,
bbox: list[float] | None = None,
) -> COCO:
"""Build a minimal COCO GT object with a single annotation.
Args:
num_keypoints: Number of keypoints per instance.
gt_keypoints: Flat COCO keypoint list [x0,y0,v0, x1,y1,v1, ...].
Visibility values should be 2 (labelled+visible).
area: Ground-truth object area for OKS normalisation.
bbox: Ground-truth bounding box [x, y, w, h]. Defaults to [0,0,100,100].
Returns:
A fully-indexed ``faster_coco_eval.COCO`` object.
"""
if bbox is None:
bbox = [0.0, 0.0, 100.0, 100.0]
kp_names = [f"kp{i}" for i in range(num_keypoints)]
dataset = {
"images": [{"id": 1, "width": 100, "height": 100, "file_name": "img.jpg"}],
"categories": [{"id": 1, "name": "obj", "keypoints": kp_names, "skeleton": []}],
"annotations": [
{
"id": 1,
"image_id": 1,
"category_id": 1,
"keypoints": gt_keypoints,
"num_keypoints": num_keypoints,
"area": area,
"bbox": bbox,
"iscrowd": 0,
}
],
}
coco_gt = COCO()
coco_gt.dataset = dataset
coco_gt.createIndex()
return coco_gt
def _make_keypoint_prediction(
image_id: int,
kp_xy: list[tuple[float, float]],
category_id: int = 1,
score: float = 0.99,
box: list[float] | None = None,
) -> dict:
"""Build a per-image prediction dict for MetricKeypointOKS.update().
Args:
image_id: COCO image ID.
kp_xy: List of (x, y) coordinates — one per keypoint. Visibility is
set to 1.0 for all.
category_id: Category label (raw COCO ID used here, no remapping).
score: Detection confidence score.
box: Bounding box [x1, y1, x2, y2] in pixel coords. Defaults to the
full 100x100 image.
Returns:
Dict mapping ``image_id`` to a prediction dict accepted by
:meth:`~rfdetr.evaluation.keypoint_oks.MetricKeypointOKS.update`.
"""
if box is None:
box = [0.0, 0.0, 100.0, 100.0]
num_kp = len(kp_xy)
kp_tensor = torch.zeros(1, num_kp, 3)
for i, (x, y) in enumerate(kp_xy):
kp_tensor[0, i, 0] = x
kp_tensor[0, i, 1] = y
kp_tensor[0, i, 2] = 1.0
return {
image_id: {
"boxes": torch.tensor([box], dtype=torch.float32),
"scores": torch.tensor([score]),
"labels": torch.tensor([category_id], dtype=torch.long),
"keypoints": kp_tensor,
}
}
# ---------------------------------------------------------------------------
# Unit tests (evaluator is mocked)
# ---------------------------------------------------------------------------
class TestHasUpdates:
"""has_updates reflects whether any batch has been accumulated."""
def test_false_on_construction(self) -> None:
"""Fresh metric reports no updates."""
metric = MetricKeypointOKS(_make_coco_gt())
assert metric.has_updates is False
def test_true_after_update(self) -> None:
"""has_updates becomes True after any update() call."""
metric = MetricKeypointOKS(_make_coco_gt())
metric.update({1: {}})
assert metric.has_updates is True
def test_false_after_reset(self) -> None:
"""has_updates returns False after reset() clears all batches."""
metric = MetricKeypointOKS(_make_coco_gt())
metric.update({1: {}})
metric.reset()
assert metric.has_updates is False
class TestReset:
"""Reset() clears all accumulated batches."""
def test_clears_all_batches(self) -> None:
"""Reset() empties internal _batches list."""
metric = MetricKeypointOKS(_make_coco_gt())
metric.update(_make_predictions(image_id=1))
metric.update(_make_predictions(image_id=2))
metric.reset()
assert metric._batches == []
def test_idempotent_on_empty_state(self) -> None:
"""Reset() on empty metric does not raise."""
metric = MetricKeypointOKS(_make_coco_gt())
metric.reset()
assert metric.has_updates is False
class TestUpdate:
"""Update() appends batches without merging or overwriting."""
def test_each_call_appends_one_batch(self) -> None:
"""Two update() calls produce two entries in _batches."""
metric = MetricKeypointOKS(_make_coco_gt())
metric.update(_make_predictions(image_id=1))
metric.update(_make_predictions(image_id=2))
assert len(metric._batches) == 2
def test_same_image_id_in_two_batches_both_preserved(self) -> None:
"""Predictions for the same image_id in separate batches are NOT overwritten."""
metric = MetricKeypointOKS(_make_coco_gt())
metric.update({5: {"scores": torch.tensor([0.9])}})
metric.update({5: {"scores": torch.tensor([0.3])}})
# Both batches must be preserved — not merged/overwritten
assert len(metric._batches) == 2
assert float(metric._batches[0][5]["scores"][0]) == pytest.approx(0.9)
assert float(metric._batches[1][5]["scores"][0]) == pytest.approx(0.3)
def test_empty_prediction_dict_appended_as_batch(self) -> None:
"""Empty dict marks an image with no detections and is preserved as a batch."""
metric = MetricKeypointOKS(_make_coco_gt())
metric.update({42: {}})
assert len(metric._batches) == 1
assert metric._batches[0] == {42: {}}
class TestCompute:
"""Compute() delegates to CocoEvaluator and returns correct stat dict."""
def test_returns_correct_stat_keys(self) -> None:
"""Compute() returns dict with map, map_50, map_75, mar keys."""
evaluator = _make_evaluator_mock([0.5, 0.7, 0.4, -1.0, -1.0, 0.6, -1.0, -1.0, -1.0, -1.0])
metric = MetricKeypointOKS(_make_coco_gt())
with patch("rfdetr.evaluation.keypoint_oks.CocoEvaluator", return_value=evaluator):
result = metric.compute()
assert set(result.keys()) == {"map", "map_50", "map_75", "mar"}
def test_maps_stats_indices_to_dict_keys(self) -> None:
"""Compute() maps stats[0,1,2,5] to map, map_50, map_75, mar."""
evaluator = _make_evaluator_mock([0.42, 0.72, 0.31, -1.0, -1.0, 0.55, -1.0, -1.0, -1.0, -1.0])
metric = MetricKeypointOKS(_make_coco_gt())
with patch("rfdetr.evaluation.keypoint_oks.CocoEvaluator", return_value=evaluator):
result = metric.compute()
assert result["map"] == pytest.approx(0.42)
assert result["map_50"] == pytest.approx(0.72)
assert result["map_75"] == pytest.approx(0.31)
assert result["mar"] == pytest.approx(0.55)
def test_raises_on_wrong_stats_shape(self) -> None:
"""Compute() raises AssertionError when stats array is not shape (10,).
_summarizeKps() always returns (10,); a shape mismatch signals a pycocotools contract violation (e.g. wrong
faster_coco_eval version) and must not silently produce incorrect metric values via index-out-of-bounds sentinel
fallback.
"""
evaluator = MagicMock(name="evaluator")
evaluator.coco_eval = {"keypoints": MagicMock(stats=np.array([0.3], dtype=np.float32))}
metric = MetricKeypointOKS(_make_coco_gt())
with patch("rfdetr.evaluation.keypoint_oks.CocoEvaluator", return_value=evaluator):
with pytest.raises(AssertionError, match="Expected coco keypoint stats shape"):
metric.compute()
def test_calls_synchronize_and_accumulate(self) -> None:
"""Compute() calls synchronize_between_processes() and accumulate() on the evaluator."""
evaluator = _make_evaluator_mock([0.5, 0.7, 0.4, -1.0, -1.0, 0.6, -1.0, -1.0, -1.0, -1.0])
metric = MetricKeypointOKS(_make_coco_gt())
with patch("rfdetr.evaluation.keypoint_oks.CocoEvaluator", return_value=evaluator):
metric.compute()
evaluator.synchronize_between_processes.assert_called_once()
evaluator.accumulate.assert_called_once()
def test_constructs_evaluator_with_metric_params(self) -> None:
"""Compute() passes max_dets and keypoint_oks_sigmas to CocoEvaluator."""
coco_gt = _make_coco_gt()
evaluator = _make_evaluator_mock([0.5, 0.7, 0.4, -1.0, -1.0, 0.6, -1.0, -1.0, -1.0, -1.0])
metric = MetricKeypointOKS(coco_gt, keypoint_oks_sigmas=[0.05, 0.1], max_dets=100)
with patch("rfdetr.evaluation.keypoint_oks.CocoEvaluator", return_value=evaluator) as cls:
metric.compute()
cls.assert_called_once_with(
coco_gt,
["keypoints"],
max_dets=100,
keypoint_oks_sigmas=[0.05, 0.1],
log_summary=False,
)
def test_replays_each_batch_as_separate_evaluator_update(self) -> None:
"""Compute() calls evaluator.update() once per accumulated batch."""
evaluator = _make_evaluator_mock([0.5, 0.7, 0.4, -1.0, -1.0, 0.6, -1.0, -1.0, -1.0, -1.0])
metric = MetricKeypointOKS(_make_coco_gt())
metric.update(_make_predictions(image_id=1))
metric.update(_make_predictions(image_id=2))
with patch("rfdetr.evaluation.keypoint_oks.CocoEvaluator", return_value=evaluator):
metric.compute()
assert evaluator.update.call_count == 2
def test_forwards_correct_image_id_to_evaluator(self) -> None:
"""Compute() passes predictions with the correct image_id to the evaluator."""
evaluator = _make_evaluator_mock([0.5, 0.7, 0.4, -1.0, -1.0, 0.6, -1.0, -1.0, -1.0, -1.0])
metric = MetricKeypointOKS(_make_coco_gt())
metric.update(_make_predictions(image_id=7))
with patch("rfdetr.evaluation.keypoint_oks.CocoEvaluator", return_value=evaluator):
metric.compute()
passed_preds = evaluator.update.call_args.args[0]
assert 7 in passed_preds
def test_skips_evaluator_update_when_no_predictions(self) -> None:
"""Compute() does not call evaluator.update() when no batches accumulated."""
evaluator = _make_evaluator_mock([0.5, 0.7, 0.4, -1.0, -1.0, 0.6, -1.0, -1.0, -1.0, -1.0])
metric = MetricKeypointOKS(_make_coco_gt())
with patch("rfdetr.evaluation.keypoint_oks.CocoEvaluator", return_value=evaluator):
metric.compute()
evaluator.update.assert_not_called()
def test_compute_is_idempotent(self) -> None:
"""Two compute() calls with identical batches return the same stats.
Proves the shared coco_gt reference is not mutated between calls.
"""
evaluator = _make_evaluator_mock([0.42, 0.72, 0.31, -1.0, -1.0, 0.55, -1.0, -1.0, -1.0, -1.0])
metric = MetricKeypointOKS(_make_coco_gt())
metric.update(_make_predictions(image_id=1))
with patch("rfdetr.evaluation.keypoint_oks.CocoEvaluator", return_value=evaluator):
result_a = metric.compute()
result_b = metric.compute()
assert result_a == result_b
@pytest.mark.parametrize(
"sigmas",
[
pytest.param(None, id="no_sigmas"),
pytest.param([0.05] * 17, id="17kp_sigmas"),
pytest.param([0.05] * 4, id="4kp_sigmas"),
],
)
def test_compute_accepts_arbitrary_keypoint_counts(self, sigmas: list[float] | None) -> None:
"""Compute() passes any keypoint_oks_sigmas length to CocoEvaluator without restriction."""
evaluator = _make_evaluator_mock([0.5, 0.7, 0.4, -1.0, -1.0, 0.6, -1.0, -1.0, -1.0, -1.0])
metric = MetricKeypointOKS(_make_coco_gt(), keypoint_oks_sigmas=sigmas)
with patch("rfdetr.evaluation.keypoint_oks.CocoEvaluator", return_value=evaluator) as cls:
metric.compute()
assert cls.call_args.kwargs["keypoint_oks_sigmas"] == sigmas
# ---------------------------------------------------------------------------
# Integration tests — real COCO GT, visually validatable expected values
# ---------------------------------------------------------------------------
class TestOKSValues:
"""End-to-end OKS mAP computation against a real CocoEvaluator.
Each test uses a single annotation and a single prediction so that
expected mAP can be derived by hand:
OKS = exp(-d² / (8 * σ² * s²))
where d = Euclidean pixel distance, s = sqrt(GT area), σ = OKS sigma.
The 8× factor comes from pycocotools: vars = (2σ)², e = d² / (vars * s² * 2).
All tests use 1 keypoint, GT at (50, 50), area = 2500.0 (s = 50.0),
and sigma = 0.05 (default for custom keypoint counts via
_DEFAULT_CUSTOM_KEYPOINT_OKS_SIGMA).
"""
_GT_KP_X = 50.0
_GT_KP_Y = 50.0
_AREA = 2500.0 # s = 50.0
_SIGMA = 0.05 # default custom OKS sigma
def _make_gt(self) -> COCO:
"""Build a 1-image, 1-annotation, 1-keypoint COCO GT."""
return _build_coco_gt(
num_keypoints=1,
gt_keypoints=[self._GT_KP_X, self._GT_KP_Y, 2],
area=self._AREA,
)
def test_perfect_prediction_gives_map_one(self) -> None:
"""Prediction exactly at GT keypoint location must yield mAP@50 = 1.0.
d = 0 → OKS = exp(0) = 1.0 → all IoU thresholds pass → mAP = 1.0.
"""
coco_gt = self._make_gt()
metric = MetricKeypointOKS(coco_gt, keypoint_oks_sigmas=[self._SIGMA])
metric.update(_make_keypoint_prediction(1, [(self._GT_KP_X, self._GT_KP_Y)]))
result = metric.compute()
assert result["map_50"] == pytest.approx(1.0, abs=1e-3)
assert result["map"] == pytest.approx(1.0, abs=1e-3)
def test_no_predictions_gives_map_zero(self) -> None:
"""Empty prediction set must yield mAP = 0.0 (no true positives)."""
coco_gt = self._make_gt()
metric = MetricKeypointOKS(coco_gt, keypoint_oks_sigmas=[self._SIGMA])
metric.update({1: {}})
result = metric.compute()
assert result["map_50"] == pytest.approx(0.0, abs=1e-3)
assert result["map"] == pytest.approx(0.0, abs=1e-3)
def test_far_prediction_gives_map_near_zero(self) -> None:
"""Prediction far from GT must yield near-zero mAP.
d = sqrt(50² + 50²) ≈ 70.7, s = 50, σ = 0.05.
pycocotools formula: OKS = exp(-d² / (8 * σ² * s²))
= exp(-5000 / (8 * 0.0025 * 2500))
= exp(-5000 / 50)
= exp(-100) ≈ 0 → mAP@50 = 0.0.
"""
coco_gt = self._make_gt()
metric = MetricKeypointOKS(coco_gt, keypoint_oks_sigmas=[self._SIGMA])
metric.update(_make_keypoint_prediction(1, [(0.0, 0.0)]))
result = metric.compute()
assert result["map_50"] == pytest.approx(0.0, abs=1e-3)
def test_known_oks_threshold_boundary(self) -> None:
"""Prediction at OKS ≈ 0.6 passes @50 but fails @75.
pycocotools (and faster_coco_eval) compute OKS as::
vars = (sigma * 2)²
e = d² / (vars * s² * 2) = d² / (8 * sigma² * s²)
OKS = exp(-e)
Solving for d where OKS = 0.6 (clearly between the 0.5 and 0.75 thresholds)::
d² = -ln(0.6) * 8 * sigma² * s²
= 0.511 * 8 * 0.0025 * 2500 = 25.55
d = sqrt(25.55) ≈ 5.05 pixels.
Displacement along x-axis only: predict at (50 + 5.05, 50).
OKS ≈ 0.6 → passes @50 (threshold 0.5), fails @75 (threshold 0.75).
mAP@50 should be 1.0, mAP@75 should be 0.0.
"""
sigma = self._SIGMA
s = np.sqrt(self._AREA)
oks_target = 0.6 # between 0.50 and 0.75 — clear boundary
# pycocotools formula: OKS = exp(-d² / (8 * sigma² * s²))
d = np.sqrt(-np.log(oks_target) * 8 * sigma**2 * s**2)
pred_x = float(self._GT_KP_X + d)
coco_gt = self._make_gt()
metric = MetricKeypointOKS(coco_gt, keypoint_oks_sigmas=[sigma])
metric.update(_make_keypoint_prediction(1, [(pred_x, self._GT_KP_Y)]))
result = metric.compute()
assert result["map_50"] == pytest.approx(1.0, abs=1e-3), "OKS=0.6 should pass @50 threshold"
assert result["map_75"] == pytest.approx(0.0, abs=1e-3), "OKS=0.6 should fail @75 threshold"
def test_metric_stable_across_identical_epochs(self) -> None:
"""Identical predictions fed across three separate compute() cycles give identical mAP.
This proves no GT mutation, no accumulation bleed, and correct reset between epochs. A metric that peaks-then-
decreases under frozen predictions would fail here.
"""
coco_gt = self._make_gt()
metric = MetricKeypointOKS(coco_gt, keypoint_oks_sigmas=[self._SIGMA])
results = []
for _ in range(3):
metric.reset()
metric.update(_make_keypoint_prediction(1, [(self._GT_KP_X, self._GT_KP_Y)]))
results.append(metric.compute())
assert results[0]["map_50"] == pytest.approx(results[1]["map_50"], abs=1e-6)
assert results[1]["map_50"] == pytest.approx(results[2]["map_50"], abs=1e-6)
def test_multi_batch_same_result_as_single_batch(self) -> None:
"""Splitting predictions across two update() calls gives same mAP as one call.
Two images, each predicted correctly. Whether fed in one batch or two, mAP@50 must equal 1.0 — verifies that
per-batch append semantics are equivalent to batched evaluation.
"""
kp_names = ["kp0"]
dataset = {
"images": [
{"id": 1, "width": 100, "height": 100, "file_name": "a.jpg"},
{"id": 2, "width": 100, "height": 100, "file_name": "b.jpg"},
],
"categories": [{"id": 1, "name": "obj", "keypoints": kp_names, "skeleton": []}],
"annotations": [
{
"id": 1,
"image_id": 1,
"category_id": 1,
"keypoints": [50.0, 50.0, 2],
"num_keypoints": 1,
"area": 2500.0,
"bbox": [0.0, 0.0, 100.0, 100.0],
"iscrowd": 0,
},
{
"id": 2,
"image_id": 2,
"category_id": 1,
"keypoints": [25.0, 75.0, 2],
"num_keypoints": 1,
"area": 2500.0,
"bbox": [0.0, 0.0, 100.0, 100.0],
"iscrowd": 0,
},
],
}
coco_gt = COCO()
coco_gt.dataset = dataset
coco_gt.createIndex()
sigma = [self._SIGMA]
# Single batch
metric_single = MetricKeypointOKS(coco_gt, keypoint_oks_sigmas=sigma)
combined = {}
combined.update(_make_keypoint_prediction(1, [(50.0, 50.0)]))
combined.update(_make_keypoint_prediction(2, [(25.0, 75.0)]))
metric_single.update(combined)
result_single = metric_single.compute()
# Two batches (one image each)
metric_split = MetricKeypointOKS(coco_gt, keypoint_oks_sigmas=sigma)
metric_split.update(_make_keypoint_prediction(1, [(50.0, 50.0)]))
metric_split.update(_make_keypoint_prediction(2, [(25.0, 75.0)]))
result_split = metric_split.compute()
assert result_single["map_50"] == pytest.approx(result_split["map_50"], abs=1e-6)
assert result_single["map_50"] == pytest.approx(1.0, abs=1e-3)