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
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:
@@ -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]
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user