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]
# ------------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
+928
View File
@@ -0,0 +1,928 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Regression tests for COCO dataset handling.
Tests cover:
- Sparse COCO category ID remapping in ``ConvertCoco``
- ``_load_classes`` hierarchy detection (GitHub #609)
"""
import json
import types
from pathlib import Path
from typing import Dict, List
import pytest
import torch
from PIL import Image
from rfdetr.datasets._keypoint_schema import infer_coco_keypoint_schema
from rfdetr.datasets.coco import CocoDetection, ConvertCoco, build_coco, build_roboflow_from_coco
from rfdetr.detr import RFDETR
# Minimal image shared across all tests
_IMAGE = Image.new("RGB", (100, 100))
# Sparse COCO-style category IDs (as in the real COCO dataset: 1-90 with gaps)
# e.g. COCO skips IDs 12, 26, 29, 30, 45, 66, 68, 69, 71, 83, 91
_SPARSE_CAT_IDS = [1, 2, 3, 7, 8] # sparse, non-zero-indexed
_ANNOTATIONS = [
{"bbox": [10, 10, 30, 30], "category_id": 1, "area": 900, "iscrowd": 0},
{"bbox": [50, 50, 20, 20], "category_id": 7, "area": 400, "iscrowd": 0},
]
_CAT2LABEL = {cat_id: i for i, cat_id in enumerate(sorted(_SPARSE_CAT_IDS))}
# {1: 0, 2: 1, 3: 2, 7: 3, 8: 4}
def _make_target(annotations=_ANNOTATIONS):
return {"image_id": 1, "annotations": annotations}
class TestConvertCocoWithoutMapping:
"""Without cat2label, sparse IDs pass through unchanged — demonstrating the bug."""
def test_labels_are_raw_category_ids(self):
converter = ConvertCoco(cat2label=None)
_, target = converter(_IMAGE, _make_target())
# Raw COCO IDs — NOT safe to use as indices into an 80-class tensor
assert target["labels"].tolist() == [1, 7]
def test_raw_ids_would_exceed_num_classes(self):
"""Illustrates why raw IDs cause CUDA out-of-bounds with num_classes=80."""
converter = ConvertCoco(cat2label=None)
_, target = converter(_IMAGE, _make_target())
num_classes = len(_SPARSE_CAT_IDS) # 5 — same as model would see
assert any(lbl >= num_classes for lbl in target["labels"].tolist()), (
"At least one raw category_id should exceed num_classes, "
"triggering an out-of-bounds index in the matcher/loss."
)
class TestConvertCocoWithMapping:
"""With cat2label, sparse IDs are remapped to contiguous 0-indexed labels."""
def test_labels_are_remapped_to_zero_indexed(self):
converter = ConvertCoco(cat2label=_CAT2LABEL)
_, target = converter(_IMAGE, _make_target())
# category_id 1 → 0, category_id 7 → 3
assert target["labels"].tolist() == [0, 3]
def test_all_labels_within_num_classes(self):
converter = ConvertCoco(cat2label=_CAT2LABEL)
_, target = converter(_IMAGE, _make_target())
num_classes = len(_SPARSE_CAT_IDS)
assert all(lbl < num_classes for lbl in target["labels"].tolist())
def test_keypoints_retain_instances_with_all_invisible_keypoints(self) -> None:
"""Instances with all-invisible keypoints must be retained for box/class supervision."""
converter = ConvertCoco(include_keypoints=True, num_keypoints_per_class=[17])
visible_keypoints = [0.0, 0.0, 0.0] * 17
visible_keypoints[2] = 2.0
unlabeled_keypoints = [0.0, 0.0, 0.0] * 17
_, target = converter(
_IMAGE,
_make_target(
[
{
"id": 1,
"image_id": 1,
"category_id": 1,
"bbox": [10.0, 10.0, 20.0, 20.0],
"area": 400.0,
"iscrowd": 0,
"keypoints": unlabeled_keypoints,
},
{
"id": 2,
"image_id": 1,
"category_id": 1,
"bbox": [30.0, 30.0, 20.0, 20.0],
"area": 400.0,
"iscrowd": 0,
"keypoints": visible_keypoints,
},
]
),
)
assert target["boxes"].shape == (2, 4)
assert target["labels"].tolist() == [1, 1]
assert target["keypoints"].shape == (2, 17, 3)
assert target["keypoints"][1, 0, 2].item() == 2.0
def test_roboflow_zero_indexed_is_identity(self):
"""Roboflow datasets already use 0-indexed IDs — mapping must be identity."""
roboflow_cat2label = {0: 0, 1: 1, 2: 2}
annotations = [
{"bbox": [10, 10, 30, 30], "category_id": 0, "area": 900, "iscrowd": 0},
{"bbox": [50, 50, 20, 20], "category_id": 2, "area": 400, "iscrowd": 0},
]
converter = ConvertCoco(cat2label=roboflow_cat2label)
_, target = converter(_IMAGE, _make_target(annotations))
assert target["labels"].tolist() == [0, 2]
def test_label_tensor_dtype(self):
converter = ConvertCoco(cat2label=_CAT2LABEL)
_, target = converter(_IMAGE, _make_target())
assert target["labels"].dtype == torch.int64
def _write_coco_json(path: Path, categories: List[Dict]) -> None:
"""Write a minimal valid COCO annotation file."""
path.parent.mkdir(parents=True, exist_ok=True)
data = {"images": [], "annotations": [], "categories": categories}
path.write_text(json.dumps(data))
def _write_roboflow_keypoint_coco(path: Path, *, category_id: int = 0) -> None:
"""Write a minimal Roboflow-style COCO keypoint split."""
path.parent.mkdir(parents=True, exist_ok=True)
image_path = path.parent / "person.png"
Image.new("RGB", (64, 48), color=(255, 255, 255)).save(image_path)
keypoint_names = [
"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",
]
keypoints = []
for idx in range(len(keypoint_names)):
keypoints.extend([10 + idx, 20 + idx, 2])
data = {
"images": [{"id": 1, "file_name": image_path.name, "width": 64, "height": 48}],
"annotations": [
{
"id": 1,
"image_id": 1,
"category_id": category_id,
"bbox": [8, 18, 24, 24],
"area": 576,
"iscrowd": 0,
"num_keypoints": len(keypoint_names),
"keypoints": keypoints,
}
],
"categories": [
{
"id": category_id,
"name": "person",
"supercategory": "person",
"keypoints": keypoint_names,
"skeleton": [],
}
],
}
path.write_text(json.dumps(data), encoding="utf-8")
class TestLoadClassesHierarchy:
"""Regression tests for ``_load_classes`` supercategory filtering (#609).
When all categories have ``supercategory: "none"`` (flat COCO datasets), ``_load_classes`` previously returned an
empty list. It should only filter when a Roboflow hierarchical export is detected.
"""
def test_roboflow_hierarchy_filters_parent(self, tmp_path: Path) -> None:
"""Roboflow exports include a parent node — only leaf categories kept."""
categories = [
{"id": 0, "name": "annotations", "supercategory": "none"},
{"id": 1, "name": "dog", "supercategory": "annotations"},
{"id": 2, "name": "cat", "supercategory": "annotations"},
]
_write_coco_json(tmp_path / "train" / "_annotations.coco.json", categories)
result = RFDETR._load_classes(str(tmp_path))
assert result == ["dog", "cat"]
def test_flat_none_supercategory_keeps_all(self, tmp_path: Path) -> None:
"""Flat datasets where every category has supercategory 'none' (#609)."""
categories = [
{"id": 1, "name": "dog", "supercategory": "none"},
{"id": 2, "name": "cat", "supercategory": "none"},
]
_write_coco_json(tmp_path / "train" / "_annotations.coco.json", categories)
result = RFDETR._load_classes(str(tmp_path))
assert result == ["dog", "cat"]
def test_mixed_supercategories_keeps_all(self, tmp_path: Path) -> None:
"""Mix of 'none' and non-'none' supercategories where no category is a parent of another.
'animal' appears as a supercategory but is not itself a category name, so ``has_children`` is empty and all
categories pass the ``name not in has_children`` filter — both 'dog' and 'cat' are returned.
"""
categories = [
{"id": 1, "name": "dog", "supercategory": "none"},
{"id": 2, "name": "cat", "supercategory": "animal"},
]
_write_coco_json(tmp_path / "train" / "_annotations.coco.json", categories)
result = RFDETR._load_classes(str(tmp_path))
assert result == ["dog", "cat"]
def test_category_named_none_does_not_empty_list(self, tmp_path: Path) -> None:
"""If a category is literally named 'none' and all supercategories are placeholders, the loader must return all
class names instead of []."""
categories = [
{"id": 1, "name": "none", "supercategory": "none"},
{"id": 2, "name": "dog", "supercategory": "none"},
{"id": 3, "name": "cat", "supercategory": "none"},
]
_write_coco_json(tmp_path / "train" / "_annotations.coco.json", categories)
result = RFDETR._load_classes(str(tmp_path))
assert result == ["none", "dog", "cat"]
def test_mixed_hierarchy_leaf_and_standalone_forwarding(self, tmp_path: Path) -> None:
"""Mixed hierarchy: only leaf classes + standalone top-level categories should be forwarded.
Parent/grouping nodes are dropped.
"""
categories = [
{"id": 1, "name": "animals", "supercategory": "none"},
{"id": 2, "name": "mammal", "supercategory": "animals"},
{"id": 3, "name": "dog", "supercategory": "mammal"},
{"id": 4, "name": "cat", "supercategory": "mammal"},
{"id": 5, "name": "bird", "supercategory": "animals"},
{"id": 6, "name": "eagle", "supercategory": "bird"},
{"id": 7, "name": "pigeon", "supercategory": "bird"},
{"id": 8, "name": "objects", "supercategory": "none"},
{"id": 9, "name": "vehicle", "supercategory": "objects"},
{"id": 10, "name": "car", "supercategory": "vehicle"},
{"id": 11, "name": "truck", "supercategory": "vehicle"},
{"id": 12, "name": "appliance", "supercategory": "objects"},
{"id": 13, "name": "toaster", "supercategory": "appliance"},
{"id": 14, "name": "microwave", "supercategory": "appliance"},
{"id": 15, "name": "person", "supercategory": "none"},
]
_write_coco_json(tmp_path / "train" / "_annotations.coco.json", categories)
result = RFDETR._load_classes(str(tmp_path))
expected = [
"dog",
"cat",
"eagle",
"pigeon",
"car",
"truck",
"toaster",
"microwave",
"person",
]
assert result == expected
def test_placeholder_values_treated_as_no_parent(self, tmp_path: Path) -> None:
"""Placeholders like None, '', and 'null' should be treated the same as 'none'."""
categories = [
{"id": 1, "name": "dog", "supercategory": None},
{"id": 2, "name": "cat", "supercategory": ""},
{"id": 3, "name": "elephant", "supercategory": "null"},
]
_write_coco_json(tmp_path / "train" / "_annotations.coco.json", categories)
result = RFDETR._load_classes(str(tmp_path))
assert result == ["dog", "cat", "elephant"]
def test_unsorted_category_ids_return_id_sorted_class_order(self, tmp_path: Path) -> None:
"""Returned class names must follow category-ID order for stable index mapping."""
categories = [
{"id": 30, "name": "truck", "supercategory": "vehicle"},
{"id": 10, "name": "vehicle", "supercategory": "none"},
{"id": 20, "name": "car", "supercategory": "vehicle"},
{"id": 40, "name": "person", "supercategory": "none"},
]
_write_coco_json(tmp_path / "train" / "_annotations.coco.json", categories)
result = RFDETR._load_classes(str(tmp_path))
assert result == ["car", "truck", "person"]
class TestRoboflowCocoKeypointFormat:
"""Roboflow COCO keypoint datasets should align labels with the keypoint schema."""
def _make_args(self, dataset_dir: Path) -> types.SimpleNamespace:
"""Return minimal args consumed by ``build_roboflow_from_coco`` in keypoint mode."""
return types.SimpleNamespace(
dataset_dir=str(dataset_dir),
square_resize_div_64=False,
segmentation_head=False,
multi_scale=False,
expanded_scales=False,
do_random_resize_via_padding=False,
patch_size=16,
num_windows=4,
use_grouppose_keypoints=True,
num_keypoints_per_class=[17],
aug_config={},
augmentation_backend="cpu",
)
def test_keypoint_category_maps_to_active_schema_slot(self, tmp_path: Path) -> None:
"""A one-class Roboflow keypoint dataset maps person to label 0 for the `[17]` preview schema."""
_write_roboflow_keypoint_coco(tmp_path / "train" / "_annotations.coco.json", category_id=0)
dataset = build_roboflow_from_coco("train", self._make_args(tmp_path), resolution=64)
_, target = dataset[0]
assert target["labels"].tolist() == [0]
assert target["keypoints"].shape == (1, 17, 3)
assert dataset.cat2label == {0: 0}
assert dataset.label2cat == {0: 0}
assert dataset.coco.label2cat == {0: 0}
def test_standard_coco_cat_id_maps_to_active_schema_slot(self, tmp_path: Path) -> None:
"""Standard COCO person (cat_id=1) maps to slot 0 under the active-first [17] schema."""
_write_roboflow_keypoint_coco(tmp_path / "train" / "_annotations.coco.json", category_id=1)
dataset = build_roboflow_from_coco("train", self._make_args(tmp_path), resolution=64)
assert dataset.cat2label == {1: 0}
def test_keypoint_coco_without_keypoint_schema_raises(self, tmp_path: Path) -> None:
"""Keypoint mode should fail clearly if a COCO dataset has no keypoint metadata or annotations."""
_write_coco_json(
tmp_path / "train" / "_annotations.coco.json",
[{"id": 0, "name": "person", "supercategory": "none"}],
)
with pytest.raises(ValueError, match="Keypoint COCO dataset"):
build_roboflow_from_coco("train", self._make_args(tmp_path), resolution=64)
class TestInferCocoKeypointSchema:
"""COCO keypoint schema inference."""
def test_reads_category_keypoint_metadata(self, tmp_path: Path) -> None:
"""Category keypoint names define the per-class keypoint count."""
_write_roboflow_keypoint_coco(tmp_path / "train" / "_annotations.coco.json", category_id=0)
schema = infer_coco_keypoint_schema(tmp_path / "train" / "_annotations.coco.json")
assert schema.class_names == ["person"]
assert schema.num_keypoints_per_class == [17]
assert len(schema.keypoint_oks_sigmas) == 17
def test_falls_back_to_annotation_keypoint_vectors(self, tmp_path: Path) -> None:
"""Annotation vectors can define keypoint count when category names are absent."""
annotation_path = tmp_path / "train" / "_annotations.coco.json"
annotation_path.parent.mkdir(parents=True, exist_ok=True)
annotation_path.write_text(
json.dumps(
{
"images": [],
"annotations": [
{
"id": 1,
"image_id": 1,
"category_id": 0,
"bbox": [0, 0, 10, 10],
"area": 100,
"iscrowd": 0,
"keypoints": [1, 2, 2, 3, 4, 2],
}
],
"categories": [{"id": 0, "name": "person", "supercategory": "none"}],
}
),
encoding="utf-8",
)
schema = infer_coco_keypoint_schema(annotation_path)
assert schema.num_keypoints_per_class == [2]
# ---------------------------------------------------------------------------
# TestBuildO365RawGpuBackend — validates that build_o365_raw emits a WARNING
# and passes gpu_postprocess when augmentation_backend != 'cpu'.
# ---------------------------------------------------------------------------
class TestBuildO365RawGpuBackend:
"""build_o365_raw warns and wires gpu_postprocess for non-cpu backends."""
class _FakeArgs:
"""Minimal args stub for build_o365_raw."""
def __init__(self, augmentation_backend="cpu", square_resize_div_64=False):
self.augmentation_backend = augmentation_backend
self.square_resize_div_64 = square_resize_div_64
self.multi_scale = False
self.expanded_scales = False
self.dataset_dir = "/nonexistent/o365"
self.coco_path = "/nonexistent/o365"
def _call_build_o365_raw(self, augmentation_backend, square_resize_div_64=False):
"""Call build_o365_raw with mocked CocoDetection and transform builders."""
from unittest.mock import MagicMock, patch
from rfdetr.datasets.o365 import build_o365_raw
args = self._FakeArgs(augmentation_backend=augmentation_backend, square_resize_div_64=square_resize_div_64)
fake_dataset = MagicMock()
with (
patch("rfdetr.datasets.o365.CocoDetection", return_value=fake_dataset),
patch("rfdetr.datasets.o365.make_coco_transforms") as mock_transform,
patch("rfdetr.datasets.o365.make_coco_transforms_square_div_64") as mock_sq_transform,
):
mock_transform.return_value = MagicMock()
mock_sq_transform.return_value = MagicMock()
result = build_o365_raw("train", args, resolution=640)
return result, mock_transform, mock_sq_transform
def test_cpu_backend_no_warning(self):
"""Cpu backend does not call logger.warning with O365 content."""
from unittest.mock import patch
with patch("rfdetr.datasets.o365.logger") as mock_logger:
self._call_build_o365_raw("cpu")
o365_warns = [c for c in mock_logger.warning.call_args_list if "O365" in str(c)]
assert len(o365_warns) == 0, "cpu backend must not warn about O365 GPU augmentation"
def test_auto_backend_emits_warning(self):
"""Auto + CUDA + kornia available: logger.warning about O365 Phase 1 limitation."""
import sys
from unittest.mock import MagicMock, patch
with (
patch("rfdetr.datasets.kornia_transforms._has_cuda_device", return_value=True),
patch.dict(sys.modules, {"kornia": MagicMock(), "kornia.augmentation": MagicMock()}),
patch("rfdetr.datasets.o365.logger") as mock_logger,
):
self._call_build_o365_raw("auto")
o365_warns = [c for c in mock_logger.warning.call_args_list if "O365" in str(c)]
assert len(o365_warns) >= 1, "auto backend must warn about O365 GPU aug limitation"
def test_auto_backend_no_cuda_no_warning(self):
"""Auto + no CUDA: resolves to cpu, no O365 warning emitted."""
from unittest.mock import patch
with (
patch("rfdetr.datasets.kornia_transforms._has_cuda_device", return_value=False),
patch("rfdetr.datasets.o365.logger") as mock_logger,
):
self._call_build_o365_raw("auto")
o365_warns = [c for c in mock_logger.warning.call_args_list if "O365" in str(c)]
assert len(o365_warns) == 0, "auto + no CUDA must not warn about O365 GPU aug"
def test_gpu_postprocess_false_for_cpu_backend(self):
"""Cpu backend passes gpu_postprocess=False (or omits it) to make_coco_transforms."""
_, mock_transform, _ = self._call_build_o365_raw("cpu")
call_kwargs = mock_transform.call_args.kwargs if mock_transform.call_args else {}
assert call_kwargs.get("gpu_postprocess", False) is False
def test_gpu_postprocess_true_for_auto_backend(self):
"""Auto + CUDA + kornia available: gpu_postprocess=True passed to make_coco_transforms."""
import sys
from unittest.mock import MagicMock, patch
with (
patch("rfdetr.datasets.kornia_transforms._has_cuda_device", return_value=True),
patch.dict(sys.modules, {"kornia": MagicMock(), "kornia.augmentation": MagicMock()}),
):
_, mock_transform, _ = self._call_build_o365_raw("auto")
call_kwargs = mock_transform.call_args.kwargs if mock_transform.call_args else {}
assert call_kwargs.get("gpu_postprocess", False) is True
def test_gpu_postprocess_false_for_auto_no_cuda(self):
"""Auto + no CUDA: gpu_postprocess=False so CPU Normalize is retained."""
from unittest.mock import patch
with patch("rfdetr.datasets.kornia_transforms._has_cuda_device", return_value=False):
_, mock_transform, _ = self._call_build_o365_raw("auto")
call_kwargs = mock_transform.call_args.kwargs if mock_transform.call_args else {}
assert call_kwargs.get("gpu_postprocess", False) is False, "auto + no CUDA must not strip CPU Normalize"
def test_square_resize_uses_square_transform(self):
"""square_resize_div_64=True delegates to make_coco_transforms_square_div_64."""
_, mock_transform, mock_sq_transform = self._call_build_o365_raw("cpu", square_resize_div_64=True)
mock_sq_transform.assert_called_once()
mock_transform.assert_not_called()
def test_gpu_backend_no_cuda_raises_runtime_error(self):
"""Gpu backend must fail fast when CUDA is unavailable."""
from unittest.mock import patch
with (
patch("rfdetr.datasets.kornia_transforms._has_cuda_device", return_value=False),
pytest.raises(RuntimeError, match="CUDA"),
):
self._call_build_o365_raw("gpu")
def test_gpu_backend_no_kornia_raises_import_error(self):
"""Gpu backend must raise with install hint when kornia is missing."""
from unittest.mock import patch
original_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__
def _mock_import(name, *args, **kwargs):
if name == "kornia" or name.startswith("kornia."):
raise ImportError("No module named 'kornia'")
return original_import(name, *args, **kwargs)
with (
patch("rfdetr.datasets.kornia_transforms._has_cuda_device", return_value=True),
patch("builtins.__import__", side_effect=_mock_import),
pytest.raises(ImportError, match="rfdetr\\[kornia\\]"),
):
self._call_build_o365_raw("gpu")
class TestBuildRoboflowFromCocoBackendResolution:
"""Roboflow COCO builder should resolve backend for gpu_postprocess consistently."""
def test_auto_no_cuda_keeps_cpu_normalize(self):
"""Auto + no CUDA must set gpu_postprocess=False."""
from unittest.mock import MagicMock, patch
from rfdetr.datasets.coco import build_roboflow_from_coco
args = types.SimpleNamespace(
dataset_dir="/fake/dataset",
augmentation_backend="auto",
square_resize_div_64=False,
segmentation_head=False,
multi_scale=False,
expanded_scales=False,
do_random_resize_via_padding=False,
patch_size=16,
num_windows=4,
aug_config=None,
)
with (
patch("rfdetr.datasets.coco.Path") as mock_path,
patch("rfdetr.datasets.coco.make_coco_transforms") as mock_transforms,
patch("rfdetr.datasets.coco.CocoDetection", return_value=MagicMock()),
patch("rfdetr.datasets.kornia_transforms._has_cuda_device", return_value=False),
):
mock_path.return_value.exists.return_value = True
mock_transforms.return_value = MagicMock()
build_roboflow_from_coco("train", args, resolution=640)
assert mock_transforms.call_args.kwargs["gpu_postprocess"] is False
@pytest.mark.parametrize(
("square_resize_div_64", "transform_factory"),
[
pytest.param(False, "make_coco_transforms", id="standard_resize"),
pytest.param(True, "make_coco_transforms_square_div_64", id="square_resize"),
],
)
def test_keypoint_flip_pairs_forwarded_to_transforms(
self,
tmp_path: Path,
square_resize_div_64: bool,
transform_factory: str,
) -> None:
"""Roboflow keypoint datasets must pass flip pairs to CPU augmentation transforms."""
from unittest.mock import MagicMock, patch
from rfdetr.datasets.coco import build_roboflow_from_coco
args = types.SimpleNamespace(
dataset_dir=str(tmp_path),
augmentation_backend="cpu",
square_resize_div_64=square_resize_div_64,
segmentation_head=False,
multi_scale=False,
expanded_scales=False,
do_random_resize_via_padding=False,
patch_size=16,
num_windows=4,
use_grouppose_keypoints=True,
num_keypoints_per_class=[0, 4],
keypoint_flip_pairs=[0, 1, 2, 3],
aug_config={},
)
with (
patch(f"rfdetr.datasets.coco.{transform_factory}") as mock_transforms,
patch("rfdetr.datasets.coco.CocoDetection") as mock_coco,
):
mock_transforms.return_value = MagicMock()
mock_coco.return_value = MagicMock()
build_roboflow_from_coco("train", args, resolution=640)
assert mock_transforms.call_args.kwargs["keypoint_flip_pairs"] == [0, 1, 2, 3]
class TestBuilderGpuPostprocess:
"""Verify Roboflow COCO builder sets gpu_postprocess for segmentation models."""
@pytest.mark.parametrize(
"segmentation_head, augmentation_backend, resolved_backend, expected_gpu_postprocess",
[
pytest.param(False, "cpu", "cpu", False, id="cpu_backend_no_seg"),
pytest.param(True, "cpu", "cpu", False, id="cpu_backend_with_seg"),
pytest.param(False, "gpu", "gpu", True, id="gpu_backend_no_seg"),
pytest.param(True, "gpu", "gpu", True, id="gpu_backend_with_seg"),
pytest.param(True, "auto", "gpu", True, id="auto_resolved_gpu_with_seg"),
pytest.param(True, "auto", "cpu", False, id="auto_resolved_cpu_with_seg"),
],
)
def test_gpu_postprocess_flag(
self,
tmp_path,
segmentation_head,
augmentation_backend,
resolved_backend,
expected_gpu_postprocess,
):
"""Build Roboflow COCO datasets and assert the GPU postprocess flag passed to transforms."""
from unittest.mock import MagicMock, patch
from rfdetr.datasets.coco import build_roboflow_from_coco
annotations_dir = tmp_path / "train"
annotations_dir.mkdir()
(annotations_dir / "_annotations.coco.json").write_text(
json.dumps({"images": [], "annotations": [], "categories": []}),
encoding="utf-8",
)
args = types.SimpleNamespace(
dataset_dir=str(tmp_path),
segmentation_head=segmentation_head,
augmentation_backend=augmentation_backend,
square_resize_div_64=False,
multi_scale=False,
expanded_scales=False,
do_random_resize_via_padding=False,
patch_size=16,
num_windows=4,
aug_config=None,
)
with (
patch("rfdetr.datasets.coco._resolve_runtime_augmentation_backend", return_value=resolved_backend),
patch("rfdetr.datasets.coco.make_coco_transforms") as mock_transforms,
patch("rfdetr.datasets.coco.CocoDetection") as mock_coco,
):
mock_transforms.return_value = MagicMock()
mock_coco.return_value = MagicMock()
build_roboflow_from_coco("train", args, resolution=640)
call_kwargs = mock_transforms.call_args.kwargs if mock_transforms.call_args else mock_transforms.call_args[1]
assert call_kwargs["gpu_postprocess"] is expected_gpu_postprocess
def _make_keypoint_annotation(
*,
category_id: int = 1,
bbox: List[float] | None = None,
area: float = 80.0,
keypoints: List[float] | None = None,
) -> Dict[str, object]:
"""Build a minimal keypoint annotation used in keypoint conversion tests."""
return {
"bbox": bbox if bbox is not None else [10.0, 5.0, 8.0, 10.0],
"category_id": category_id,
"area": area,
"iscrowd": 0,
"keypoints": keypoints if keypoints is not None else [1.0, 2.0, 2.0] * 17,
}
def _make_coco_builder_args(tmp_path: Path, *, use_grouppose_keypoints: bool) -> types.SimpleNamespace:
"""Return a namespace with all fields consumed by ``build_coco``."""
return types.SimpleNamespace(
dataset_dir=None,
coco_path=str(tmp_path),
square_resize_div_64=False,
segmentation_head=False,
multi_scale=False,
expanded_scales=False,
do_random_resize_via_padding=False,
patch_size=16,
num_windows=4,
# Empty aug_config disables augmentation — these tests verify annotation routing, not aug.
aug_config={},
augmentation_backend="cpu",
use_grouppose_keypoints=use_grouppose_keypoints,
num_keypoints_per_class=[17] if use_grouppose_keypoints else [],
keypoint_flip_pairs=[],
)
class TestConvertCocoKeypoints:
"""ConvertCoco keypoint-mode coverage."""
def test_keypoint_target_includes_keypoints(self) -> None:
"""Keypoint-enabled conversion should emit keypoints in ``[N, K, 3]`` format."""
converter = ConvertCoco(
include_masks=False,
include_keypoints=True,
cat2label=None,
num_keypoints_per_class=[17],
)
_, target = converter(
_IMAGE,
{"image_id": 42, "annotations": [_make_keypoint_annotation()]},
)
assert target["keypoints"].shape == (1, 17, 3)
assert target["keypoints"].dtype == torch.float32
assert target["labels"].tolist() == [1]
def test_person_category_stays_raw_coco_id(self) -> None:
"""COCO person category ``1`` remains raw when no category remapping is supplied."""
converter = ConvertCoco(
include_masks=False,
include_keypoints=True,
cat2label=None,
num_keypoints_per_class=[17],
)
_, target = converter(
_IMAGE,
{"image_id": 7, "annotations": [_make_keypoint_annotation(category_id=1)]},
)
assert target["labels"].shape == (1,)
assert target["labels"].item() == 1
def test_num_keypoints_zero_annotation_retains_instance_for_box_supervision(self) -> None:
"""All-zero-visibility keypoints must not drop the instance; box/class targets are still valid."""
converter = ConvertCoco(
include_masks=False,
include_keypoints=True,
cat2label=None,
num_keypoints_per_class=[17],
)
_, target = converter(
_IMAGE,
{"image_id": 3, "annotations": [_make_keypoint_annotation(keypoints=[0.0] * (17 * 3))]},
)
assert target["boxes"].shape == (1, 4)
assert target["labels"].shape == (1,)
assert target["keypoints"].shape == (1, 17, 3)
assert torch.count_nonzero(target["keypoints"]) == 0
def test_empty_image_uses_schema_max_shape(self) -> None:
"""Empty images should emit ``(0, max(num_keypoints_per_class), 3)`` keypoint tensors."""
converter = ConvertCoco(
include_masks=False,
include_keypoints=True,
cat2label={1: 0},
num_keypoints_per_class=[2, 1],
)
_, target = converter(_IMAGE, {"image_id": 99, "annotations": []})
assert target["keypoints"].shape == (0, 2, 3)
def test_multiclass_keypoints_use_schema_max_shape(self) -> None:
"""Multi-class keypoint targets should be padded to Kmax, not schema sum."""
converter = ConvertCoco(
include_masks=False,
include_keypoints=True,
cat2label=None,
num_keypoints_per_class=[2, 1],
)
_, target = converter(
_IMAGE,
{
"image_id": 100,
"annotations": [
_make_keypoint_annotation(category_id=0, keypoints=[1.0, 2.0, 2.0, 3.0, 4.0, 2.0]),
_make_keypoint_annotation(category_id=1, keypoints=[5.0, 6.0, 2.0]),
],
},
)
assert target["labels"].tolist() == [0, 1]
assert target["keypoints"].shape == (2, 2, 3)
torch.testing.assert_close(
target["keypoints"][0],
torch.tensor([[1.0, 2.0, 2.0], [3.0, 4.0, 2.0]], dtype=torch.float32),
rtol=1e-4,
atol=1e-6,
)
torch.testing.assert_close(
target["keypoints"][1],
torch.tensor([[5.0, 6.0, 2.0], [0.0, 0.0, 0.0]], dtype=torch.float32),
rtol=1e-4,
atol=1e-6,
)
class TestBuildCocoKeypointMode:
"""COCO builder mode switch for person keypoints."""
def test_keypoint_mode_uses_person_keypoints_annotations(self, tmp_path: Path) -> None:
"""Keypoint mode should switch train annotations to ``person_keypoints_train2017.json``."""
args = _make_coco_builder_args(tmp_path, use_grouppose_keypoints=True)
from unittest.mock import patch
with (
patch("rfdetr.datasets.coco.make_coco_transforms", return_value=lambda image, target: (image, target)),
patch("rfdetr.datasets.coco.CocoDetection", return_value=object()) as mock_dataset,
):
build_coco("train", args, resolution=640)
_, kwargs = mock_dataset.call_args
ann_file = Path(mock_dataset.call_args.args[1])
assert ann_file.parent.name == "annotations"
assert ann_file.name == "person_keypoints_train2017.json"
assert kwargs["include_keypoints"] is True
assert kwargs["remap_category_ids"] is True
def test_default_mode_uses_instances_annotations_with_raw_coco_ids(self, tmp_path: Path) -> None:
"""Default COCO detection mode should keep raw sparse category IDs for pretrained checkpoints."""
from unittest.mock import patch
args = _make_coco_builder_args(tmp_path, use_grouppose_keypoints=False)
with (
patch("rfdetr.datasets.coco.make_coco_transforms", return_value=lambda image, target: (image, target)),
patch("rfdetr.datasets.coco.CocoDetection", return_value=object()) as mock_dataset,
):
build_coco("train", args, resolution=640)
_, kwargs = mock_dataset.call_args
ann_file = Path(mock_dataset.call_args.args[1])
assert ann_file.parent.name == "annotations"
assert ann_file.name == "instances_train2017.json"
assert kwargs["include_keypoints"] is False
assert kwargs["remap_category_ids"] is False
class TestBuildKeypointCat2Label:
"""Unit tests for ``_build_keypoint_cat2label`` schema alignment."""
def _person_coco(self, cat_id: int = 1) -> types.SimpleNamespace:
"""Return a minimal COCO-like object with a single keypoint-bearing person category."""
return types.SimpleNamespace(
cats={cat_id: {"name": "person", "keypoints": ["nose"] * 17}},
anns={},
)
def test_legacy_bgfirst_schema_maps_person_to_slot_1(self) -> None:
"""Legacy [0, 17] schema maps person (cat_id=1) to slot 1, not slot 0."""
from rfdetr.datasets.coco import _build_keypoint_cat2label
result = _build_keypoint_cat2label(self._person_coco(cat_id=1), num_keypoints_per_class=[0, 17])
assert result == {1: 1}
def test_mixed_detection_and_keypoint_categories(self) -> None:
"""Non-keypoint categories fill free slots after keypoint categories are assigned."""
from rfdetr.datasets.coco import _build_keypoint_cat2label
coco = types.SimpleNamespace(
cats={
1: {"name": "person", "keypoints": ["nose"] * 17},
3: {"name": "car"},
},
anns={},
)
result = _build_keypoint_cat2label(coco, num_keypoints_per_class=[17])
assert result == {1: 0, 3: 1}
class TestCocoDetectionZeroAnnotations:
"""CocoDetection correctly handles images with no annotations."""
def test_zero_annotation_sample_yields_empty_boxes_and_labels(self, tmp_path: Path) -> None:
"""An image with no annotations yields boxes (0, 4) float32 and labels (0,) int64 tensors."""
img_dir = tmp_path / "images"
img_dir.mkdir()
Image.new("RGB", (100, 100)).save(img_dir / "img1.jpg")
Image.new("RGB", (100, 100)).save(img_dir / "img2.jpg")
ann_file = tmp_path / "annotations.json"
ann_file.write_text(
json.dumps(
{
"images": [
{"id": 1, "file_name": "img1.jpg", "width": 100, "height": 100},
{"id": 2, "file_name": "img2.jpg", "width": 100, "height": 100},
],
"annotations": [
{"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 10, 30, 30], "area": 900, "iscrowd": 0}
],
"categories": [{"id": 1, "name": "cat", "supercategory": "animal"}],
}
)
)
dataset = CocoDetection(img_dir, ann_file, transforms=None)
zero_ann_idx = dataset.ids.index(2)
_, target = dataset[zero_ann_idx]
assert target["boxes"].shape == torch.Size([0, 4])
assert target["labels"].shape == torch.Size([0])
assert target["boxes"].dtype == torch.float32
assert target["labels"].dtype == torch.int64
+267
View File
@@ -0,0 +1,267 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Characterization tests for _build_train_resize_config."""
import pytest
from rfdetr.datasets.coco import _build_train_resize_config
class TestBuildTrainResizeConfigStructure:
"""Top-level structure is always a single-element list wrapping a OneOf."""
@pytest.mark.parametrize(
"scales,square",
[
pytest.param([640], True, id="square-single"),
pytest.param([480, 640], True, id="square-multi"),
pytest.param([640], False, id="nonsquare-single"),
pytest.param([480, 640], False, id="nonsquare-multi"),
],
)
def test_returns_single_element_list(self, scales, square):
result = _build_train_resize_config(scales, square=square)
assert isinstance(result, list)
assert len(result) == 1
@pytest.mark.parametrize(
"scales,square",
[
pytest.param([640], True, id="square-single"),
pytest.param([480, 640], True, id="square-multi"),
pytest.param([640], False, id="nonsquare-single"),
pytest.param([480, 640], False, id="nonsquare-multi"),
],
)
def test_top_level_is_oneof_with_two_branches(self, scales, square):
result = _build_train_resize_config(scales, square=square)
entry = result[0]
assert "OneOf" in entry
oneof = entry["OneOf"]
assert len(oneof["transforms"]) == 2
class TestBuildTrainResizeConfigSquareSingleScale:
"""Square=True, single scale — OneOf[Resize] + Sequential[..., OneOf[RandomSizedCrop]]."""
def test_option_a_is_oneof_wrapping_single_resize(self):
result = _build_train_resize_config([640], square=True)
option_a = result[0]["OneOf"]["transforms"][0]
assert option_a == {
"OneOf": {
"transforms": [{"Resize": {"height": 640, "width": 640}}],
}
}
def test_option_b_is_sequential_with_oneof_crop(self):
result = _build_train_resize_config([640], square=True)
option_b = result[0]["OneOf"]["transforms"][1]
assert option_b == {
"Sequential": {
"transforms": [
{"SmallestMaxSize": {"max_size": [400, 500, 600]}},
{
"OneOf": {
"transforms": [
{"RandomSizedCrop": {"min_max_height": [384, 600], "height": 640, "width": 640}},
],
}
},
]
}
}
def test_uses_correct_scale_value(self):
result = _build_train_resize_config([480], square=True)
option_a = result[0]["OneOf"]["transforms"][0]
assert option_a == {
"OneOf": {
"transforms": [{"Resize": {"height": 480, "width": 480}}],
}
}
class TestBuildTrainResizeConfigSquareMultiScale:
"""Square=True, multiple scales — OneOf[Resize] + Sequential[..., OneOf[RandomSizedCrop]]."""
def test_option_a_is_oneof_of_resizes(self):
result = _build_train_resize_config([480, 640], square=True)
option_a = result[0]["OneOf"]["transforms"][0]
assert option_a == {
"OneOf": {
"transforms": [
{"Resize": {"height": 480, "width": 480}},
{"Resize": {"height": 640, "width": 640}},
],
}
}
def test_option_b_is_sequential_with_oneof_crop(self):
result = _build_train_resize_config([480, 640], square=True)
option_b = result[0]["OneOf"]["transforms"][1]
assert option_b == {
"Sequential": {
"transforms": [
{"SmallestMaxSize": {"max_size": [400, 500, 600]}},
{
"OneOf": {
"transforms": [
{"RandomSizedCrop": {"min_max_height": [384, 600], "height": 480, "width": 480}},
{"RandomSizedCrop": {"min_max_height": [384, 600], "height": 640, "width": 640}},
],
}
},
]
}
}
def test_three_scales_produce_three_resize_options(self):
result = _build_train_resize_config([384, 512, 640], square=True)
option_a = result[0]["OneOf"]["transforms"][0]
assert len(option_a["OneOf"]["transforms"]) == 3
class TestBuildTrainResizeConfigNonSquareSingleScale:
"""Square=False, single scale — SmallestMaxSize uses scalar, default cap 1333."""
def test_option_a_uses_scalar_size(self):
result = _build_train_resize_config([640], square=False)
option_a = result[0]["OneOf"]["transforms"][0]
assert option_a == {
"Sequential": {
"transforms": [
{"SmallestMaxSize": {"max_size": 640}},
{"LongestMaxSize": {"max_size": 1333}},
]
}
}
def test_option_b_uses_scalar_size(self):
result = _build_train_resize_config([640], square=False)
option_b = result[0]["OneOf"]["transforms"][1]
assert option_b == {
"Sequential": {
"transforms": [
{"SmallestMaxSize": {"max_size": [400, 500, 600]}},
{
"OneOf": {
"transforms": [
{"RandomSizedCrop": {"min_max_height": [384, 600], "height": 640, "width": 640}},
]
}
},
]
}
}
def test_custom_max_size(self):
result = _build_train_resize_config([640], square=False, max_size=800)
option_a = result[0]["OneOf"]["transforms"][0]
assert option_a["Sequential"]["transforms"][1] == {"LongestMaxSize": {"max_size": 800}}
class TestBuildTrainResizeConfigNonSquareMultiScale:
"""Square=False, multiple scales — SmallestMaxSize uses list directly."""
def test_option_a_uses_list_size(self):
result = _build_train_resize_config([480, 640], square=False)
option_a = result[0]["OneOf"]["transforms"][0]
assert option_a == {
"Sequential": {
"transforms": [
{"SmallestMaxSize": {"max_size": [480, 640]}},
{"LongestMaxSize": {"max_size": 1333}},
]
}
}
def test_option_b_uses_list_size(self):
result = _build_train_resize_config([480, 640], square=False)
option_b = result[0]["OneOf"]["transforms"][1]
assert option_b == {
"Sequential": {
"transforms": [
{"SmallestMaxSize": {"max_size": [400, 500, 600]}},
{
"OneOf": {
"transforms": [
{"RandomSizedCrop": {"min_max_height": [384, 600], "height": 480, "width": 480}},
{"RandomSizedCrop": {"min_max_height": [384, 600], "height": 640, "width": 640}},
]
}
},
]
}
}
def test_custom_max_size_applies_to_option_a_only(self):
"""max_size caps option_a's long side; option_b now resizes the crop directly to the target (no cap step)."""
result = _build_train_resize_config([480, 640], square=False, max_size=1000)
option_a = result[0]["OneOf"]["transforms"][0]
option_b_steps = result[0]["OneOf"]["transforms"][1]["Sequential"]["transforms"]
assert option_a["Sequential"]["transforms"][1] == {"LongestMaxSize": {"max_size": 1000}}
assert not any("LongestMaxSize" in step for step in option_b_steps)
class TestBuildTrainResizeConfigNonSquareScaleJitter:
"""Non-square option_b must keep RandomSizedCrop (scale jitter), not a fixed RandomCrop.
Regression tests for https://github.com/roboflow/rf-detr/issues/1018 — PR #752 replaced RandomSizeCrop(384, 600)
with a fixed RandomCrop(384, 384), silently removing scale jitter from the non-square training pipeline.
The ``fix-resize-crop`` branch keeps RandomSizedCrop and removes the wasteful fixed-384 intermediate hop: the crop
now resizes directly to the target scale (per-scale ``OneOf``, mirroring the square path). ``min_max_height`` uses
``[384, 600]`` to match the full SmallestMaxSize range — when the image short side is 400, albumentations clamps
the crop to the image height (a full-image crop), which is the original DETR recipe behaviour and preserves
zoom-out diversity across the SmallestMaxSize range.
"""
@pytest.mark.parametrize(
"scales",
[
pytest.param([640], id="nonsquare-single"),
pytest.param([480, 640], id="nonsquare-multi"),
],
)
def test_option_b_crop_step_uses_random_sized_crop(self, scales):
"""Non-square option_b crop must use RandomSizedCrop, never fixed RandomCrop (issue #1018)."""
result = _build_train_resize_config(scales, square=False)
option_b = result[0]["OneOf"]["transforms"][1]
crop_step = option_b["Sequential"]["transforms"][1]
crop_variants = crop_step["OneOf"]["transforms"]
assert crop_variants and all(
"RandomSizedCrop" in entry and "RandomCrop" not in entry for entry in crop_variants
)
@pytest.mark.parametrize(
"scales",
[
pytest.param([640], id="nonsquare-single"),
pytest.param([480, 640], id="nonsquare-multi"),
],
)
def test_option_b_crop_uses_full_scale_jitter_range(self, scales):
"""RandomSizedCrop min_max_height matches SmallestMaxSize range [384, 600] for full zoom-out diversity."""
result = _build_train_resize_config(scales, square=False)
option_b = result[0]["OneOf"]["transforms"][1]
crop_variants = option_b["Sequential"]["transforms"][1]["OneOf"]["transforms"]
assert all(entry["RandomSizedCrop"]["min_max_height"] == [384, 600] for entry in crop_variants)
@pytest.mark.parametrize(
"scales,square",
[
pytest.param([640], True, id="square-single"),
pytest.param([480, 640], True, id="square-multi"),
],
)
def test_square_option_b_unchanged(self, scales, square):
"""Square path must still use RandomSizedCrop parameterized by scale."""
result = _build_train_resize_config(scales, square=square)
option_b = result[0]["OneOf"]["transforms"][1]
inner_transforms = option_b["Sequential"]["transforms"][1]["OneOf"]["transforms"]
for entry in inner_transforms:
assert "RandomSizedCrop" in entry
assert entry["RandomSizedCrop"]["min_max_height"] == [384, 600]
+280
View File
@@ -0,0 +1,280 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for native RLE annotation support in the COCO dataset pipeline.
Verifies that :func:`convert_coco_poly_to_mask` and :class:`ConvertCoco` correctly handle compressed RLE, uncompressed
RLE, and polygon segmentation formats — including mixed annotations within the same image.
"""
import numpy as np
import pycocotools.mask as mask_util
import pytest
import torch
from PIL import Image
from rfdetr.datasets.coco import ConvertCoco, _is_rle, convert_coco_poly_to_mask
# Shared test dimensions
_H, _W = 100, 100
_IMAGE = Image.new("RGB", (_W, _H))
def _make_reference_mask() -> np.ndarray:
"""Create a deterministic 100x100 binary mask with a rectangular region."""
mask = np.zeros((_H, _W), dtype=np.uint8)
mask[20:50, 30:70] = 1
return mask
def _encode_compressed_rle(mask: np.ndarray) -> dict:
"""Encode a binary mask to compressed RLE with string counts (COCO JSON format)."""
rle = mask_util.encode(np.asfortranarray(mask))
# COCO JSON stores counts as a UTF-8 string, not bytes
rle["counts"] = rle["counts"].decode("utf-8") if isinstance(rle["counts"], bytes) else rle["counts"]
rle["size"] = list(rle["size"])
return rle
def _encode_uncompressed_rle(mask: np.ndarray) -> dict:
"""Encode a binary mask to uncompressed RLE with integer counts."""
flat = mask.flatten(order="F")
counts = []
current_val = 0
run_length = 0
for pixel in flat:
if pixel == current_val:
run_length += 1
else:
counts.append(run_length)
current_val = pixel
run_length = 1
counts.append(run_length)
return {"counts": counts, "size": [_H, _W]}
def _make_polygon(mask: np.ndarray) -> list:
"""Create a polygon annotation from a rectangular mask region."""
# Simple rectangle polygon matching the mask region [20:50, 30:70]
return [[30, 20, 70, 20, 70, 50, 30, 50]]
class TestIsRle:
"""Tests for the ``_is_rle`` helper."""
def test_compressed_rle_detected(self) -> None:
assert _is_rle({"counts": "abc", "size": [100, 100]}) is True
def test_uncompressed_rle_detected(self) -> None:
assert _is_rle({"counts": [0, 5, 10], "size": [100, 100]}) is True
def test_bytes_counts_detected(self) -> None:
assert _is_rle({"counts": b"abc", "size": [100, 100]}) is True
def test_polygon_not_detected(self) -> None:
assert _is_rle([[30, 20, 70, 20, 70, 50, 30, 50]]) is False
def test_empty_list_not_detected(self) -> None:
assert _is_rle([]) is False
def test_none_not_detected(self) -> None:
assert _is_rle(None) is False
class TestConvertCocoPolyToMaskRle:
"""Tests for RLE support in ``convert_coco_poly_to_mask``."""
def test_compressed_rle_decodes_correctly(self) -> None:
"""Compressed RLE (string counts) should decode to the expected mask."""
ref_mask = _make_reference_mask()
rle = _encode_compressed_rle(ref_mask)
result = convert_coco_poly_to_mask([rle], _H, _W)
assert result.shape == (1, _H, _W)
assert result.dtype == torch.uint8
assert torch.equal(result[0], torch.as_tensor(ref_mask, dtype=torch.uint8))
def test_uncompressed_rle_decodes_correctly(self) -> None:
"""Uncompressed RLE (int-list counts) should decode to the expected mask."""
ref_mask = _make_reference_mask()
uncompressed = _encode_uncompressed_rle(ref_mask)
result = convert_coco_poly_to_mask([uncompressed], _H, _W)
assert result.shape == (1, _H, _W)
assert result.dtype == torch.uint8
assert torch.equal(result[0], torch.as_tensor(ref_mask, dtype=torch.uint8))
def test_polygon_still_works(self) -> None:
"""Polygon annotations should continue to work as before."""
polygon = _make_polygon(_make_reference_mask())
result = convert_coco_poly_to_mask([polygon], _H, _W)
assert result.shape == (1, _H, _W)
assert result.dtype == torch.uint8
# The polygon covers the same rectangular region
assert result[0, 30, 50] == 1 # inside the region
assert result[0, 0, 0] == 0 # outside
def test_compressed_rle_matches_polygon(self) -> None:
"""Compressed RLE and polygon for the same region should produce identical masks."""
polygon = _make_polygon(_make_reference_mask())
poly_masks = convert_coco_poly_to_mask([polygon], _H, _W)
# Encode the polygon result as RLE, then decode via our path
ref_np = poly_masks[0].numpy()
rle = _encode_compressed_rle(ref_np)
rle_masks = convert_coco_poly_to_mask([rle], _H, _W)
assert torch.equal(poly_masks, rle_masks)
def test_mixed_polygon_and_rle(self) -> None:
"""An image can have both polygon and RLE annotations across instances."""
ref_mask = _make_reference_mask()
polygon = _make_polygon(ref_mask)
rle = _encode_compressed_rle(ref_mask)
result = convert_coco_poly_to_mask([polygon, rle], _H, _W)
assert result.shape == (2, _H, _W)
# Both should produce the same mask
assert torch.equal(result[0], result[1])
def test_empty_segmentation_unchanged(self) -> None:
"""Empty segmentation should produce a zero mask."""
result = convert_coco_poly_to_mask([[]], _H, _W)
assert result.shape == (1, _H, _W)
assert result.sum() == 0
def test_none_segmentation_unchanged(self) -> None:
"""None segmentation should produce a zero mask."""
result = convert_coco_poly_to_mask([None], _H, _W)
assert result.shape == (1, _H, _W)
assert result.sum() == 0
def test_empty_list_returns_zero_tensor(self) -> None:
"""No segmentations at all should return (0, H, W) tensor."""
result = convert_coco_poly_to_mask([], _H, _W)
assert result.shape == (0, _H, _W)
def test_rle_size_mismatch_behavior(self) -> None:
"""Compressed RLE with mismatched embedded size should raise a decode error."""
ref_mask = _make_reference_mask()
rle = _encode_compressed_rle(ref_mask)
rle["size"] = [50, 50]
# Observed behavior: pycocotools rejects mismatched RLE metadata during decode.
with pytest.raises(ValueError, match="Invalid RLE mask representation"):
convert_coco_poly_to_mask([rle], _H, _W)
def test_compressed_rle_bytes_counts_decode(self) -> None:
"""Compressed RLE with bytes counts should decode correctly."""
ref_mask = _make_reference_mask()
rle = mask_util.encode(np.asfortranarray(ref_mask))
rle["counts"] = rle["counts"].encode("utf-8") if isinstance(rle["counts"], str) else rle["counts"]
rle["size"] = list(rle["size"])
result = convert_coco_poly_to_mask([rle], _H, _W)
assert result.shape == (1, _H, _W)
assert result[0, 30, 50] == 1
assert result[0, 0, 0] == 0
def test_malformed_rle_counts_none_raises_value_error(self) -> None:
"""Malformed RLE with counts=None should raise ValueError."""
with pytest.raises(ValueError, match="unsupported counts type"):
convert_coco_poly_to_mask([{"counts": None, "size": [_H, _W]}], _H, _W)
class TestConvertCocoClassWithRle:
"""Tests that ``ConvertCoco`` correctly passes RLE annotations through."""
def _make_annotation(self, segmentation: object, category_id: int = 0) -> dict:
return {
"bbox": [30, 20, 40, 30],
"category_id": category_id,
"area": 1200,
"iscrowd": 0,
"segmentation": segmentation,
}
def _make_target(self, annotations: list) -> dict:
return {"image_id": 1, "annotations": annotations}
def test_rle_masks_included_in_target(self) -> None:
"""ConvertCoco with include_masks=True should handle RLE segmentations."""
ref_mask = _make_reference_mask()
rle = _encode_compressed_rle(ref_mask)
anno = self._make_annotation(rle)
converter = ConvertCoco(include_masks=True)
_, target = converter(_IMAGE, self._make_target([anno]))
assert "masks" in target
assert target["masks"].shape == (1, _H, _W)
assert target["masks"].dtype == torch.bool
assert target["masks"][0].any()
def test_polygon_masks_still_work(self) -> None:
"""ConvertCoco should still handle polygon segmentations."""
polygon = _make_polygon(_make_reference_mask())
anno = self._make_annotation(polygon)
converter = ConvertCoco(include_masks=True)
_, target = converter(_IMAGE, self._make_target([anno]))
assert "masks" in target
assert target["masks"].shape == (1, _H, _W)
assert target["masks"].dtype == torch.bool
def test_mixed_rle_and_polygon_in_same_image(self) -> None:
"""An image with both polygon and RLE annotations across instances."""
ref_mask = _make_reference_mask()
rle_anno = self._make_annotation(_encode_compressed_rle(ref_mask), category_id=0)
poly_anno = self._make_annotation(_make_polygon(ref_mask), category_id=1)
converter = ConvertCoco(include_masks=True)
_, target = converter(_IMAGE, self._make_target([rle_anno, poly_anno]))
assert target["masks"].shape == (2, _H, _W)
assert target["labels"].tolist() == [0, 1]
def test_no_masks_without_flag(self) -> None:
"""RLE annotations should not produce masks when include_masks=False."""
rle = _encode_compressed_rle(_make_reference_mask())
anno = self._make_annotation(rle)
converter = ConvertCoco(include_masks=False)
_, target = converter(_IMAGE, self._make_target([anno]))
assert "masks" not in target
class TestMalformedRle:
"""Documents _is_rle behaviour for structurally malformed inputs.
Before this PR a bare ``except:`` in the polygon path silently swallowed any pycocotools error. These tests confirm
that ``_is_rle`` is a *structural* check only (it does not validate values inside the dict) and that dicts missing
required keys are correctly classified as non-RLE so they are routed through the polygon path — where pycocotools
will either handle them or raise a descriptive error rather than silently falling back.
"""
def test_missing_size_key_is_not_rle(self) -> None:
"""Dict with 'counts' but no 'size' is not treated as RLE."""
assert _is_rle({"counts": [1, 2, 3]}) is False
def test_missing_counts_key_is_not_rle(self) -> None:
"""Dict with 'size' but no 'counts' is not treated as RLE."""
assert _is_rle({"size": [100, 100]}) is False
def test_counts_none_is_classified_as_rle(self) -> None:
"""_is_rle is a structural check: presence of both keys suffices regardless of value types."""
assert _is_rle({"counts": None, "size": [_H, _W]}) is True
def test_size_mismatch_is_still_classified_as_rle(self) -> None:
"""Dicts with both keys are RLE even when the embedded size mismatches the image dimensions."""
assert _is_rle({"counts": [1, 2], "size": [50, 50]}) is True
+435
View File
@@ -0,0 +1,435 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for private COCO keypoint schema inference helpers."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from rfdetr.datasets._keypoint_schema import (
CocoKeypointSchema,
YoloKeypointSchema,
_infer_keypoint_flip_pairs_from_names,
_merge_category_keypoint_flip_pairs,
infer_coco_keypoint_schema,
infer_yolo_keypoint_schema,
)
def _write_coco_annotations(
path: Path,
*,
categories: list[dict],
annotations: list[dict] | None = None,
) -> None:
"""Write a minimal COCO annotation file.
Args:
path: Destination JSON path.
categories: COCO category objects.
annotations: Optional COCO annotation objects.
Returns:
``None``.
Raises:
OSError: If the file cannot be written.
Example:
>>> import tempfile
>>> output = Path(tempfile.mkdtemp()) / "annotations.json"
>>> _write_coco_annotations(output, categories=[{"id": 0, "name": "person"}])
>>> output.exists()
True
"""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"images": [], "annotations": annotations or [], "categories": categories}),
encoding="utf-8",
)
def test_infer_coco_keypoint_schema_uses_declared_category_keypoints(tmp_path: Path) -> None:
"""Declared category keypoints should produce a category-aligned keypoint schema."""
annotation_path = tmp_path / "annotations.json"
_write_coco_annotations(
annotation_path,
categories=[{"id": 0, "name": "person", "keypoints": ["nose", "left_eye"], "skeleton": []}],
)
schema = infer_coco_keypoint_schema(annotation_path)
assert schema == CocoKeypointSchema(
class_names=["person"],
num_keypoints_per_class=[2],
keypoint_oks_sigmas=[0.1, 0.1],
)
def test_infer_coco_keypoint_schema_infers_left_right_flip_pairs(tmp_path: Path) -> None:
"""COCO category keypoint names should infer horizontal flip pairs when unambiguous."""
annotation_path = tmp_path / "annotations.json"
_write_coco_annotations(
annotation_path,
categories=[
{
"id": 0,
"name": "person",
"keypoints": ["nose", "left_eye", "right_eye", "left_wrist", "right_wrist"],
"skeleton": [],
}
],
)
schema = infer_coco_keypoint_schema(annotation_path)
assert schema.keypoint_flip_pairs == [1, 2, 3, 4]
def test_infer_coco_keypoint_schema_does_not_invent_missing_mirror_pairs(tmp_path: Path) -> None:
"""A left/right token without its counterpart should keep the keypoint slots unswapped."""
annotation_path = tmp_path / "annotations.json"
_write_coco_annotations(
annotation_path,
categories=[
{
"id": 0,
"name": "person",
"keypoints": ["nose", "left_eye", "left_wrist"],
"skeleton": [],
}
],
)
schema = infer_coco_keypoint_schema(annotation_path)
assert schema.num_keypoints_per_class == [3]
assert schema.keypoint_flip_pairs == []
def test_infer_coco_keypoint_schema_drops_pairs_when_keypoint_categories_disagree(tmp_path: Path) -> None:
"""A global flip-pair list is unsafe when keypoint classes use different slot layouts."""
annotation_path = tmp_path / "annotations.json"
_write_coco_annotations(
annotation_path,
categories=[
{
"id": 0,
"name": "standing_person",
"keypoints": ["left_eye", "right_eye", "nose"],
"skeleton": [],
},
{
"id": 1,
"name": "seated_person",
"keypoints": ["nose", "left_eye", "right_eye"],
"skeleton": [],
},
],
)
schema = infer_coco_keypoint_schema(annotation_path)
assert schema.num_keypoints_per_class == [3, 3]
assert schema.keypoint_flip_pairs == []
def test_infer_coco_keypoint_schema_uses_annotation_keypoints_when_category_metadata_is_missing(
tmp_path: Path,
) -> None:
"""Annotation keypoint arrays should define the count when category metadata is absent."""
annotation_path = tmp_path / "annotations.json"
_write_coco_annotations(
annotation_path,
categories=[{"id": 7, "name": "pose"}],
annotations=[{"id": 1, "image_id": 1, "category_id": 7, "keypoints": [1, 2, 2, 3, 4, 1]}],
)
schema = infer_coco_keypoint_schema(annotation_path, keypoint_oks_sigma=0.1)
assert schema.class_names == ["pose"]
assert schema.num_keypoints_per_class == [2]
assert schema.keypoint_oks_sigmas == [0.1, 0.1]
def test_infer_coco_keypoint_schema_places_detection_only_categories_in_free_slots(tmp_path: Path) -> None:
"""Detection-only categories should stay category-aligned with zero keypoint counts."""
annotation_path = tmp_path / "annotations.json"
_write_coco_annotations(
annotation_path,
categories=[
{"id": 0, "name": "person", "keypoints": ["nose", "left_eye"]},
{"id": 1, "name": "helmet"},
{"id": 2, "name": "vest"},
],
)
schema = infer_coco_keypoint_schema(annotation_path)
assert schema.class_names == ["person", "helmet", "vest"]
assert schema.num_keypoints_per_class == [2, 0, 0]
assert schema.keypoint_oks_sigmas == [0.1, 0.1]
def test_infer_coco_keypoint_schema_supports_multiple_keypoint_categories_with_same_count(tmp_path: Path) -> None:
"""Multiple keypoint classes with the same keypoint count should stay category-aligned."""
annotation_path = tmp_path / "annotations.json"
_write_coco_annotations(
annotation_path,
categories=[
{"id": 3, "name": "adult", "keypoints": ["head", "foot"]},
{"id": 9, "name": "child", "keypoints": ["head", "foot"]},
],
)
schema = infer_coco_keypoint_schema(annotation_path)
assert schema.class_names == ["adult", "child"]
assert schema.num_keypoints_per_class == [2, 2]
assert schema.keypoint_oks_sigmas == [0.1, 0.1]
def test_infer_coco_keypoint_schema_rejects_missing_keypoints(tmp_path: Path) -> None:
"""Detection-only COCO files should fail fast instead of silently training without keypoints."""
annotation_path = tmp_path / "annotations.json"
_write_coco_annotations(annotation_path, categories=[{"id": 0, "name": "person"}])
with pytest.raises(ValueError, match="has no keypoint metadata"):
infer_coco_keypoint_schema(annotation_path)
def test_infer_coco_keypoint_schema_supports_mixed_keypoint_counts(tmp_path: Path) -> None:
"""Different keypoint counts are represented per class and padded later by the dataset."""
annotation_path = tmp_path / "annotations.json"
_write_coco_annotations(
annotation_path,
categories=[
{"id": 0, "name": "person", "keypoints": ["nose"]},
{"id": 1, "name": "animal", "keypoints": ["head", "tail"]},
],
)
schema = infer_coco_keypoint_schema(annotation_path)
assert schema.class_names == ["person", "animal"]
assert schema.num_keypoints_per_class == [1, 2]
assert schema.keypoint_oks_sigmas == [0.1, 0.1]
def test_infer_coco_keypoint_schema_rejects_malformed_annotation_keypoint_length(tmp_path: Path) -> None:
"""COCO keypoint arrays must be flattened ``x, y, visibility`` triples."""
annotation_path = tmp_path / "annotations.json"
_write_coco_annotations(
annotation_path,
categories=[{"id": 0, "name": "person"}],
annotations=[{"id": 1, "image_id": 1, "category_id": 0, "keypoints": [1, 2]}],
)
with pytest.raises(ValueError, match="length divisible by 3"):
infer_coco_keypoint_schema(annotation_path)
def test_infer_coco_keypoint_schema_raises_file_not_found(tmp_path: Path) -> None:
"""Non-existent annotation file should raise FileNotFoundError before any parsing."""
missing = tmp_path / "does_not_exist.json"
with pytest.raises(FileNotFoundError):
infer_coco_keypoint_schema(missing)
def test_infer_coco_keypoint_schema_raises_key_error_for_missing_categories_key(tmp_path: Path) -> None:
"""JSON file missing the 'categories' key should raise KeyError."""
annotation_path = tmp_path / "no_categories.json"
annotation_path.write_text('{"images": [], "annotations": []}', encoding="utf-8")
with pytest.raises(KeyError):
infer_coco_keypoint_schema(annotation_path)
def test_infer_coco_keypoint_schema_raises_value_error_for_list_root(tmp_path: Path) -> None:
"""JSON file whose root is a list (not an object) should raise ValueError."""
annotation_path = tmp_path / "list_root.json"
annotation_path.write_text("[]", encoding="utf-8")
with pytest.raises(ValueError):
infer_coco_keypoint_schema(annotation_path)
@pytest.mark.parametrize(
"counts,expected",
[
pytest.param([0, 17, 25], [17, 25], id="leading-zero-filtered"),
pytest.param([0, 0], [], id="all-zero-returns-empty"),
pytest.param([5, 17], [5, 17], id="all-nonzero-returned-unchanged"),
pytest.param([], [], id="empty-input-returns-empty"),
],
)
def test_active_keypoint_counts_filters_zeros(counts: list[int], expected: list[int]) -> None:
"""active_keypoint_counts should return only positive counts in schema order."""
from rfdetr.datasets._keypoint_schema import active_keypoint_counts
result = active_keypoint_counts(counts)
assert result == expected, f"active_keypoint_counts({counts!r}) = {result!r}, expected {expected!r}"
def test_infer_yolo_keypoint_schema_reads_pose_yaml_metadata(tmp_path: Path) -> None:
"""YOLO pose YAML should define class names, keypoint count, names, and flip pairs."""
data_file = tmp_path / "data.yaml"
data_file.write_text(
"names:\n 0: person\nkpt_shape: [2, 3]\nflip_idx: [0, 1]\nkpt_names:\n 0:\n - left_eye\n - right_eye\n",
encoding="utf-8",
)
schema = infer_yolo_keypoint_schema(data_file)
assert schema == YoloKeypointSchema(
class_names=["person"],
num_keypoints_per_class=[2],
keypoint_oks_sigmas=[0.1, 0.1],
keypoint_names=["left_eye", "right_eye"],
flip_idx=[0, 1],
keypoint_dim=3,
)
def test_infer_yolo_keypoint_schema_rejects_detection_yaml(tmp_path: Path) -> None:
"""Detection-only YOLO YAML should fail fast in keypoint schema inference."""
data_file = tmp_path / "data.yaml"
data_file.write_text("names:\n - person\n", encoding="utf-8")
with pytest.raises(ValueError, match="kpt_shape"):
infer_yolo_keypoint_schema(data_file)
@pytest.mark.parametrize("kpt_shape", ["[17, 1]", "[0, 3]", "[17]", "[17, 4]"])
def test_infer_yolo_keypoint_schema_rejects_invalid_kpt_shape(tmp_path: Path, kpt_shape: str) -> None:
"""YOLO pose kpt_shape must be [positive_count, 2_or_3]."""
data_file = tmp_path / "data.yaml"
data_file.write_text(f"names:\n - person\nkpt_shape: {kpt_shape}\n", encoding="utf-8")
with pytest.raises(ValueError, match="kpt_shape"):
infer_yolo_keypoint_schema(data_file)
@pytest.mark.parametrize(
"flip_idx_text, expected_match",
[
pytest.param("[0, 5]", "permutation", id="out_of_range"),
pytest.param("[0, 0]", "permutation", id="duplicate"),
pytest.param("[0]", "integer indexes", id="wrong_length"),
],
)
def test_infer_yolo_keypoint_schema_rejects_invalid_flip_idx(
tmp_path: Path, flip_idx_text: str, expected_match: str
) -> None:
"""flip_idx must be a valid permutation of 0..N-1 matching kpt_shape count."""
data_file = tmp_path / "data.yaml"
data_file.write_text(
f"names:\n 0: person\nkpt_shape: [2, 3]\nflip_idx: {flip_idx_text}\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match=expected_match):
infer_yolo_keypoint_schema(data_file)
# ---------------------------------------------------------------------------
# _infer_keypoint_flip_pairs_from_names edge cases
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"names,expected",
[
pytest.param([], [], id="empty-input"),
pytest.param(["left_eye"], [], id="single-directional-no-mirror"),
pytest.param(["Left-Eye", "left_eye"], [], id="duplicate-normalized-names"),
pytest.param(["left_right_wrist"], [], id="two-directional-tokens-ambiguous"),
pytest.param(["nose", "left_eye", "right_eye"], [1, 2], id="standard-coco-pair"),
],
)
def test_infer_keypoint_flip_pairs_from_names_edge_cases(names: list[str], expected: list[int]) -> None:
"""Edge-case inputs should return the expected flat pair list without raising."""
assert _infer_keypoint_flip_pairs_from_names(names) == expected
# ---------------------------------------------------------------------------
# _merge_category_keypoint_flip_pairs success path
# ---------------------------------------------------------------------------
def test_merge_category_keypoint_flip_pairs_returns_common_pairs_when_all_agree() -> None:
"""All-agreeing categories should return the shared pair list."""
assert _merge_category_keypoint_flip_pairs([[1, 2], [1, 2], [1, 2]]) == [1, 2]
def test_merge_category_keypoint_flip_pairs_single_category() -> None:
"""Single-category input should return that category's pairs unchanged."""
assert _merge_category_keypoint_flip_pairs([[3, 5, 1, 2]]) == [3, 5, 1, 2]
# ---------------------------------------------------------------------------
# YoloKeypointSchema.keypoint_flip_pairs
# ---------------------------------------------------------------------------
def test_infer_yolo_keypoint_schema_populates_keypoint_flip_pairs(tmp_path: Path) -> None:
"""YOLO flip_idx should produce an equivalent keypoint_flip_pairs list on the schema."""
data_file = tmp_path / "data.yaml"
data_file.write_text(
"names:\n 0: person\nkpt_shape: [3, 3]\nflip_idx: [0, 2, 1]\n",
encoding="utf-8",
)
schema = infer_yolo_keypoint_schema(data_file)
assert schema.flip_idx == [0, 2, 1]
assert schema.keypoint_flip_pairs == [1, 2]
def test_infer_yolo_keypoint_schema_empty_flip_idx_gives_empty_pairs(tmp_path: Path) -> None:
"""Missing flip_idx should result in an empty keypoint_flip_pairs list."""
data_file = tmp_path / "data.yaml"
data_file.write_text(
"names:\n 0: person\nkpt_shape: [2, 3]\n",
encoding="utf-8",
)
schema = infer_yolo_keypoint_schema(data_file)
assert schema.flip_idx == []
assert schema.keypoint_flip_pairs == []
# ---------------------------------------------------------------------------
# Public re-export from rfdetr.datasets
# ---------------------------------------------------------------------------
def test_infer_coco_keypoint_schema_importable_from_datasets_package(tmp_path: Path) -> None:
"""infer_coco_keypoint_schema should be importable from the public rfdetr.datasets package."""
from rfdetr.datasets import infer_coco_keypoint_schema as public_fn
annotation_path = tmp_path / "annotations.json"
_write_coco_annotations(
annotation_path,
categories=[{"id": 0, "name": "person", "keypoints": ["nose", "left_eye", "right_eye"], "skeleton": []}],
)
schema = public_fn(annotation_path)
assert schema.keypoint_flip_pairs == [1, 2]
def test_infer_yolo_keypoint_schema_importable_from_datasets_package(tmp_path: Path) -> None:
"""infer_yolo_keypoint_schema should be importable from the public rfdetr.datasets package."""
from rfdetr.datasets import infer_yolo_keypoint_schema as public_fn
data_file = tmp_path / "data.yaml"
data_file.write_text("names:\n 0: person\nkpt_shape: [1, 3]\n", encoding="utf-8")
schema = public_fn(data_file)
assert schema.num_keypoints_per_class == [1]
+732
View File
@@ -0,0 +1,732 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for Kornia GPU augmentation pipeline builder and bbox utilities.
All tests in this module are CPU-compatible — Kornia operates on CPU tensors identically to GPU tensors, so no
``@pytest.mark.gpu`` is needed.
"""
import pytest
import torch
from rfdetr.datasets.aug_configs import (
AUG_AERIAL,
AUG_AGGRESSIVE,
AUG_CONSERVATIVE,
AUG_INDUSTRIAL,
)
# ---------------------------------------------------------------------------
# TestBuildKorniaPipeline — validates the factory that translates aug_config
# dicts into a Kornia AugmentationSequential pipeline.
# ---------------------------------------------------------------------------
class TestBuildKorniaPipeline:
"""build_kornia_pipeline returns a valid pipeline for every preset and rejects unknown transform keys with a clear
error."""
@pytest.fixture(autouse=True)
def _require_kornia(self):
pytest.importorskip("kornia")
@pytest.mark.parametrize(
"config,config_name",
[
pytest.param(AUG_CONSERVATIVE, "AUG_CONSERVATIVE", id="conservative"),
pytest.param(AUG_AGGRESSIVE, "AUG_AGGRESSIVE", id="aggressive"),
pytest.param(AUG_AERIAL, "AUG_AERIAL", id="aerial"),
pytest.param(AUG_INDUSTRIAL, "AUG_INDUSTRIAL", id="industrial"),
],
)
def test_each_preset_config(self, config, config_name):
"""Each named preset builds a pipeline without errors."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
pipeline = build_kornia_pipeline(config, 560)
assert pipeline is not None, f"build_kornia_pipeline({config_name}, 560) must return a non-None pipeline"
def test_unknown_key_raises_value_error(self):
"""An unrecognised transform key raises ValueError immediately."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
with pytest.raises(ValueError, match="FooBarTransform"):
build_kornia_pipeline({"FooBarTransform": {"p": 0.5}}, 560)
def test_empty_config_returns_pipeline(self):
"""An empty config dict returns a valid (no-op) pipeline, not None."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
pipeline = build_kornia_pipeline({}, 560)
assert pipeline is not None, "Empty config must still return a pipeline object"
def test_known_plus_unknown_raises(self):
"""Mixing a valid key with an unknown key still raises ValueError."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
mixed = {"HorizontalFlip": {"p": 0.5}, "BogusTransform": {"p": 0.3}}
with pytest.raises(ValueError, match="BogusTransform"):
build_kornia_pipeline(mixed, 560)
def test_hflip_disabled_for_keypoint_pipeline(self):
"""Keypoint-mode Kornia augmentation drops hflip transforms with a warning."""
from unittest import mock
from rfdetr.datasets import kornia_transforms
config = {"HorizontalFlip": {"p": 0.5}, "VerticalFlip": {"p": 0.5}}
mock_warning = mock.patch.object(kornia_transforms.logger, "warning")
with mock_warning as warning:
pipeline = kornia_transforms.build_kornia_pipeline(config, 560, include_keypoints=True)
transform_names = [child.__class__.__name__ for child in pipeline.children()]
assert "RandomHorizontalFlip" not in transform_names
assert "RandomVerticalFlip" in transform_names
assert warning.called
assert "HorizontalFlip" in str(warning.call_args)
# ---------------------------------------------------------------------------
# TestCollateBoxes — validates packing of variable-length per-image boxes
# into a zero-padded [B, N_max, 4] tensor with a boolean validity mask.
# ---------------------------------------------------------------------------
class TestCollateBoxes:
"""collate_boxes packs variable-length boxes into [B, N_max, 4] with mask."""
@pytest.fixture(autouse=True)
def _require_kornia(self):
pytest.importorskip("kornia")
def _make_targets(self, box_counts):
"""Build a list of target dicts with the given per-image box counts.
Each box is a valid xyxy rectangle within a 100x100 image.
"""
targets = []
for n in box_counts:
boxes = (
torch.tensor([[10.0, 10.0, 50.0, 50.0]] * n, dtype=torch.float32)
if n > 0
else torch.zeros(0, 4, dtype=torch.float32)
)
targets.append({"boxes": boxes})
return targets
def test_normal_batch(self):
"""Batch of 2 images: output shape is [2, N_max, 4] with valid mask [2, N_max]."""
from rfdetr.datasets.kornia_transforms import collate_boxes
targets = self._make_targets([2, 3])
boxes_padded, valid = collate_boxes(targets, torch.device("cpu"))
assert boxes_padded.shape == (2, 3, 4), f"Expected shape (2, 3, 4), got {boxes_padded.shape}"
assert valid.shape == (2, 3), f"Expected valid shape (2, 3), got {valid.shape}"
assert valid.dtype == torch.bool
def test_b_zero(self):
"""Empty target list produces shape [0, 0, 4] and valid [0, 0]."""
from rfdetr.datasets.kornia_transforms import collate_boxes
boxes_padded, valid = collate_boxes([], torch.device("cpu"))
assert boxes_padded.shape == (0, 0, 4), f"Expected (0, 0, 4) for empty batch, got {boxes_padded.shape}"
assert valid.shape == (0, 0), f"Expected valid (0, 0) for empty batch, got {valid.shape}"
def test_n_zero_per_image(self):
"""One image with 0 boxes: shape [1, 0, 4], valid all-False."""
from rfdetr.datasets.kornia_transforms import collate_boxes
targets = self._make_targets([0])
boxes_padded, valid = collate_boxes(targets, torch.device("cpu"))
assert boxes_padded.shape == (1, 0, 4), f"Expected (1, 0, 4), got {boxes_padded.shape}"
assert valid.shape == (1, 0), f"Expected (1, 0), got {valid.shape}"
def test_single_image(self):
"""B=1 with 3 boxes: output shape is [1, 3, 4]."""
from rfdetr.datasets.kornia_transforms import collate_boxes
targets = self._make_targets([3])
boxes_padded, valid = collate_boxes(targets, torch.device("cpu"))
assert boxes_padded.shape == (1, 3, 4)
assert valid.shape == (1, 3)
def test_valid_mask_matches_box_count(self):
"""The valid mask has True for real boxes and False for padding."""
from rfdetr.datasets.kornia_transforms import collate_boxes
targets = self._make_targets([1, 3])
_, valid = collate_boxes(targets, torch.device("cpu"))
# Image 0: 1 real box, 2 padding → [True, False, False]
assert valid[0].tolist() == [True, False, False], f"Image 0 valid mask wrong: {valid[0].tolist()}"
# Image 1: 3 real boxes, 0 padding → [True, True, True]
assert valid[1].tolist() == [True, True, True], f"Image 1 valid mask wrong: {valid[1].tolist()}"
# ---------------------------------------------------------------------------
# TestUnpackBoxes — validates the inverse: writing augmented boxes back into
# per-image target dicts with clamping, zero-area removal, and label sync.
# ---------------------------------------------------------------------------
class TestUnpackBoxes:
"""unpack_boxes writes augmented boxes back and removes zero-area entries."""
@pytest.fixture(autouse=True)
def _require_kornia(self):
pytest.importorskip("kornia")
def _make_inputs(
self,
boxes_aug,
valid_mask,
original_targets,
image_height=100,
image_width=100,
):
"""Return tensors suitable for unpack_boxes."""
boxes_tensor = torch.tensor(boxes_aug, dtype=torch.float32)
valid_tensor = torch.tensor(valid_mask, dtype=torch.bool)
return boxes_tensor, valid_tensor, original_targets, image_height, image_width
def test_all_boxes_removed_after_aug(self):
"""When all augmented boxes are zero-area, output targets have empty boxes."""
from rfdetr.datasets.kornia_transforms import unpack_boxes
# B=1, N=2: both boxes are zero-area (x1==x2 or y1==y2)
boxes_aug = [[[10.0, 10.0, 10.0, 10.0], [20.0, 20.0, 20.0, 20.0]]]
valid = [[True, True]]
targets = [
{
"boxes": torch.tensor([[10.0, 10.0, 50.0, 50.0], [20.0, 20.0, 60.0, 60.0]]),
"labels": torch.tensor([1, 2]),
"area": torch.tensor([1600.0, 1600.0]),
"iscrowd": torch.tensor([0, 0]),
}
]
boxes_t, valid_t, tgts, image_height, image_width = self._make_inputs(boxes_aug, valid, targets)
result = unpack_boxes(boxes_t, valid_t, tgts, image_height, image_width)
assert result[0]["boxes"].shape[0] == 0, (
f"Expected 0 boxes after zero-area removal, got {result[0]['boxes'].shape[0]}"
)
assert result[0]["labels"].shape[0] == 0
def test_partial_removal(self):
"""Some boxes survive, some removed; labels/area/iscrowd synced."""
from rfdetr.datasets.kornia_transforms import unpack_boxes
# Box 0: valid, non-zero area; Box 1: zero-area
boxes_aug = [[[10.0, 10.0, 50.0, 50.0], [30.0, 30.0, 30.0, 30.0]]]
valid = [[True, True]]
targets = [
{
"boxes": torch.tensor([[10.0, 10.0, 50.0, 50.0], [30.0, 30.0, 70.0, 70.0]]),
"labels": torch.tensor([1, 2]),
"area": torch.tensor([1600.0, 1600.0]),
"iscrowd": torch.tensor([0, 1]),
}
]
boxes_t, valid_t, tgts, image_height, image_width = self._make_inputs(boxes_aug, valid, targets)
result = unpack_boxes(boxes_t, valid_t, tgts, image_height, image_width)
assert result[0]["boxes"].shape[0] == 1, f"Expected 1 surviving box, got {result[0]['boxes'].shape[0]}"
assert result[0]["labels"].tolist() == [1]
def test_labels_area_iscrowd_sync(self):
"""When boxes are removed, labels/area/iscrowd entries are also removed."""
from rfdetr.datasets.kornia_transforms import unpack_boxes
# Box 0: zero-area (removed), Box 1: valid
boxes_aug = [[[5.0, 5.0, 5.0, 5.0], [10.0, 10.0, 40.0, 40.0]]]
valid = [[True, True]]
targets = [
{
"boxes": torch.tensor([[5.0, 5.0, 30.0, 30.0], [10.0, 10.0, 40.0, 40.0]]),
"labels": torch.tensor([7, 9]),
"area": torch.tensor([625.0, 900.0]),
"iscrowd": torch.tensor([0, 1]),
}
]
boxes_t, valid_t, tgts, image_height, image_width = self._make_inputs(boxes_aug, valid, targets)
result = unpack_boxes(boxes_t, valid_t, tgts, image_height, image_width)
assert result[0]["labels"].tolist() == [9], (
f"Expected label [9] after removal of box 0, got {result[0]['labels'].tolist()}"
)
assert result[0]["area"].shape[0] == 1
assert result[0]["iscrowd"].tolist() == [1]
def test_boxes_clamped_to_image_bounds(self):
"""Boxes outside [0,W]x[0,H] are clamped to image bounds."""
from rfdetr.datasets.kornia_transforms import unpack_boxes
# Box extends beyond 100x100 image
boxes_aug = [[[-10.0, -5.0, 120.0, 110.0]]]
valid = [[True]]
targets = [
{
"boxes": torch.tensor([[0.0, 0.0, 90.0, 90.0]]),
"labels": torch.tensor([1]),
"area": torch.tensor([8100.0]),
"iscrowd": torch.tensor([0]),
}
]
image_height, image_width = 100, 100
boxes_t, valid_t, tgts, image_height, image_width = self._make_inputs(
boxes_aug,
valid,
targets,
image_height,
image_width,
)
result = unpack_boxes(boxes_t, valid_t, tgts, image_height, image_width)
result_boxes = result[0]["boxes"]
assert result_boxes.shape[0] == 1, "Clamped box should survive (non-zero area)"
# Verify clamping: x1>=0, y1>=0, x2<=W, y2<=H
assert result_boxes[0, 0].item() >= 0.0, "x1 not clamped to >= 0"
assert result_boxes[0, 1].item() >= 0.0, "y1 not clamped to >= 0"
assert result_boxes[0, 2].item() <= image_width, f"x2 not clamped to <= {image_width}"
assert result_boxes[0, 3].item() <= image_height, f"y2 not clamped to <= {image_height}"
# ---------------------------------------------------------------------------
# TestRotateFactory — validates the Rotate parameter translation from
# Albumentations-style limit (scalar or tuple) to Kornia RandomRotation.
# ---------------------------------------------------------------------------
class TestRotateFactory:
"""Rotate factory translates limit (scalar or tuple) to K.RandomRotation(degrees=...)."""
@pytest.fixture(autouse=True)
def _require_kornia(self):
pytest.importorskip("kornia")
def test_limit_as_scalar(self):
"""Rotate(limit=45) produces K.RandomRotation(degrees=(-45, 45))."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
# Build a pipeline with just Rotate(limit=45)
pipeline = build_kornia_pipeline({"Rotate": {"limit": 45, "p": 1.0}}, 560)
assert pipeline is not None
# Inspect the pipeline's children to find the RandomRotation and check degrees
import kornia.augmentation as kornia_augmentation
rotation_augs = [
child for child in pipeline.children() if isinstance(child, kornia_augmentation.RandomRotation)
]
assert len(rotation_augs) == 1, f"Expected exactly 1 RandomRotation, found {len(rotation_augs)}"
degrees = rotation_augs[0].flags["degrees"]
# degrees should be a tensor representing (-45, 45)
assert float(degrees[0]) == pytest.approx(-45.0, abs=0.1)
assert float(degrees[1]) == pytest.approx(45.0, abs=0.1)
def test_limit_as_tuple(self):
"""Rotate(limit=(90, 90)) produces K.RandomRotation(degrees=(90, 90))."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
pipeline = build_kornia_pipeline({"Rotate": {"limit": (90, 90), "p": 1.0}}, 560)
assert pipeline is not None
import kornia.augmentation as kornia_augmentation
rotation_augs = [
child for child in pipeline.children() if isinstance(child, kornia_augmentation.RandomRotation)
]
assert len(rotation_augs) == 1
degrees = rotation_augs[0].flags["degrees"]
assert float(degrees[0]) == pytest.approx(90.0, abs=0.1)
assert float(degrees[1]) == pytest.approx(90.0, abs=0.1)
def test_flags_include_degrees(self):
"""Rotate factory keeps a legacy degrees entry in Kornia flags for compatibility."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
pipeline = build_kornia_pipeline({"Rotate": {"limit": 30, "p": 1.0}}, 560)
assert pipeline is not None
import kornia.augmentation as kornia_augmentation
rotation_augs = [
child for child in pipeline.children() if isinstance(child, kornia_augmentation.RandomRotation)
]
assert len(rotation_augs) == 1
assert "degrees" in rotation_augs[0].flags
assert rotation_augs[0].flags["degrees"] == (-30, 30)
# ---------------------------------------------------------------------------
# TestGpuPostprocessFlag — validates that make_coco_transforms respects the
# gpu_postprocess flag to omit augmentation and normalization from CPU path.
# ---------------------------------------------------------------------------
class TestGpuPostprocessFlag:
"""gpu_postprocess flag controls whether aug + normalize appear in CPU pipeline."""
def test_gpu_postprocess_true_omits_aug_and_normalize_from_train(self):
"""gpu_postprocess=True: train pipeline has no Normalize; fewer AlbumentationsWrappers (no aug_wrappers)."""
from rfdetr.datasets.coco import make_coco_transforms
from rfdetr.datasets.transforms import AlbumentationsWrapper, Normalize
pipeline_gpu = make_coco_transforms("train", 560, gpu_postprocess=True)
pipeline_cpu = make_coco_transforms("train", 560, gpu_postprocess=False)
steps_gpu = pipeline_gpu.transforms
steps_cpu = pipeline_cpu.transforms
normalize_gpu = [s for s in steps_gpu if isinstance(s, Normalize)]
assert len(normalize_gpu) == 0, "gpu_postprocess=True must omit Normalize from train pipeline"
# Resize wrappers (AlbumentationsWrapper) remain; aug wrappers are removed.
# Default AUG_CONFIG adds 1 aug wrapper, so gpu version must have fewer wrappers.
n_alb_gpu = sum(isinstance(s, AlbumentationsWrapper) for s in steps_gpu)
n_alb_cpu = sum(isinstance(s, AlbumentationsWrapper) for s in steps_cpu)
assert n_alb_gpu < n_alb_cpu, "gpu_postprocess=True must remove aug AlbumentationsWrappers from train pipeline"
def test_gpu_postprocess_false_includes_aug_and_normalize_from_train(self):
"""gpu_postprocess=False (default): train pipeline includes Normalize."""
from rfdetr.datasets.coco import make_coco_transforms
from rfdetr.datasets.transforms import Normalize
pipeline = make_coco_transforms("train", 560, gpu_postprocess=False)
steps = pipeline.transforms
normalize_steps = [s for s in steps if isinstance(s, Normalize)]
assert len(normalize_steps) > 0, "gpu_postprocess=False must include Normalize in train pipeline"
def test_val_path_unaffected_by_gpu_postprocess(self):
"""Val pipeline is unchanged regardless of gpu_postprocess value."""
from rfdetr.datasets.coco import make_coco_transforms
from rfdetr.datasets.transforms import Normalize
pipeline_default = make_coco_transforms("val", 560, gpu_postprocess=False)
pipeline_gpu = make_coco_transforms("val", 560, gpu_postprocess=True)
# Both should have Normalize (val is never stripped)
norm_default = [s for s in pipeline_default.transforms if isinstance(s, Normalize)]
norm_gpu = [s for s in pipeline_gpu.transforms if isinstance(s, Normalize)]
assert len(norm_default) > 0, "Val pipeline (default) must include Normalize"
assert len(norm_gpu) > 0, "Val pipeline (gpu_postprocess=True) must include Normalize"
# Same number of pipeline steps
assert len(pipeline_default.transforms) == len(pipeline_gpu.transforms), (
"Val pipeline step count must be identical regardless of gpu_postprocess"
)
# ---------------------------------------------------------------------------
# TestGaussianBlurMinKernel — validates that blur_limit < 3 is clamped so
# Kornia never receives an invalid kernel_size < 3.
# ---------------------------------------------------------------------------
class TestGaussianBlurMinKernel:
"""_make_gaussian_blur enforces kernel_size >= 3 regardless of blur_limit."""
@pytest.fixture(autouse=True)
def _require_kornia(self):
pytest.importorskip("kornia")
@pytest.mark.parametrize(
"blur_limit",
[pytest.param(1, id="blur_limit_1"), pytest.param(2, id="blur_limit_2")],
)
def test_small_blur_limit_produces_valid_kernel(self, blur_limit):
"""blur_limit below 3 must be clamped so the resulting kernel_size >= 3."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
# Should not raise; previously blur_limit=1 produced kernel_size=(3,1)
pipeline = build_kornia_pipeline({"GaussianBlur": {"blur_limit": blur_limit, "p": 1.0}}, 560)
assert pipeline is not None
import kornia.augmentation as kornia_augmentation
blur_augs = [c for c in pipeline.children() if isinstance(c, kornia_augmentation.RandomGaussianBlur)]
assert len(blur_augs) == 1
ks = blur_augs[0].flags["kernel_size"]
assert int(ks[0]) >= 3, f"kernel_size[0]={int(ks[0])} must be >= 3"
assert int(ks[1]) >= 3, f"kernel_size[1]={int(ks[1])} must be >= 3"
def test_blur_limit_3_unchanged(self):
"""blur_limit=3 (default) passes through without modification."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
pipeline = build_kornia_pipeline({"GaussianBlur": {"blur_limit": 3, "p": 1.0}}, 560)
import kornia.augmentation as kornia_augmentation
blur_augs = [c for c in pipeline.children() if isinstance(c, kornia_augmentation.RandomGaussianBlur)]
ks = blur_augs[0].flags["kernel_size"]
assert int(ks[0]) == 3
assert int(ks[1]) == 3
# ---------------------------------------------------------------------------
# TestKorniaPipelineForwardPass — validates that a built pipeline produces
# output of the correct shape and dtype on CPU tensors.
# ---------------------------------------------------------------------------
class TestKorniaPipelineForwardPass:
"""build_kornia_pipeline output passes through without shape/dtype errors."""
@pytest.fixture(autouse=True)
def _require_kornia(self):
pytest.importorskip("kornia")
def test_forward_pass_shape_and_dtype(self):
"""Pipeline output images have same shape as input; boxes shape is [B, N, 4]."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
pipeline = build_kornia_pipeline({"HorizontalFlip": {"p": 1.0}}, resolution=64)
batch_size, channels, image_height, image_width = 2, 3, 64, 64
img = torch.rand(batch_size, channels, image_height, image_width)
boxes = torch.tensor([[[0.0, 0.0, 32.0, 32.0]], [[10.0, 10.0, 50.0, 50.0]]], dtype=torch.float32)
img_out, boxes_out = pipeline(img, boxes)
assert img_out.shape == (batch_size, channels, image_height, image_width), (
f"Image shape changed: {img_out.shape}"
)
assert img_out.dtype == torch.float32
assert boxes_out.shape == (batch_size, 1, 4), f"Boxes shape wrong: {boxes_out.shape}"
def test_forward_pass_empty_boxes(self):
"""Pipeline handles a batch where N_max=0 (no boxes) without error."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
pipeline = build_kornia_pipeline({"HorizontalFlip": {"p": 1.0}}, resolution=32)
batch_size, channels, image_height, image_width = 2, 3, 32, 32
img = torch.rand(batch_size, channels, image_height, image_width)
# [B, 0, 4] — no boxes
boxes = torch.zeros(batch_size, 0, 4, dtype=torch.float32)
img_out, boxes_out = pipeline(img, boxes)
assert img_out.shape == (batch_size, channels, image_height, image_width)
assert boxes_out.shape == (batch_size, 0, 4)
# ---------------------------------------------------------------------------
# TestCollateMasks — validates packing of variable-length per-image masks
# into a zero-padded [B, N_max, H, W] float32 tensor.
# ---------------------------------------------------------------------------
class TestCollateMasks:
"""collate_masks packs [N_i, H, W] instance masks into [B, N_max, H, W]."""
def _make_targets_with_masks(self, mask_counts, h=16, w=16):
"""Build target dicts with boolean mask tensors for given instance counts."""
targets = []
for n in mask_counts:
masks = torch.ones(n, h, w, dtype=torch.bool) if n > 0 else torch.zeros(0, h, w, dtype=torch.bool)
targets.append({"masks": masks, "boxes": torch.zeros(n, 4)})
return targets
def test_normal_batch(self):
"""Batch of [2 masks, 3 masks] → shape [2, 3, H, W] float32."""
from rfdetr.datasets.kornia_transforms import collate_masks
targets = self._make_targets_with_masks([2, 3])
masks_padded = collate_masks(targets, torch.device("cpu"), n_max=3, image_height=16, image_width=16)
assert masks_padded.shape == (2, 3, 16, 16), f"Expected (2, 3, 16, 16), got {masks_padded.shape}"
assert masks_padded.dtype == torch.float32, f"Expected float32, got {masks_padded.dtype}"
def test_padding_is_zero(self):
"""Padded slots (beyond real instance count) are filled with zeros."""
from rfdetr.datasets.kornia_transforms import collate_masks
targets = self._make_targets_with_masks([1, 3]) # image 0 padded to 3
masks_padded = collate_masks(targets, torch.device("cpu"), n_max=3, image_height=16, image_width=16)
# Image 0: slot 0 real (ones), slots 1-2 zero-padded
assert masks_padded[0, 0].min() == pytest.approx(1.0), "Real mask slot must be all ones"
assert masks_padded[0, 1].max() == pytest.approx(0.0), "Padded slot 1 must be all zeros"
assert masks_padded[0, 2].max() == pytest.approx(0.0), "Padded slot 2 must be all zeros"
def test_n_max_zero_returns_empty(self):
"""n_max=0 → shape [B, 0, H, W]."""
from rfdetr.datasets.kornia_transforms import collate_masks
targets = self._make_targets_with_masks([0, 0])
masks_padded = collate_masks(targets, torch.device("cpu"), n_max=0, image_height=16, image_width=16)
assert masks_padded.shape == (2, 0, 16, 16), f"Expected (2, 0, 16, 16), got {masks_padded.shape}"
def test_empty_target_list(self):
"""Empty target list → shape [0, 0, H, W]."""
from rfdetr.datasets.kornia_transforms import collate_masks
masks_padded = collate_masks([], torch.device("cpu"), n_max=0, image_height=16, image_width=16)
assert masks_padded.shape == (0, 0, 16, 16), f"Expected (0, 0, 16, 16), got {masks_padded.shape}"
def test_targets_without_masks_key(self):
"""Targets without 'masks' key produce all-zero rows."""
from rfdetr.datasets.kornia_transforms import collate_masks
targets = [{"boxes": torch.zeros(2, 4)}, {"boxes": torch.zeros(1, 4)}]
masks_padded = collate_masks(targets, torch.device("cpu"), n_max=2, image_height=8, image_width=8)
assert masks_padded.shape == (2, 2, 8, 8)
assert masks_padded.max() == pytest.approx(0.0), "Targets without masks key must produce all-zero output"
# ---------------------------------------------------------------------------
# TestBuildKorniaPipelineWithMasks — validates that with_masks=True produces
# a pipeline with mask data_key included.
# ---------------------------------------------------------------------------
class TestBuildKorniaPipelineWithMasks:
"""build_kornia_pipeline(with_masks=True) includes mask in data_keys."""
@pytest.fixture(autouse=True)
def _require_kornia(self):
"""Skip when Kornia is unavailable (optional extra not installed in CPU CI)."""
pytest.importorskip("kornia")
def test_with_masks_false_is_default(self):
"""with_masks defaults to False; pipeline returns (img, boxes) on call."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
pipeline = build_kornia_pipeline({"HorizontalFlip": {"p": 1.0}}, resolution=32)
img = torch.rand(1, 3, 32, 32)
boxes = torch.tensor([[[0.0, 0.0, 16.0, 16.0]]])
result = pipeline(img, boxes)
assert len(result) == 2, f"Detection pipeline must return 2 values, got {len(result)}"
def test_with_masks_true_returns_three_values(self):
"""with_masks=True: pipeline(img, boxes, masks) returns (img, boxes, masks)."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
pipeline = build_kornia_pipeline({"HorizontalFlip": {"p": 1.0}}, resolution=32, with_masks=True)
img = torch.rand(1, 3, 32, 32)
boxes = torch.tensor([[[0.0, 0.0, 16.0, 16.0]]])
masks = torch.ones(1, 1, 32, 32, dtype=torch.float32)
result = pipeline(img, boxes, masks)
assert len(result) == 3, f"Segmentation pipeline must return 3 values, got {len(result)}"
def test_with_masks_true_preserves_mask_shape(self):
"""Mask shape [B, N, H, W] is preserved after pipeline pass."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
pipeline = build_kornia_pipeline({"HorizontalFlip": {"p": 0.0}}, resolution=32, with_masks=True)
img = torch.rand(2, 3, 32, 32)
boxes = torch.tensor([[[0.0, 0.0, 16.0, 16.0]], [[8.0, 8.0, 24.0, 24.0]]])
masks = torch.ones(2, 1, 32, 32, dtype=torch.float32)
_, _, masks_aug = pipeline(img, boxes, masks)
assert masks_aug.shape == (2, 1, 32, 32), f"Mask shape must be preserved: {masks_aug.shape}"
# ---------------------------------------------------------------------------
# TestUnpackBoxesWithMasks — validates that unpack_boxes propagates the same
# keep filter to masks when masks_aug is provided.
# ---------------------------------------------------------------------------
class TestUnpackBoxesWithMasks:
"""unpack_boxes with masks_aug keeps/removes masks in sync with boxes."""
def test_masks_filtered_same_as_boxes(self):
"""Box removed → corresponding mask also removed from output."""
from rfdetr.datasets.kornia_transforms import unpack_boxes
# B=1, N=2: box 0 valid, box 1 zero-area (will be removed)
boxes_aug = torch.tensor([[[5.0, 5.0, 25.0, 25.0], [30.0, 30.0, 30.0, 30.0]]])
valid = torch.tensor([[True, True]])
targets = [
{
"boxes": torch.tensor([[5.0, 5.0, 25.0, 25.0], [30.0, 30.0, 60.0, 60.0]]),
"labels": torch.tensor([1, 2]),
}
]
# 2 masks: instance 0 = all ones, instance 1 = all twos (distinguishable)
masks_aug = torch.zeros(1, 2, 8, 8, dtype=torch.float32)
masks_aug[0, 0] = 1.0
masks_aug[0, 1] = 1.0 # will be removed with box 1
result = unpack_boxes(boxes_aug, valid, targets, 100, 100, masks_aug=masks_aug)
assert "masks" in result[0], "masks key must be present in output target"
assert result[0]["masks"].shape[0] == 1, f"Expected 1 surviving mask, got {result[0]['masks'].shape[0]}"
def test_masks_converted_to_bool(self):
"""Float masks > 0.5 threshold converted to bool in output."""
from rfdetr.datasets.kornia_transforms import unpack_boxes
boxes_aug = torch.tensor([[[5.0, 5.0, 25.0, 25.0]]])
valid = torch.tensor([[True]])
targets = [{"boxes": torch.tensor([[5.0, 5.0, 25.0, 25.0]]), "labels": torch.tensor([1])}]
masks_aug = torch.full((1, 1, 8, 8), 0.8, dtype=torch.float32) # float, all 0.8
result = unpack_boxes(boxes_aug, valid, targets, 100, 100, masks_aug=masks_aug)
assert result[0]["masks"].dtype == torch.bool, f"masks must be bool, got {result[0]['masks'].dtype}"
assert result[0]["masks"].all(), "All values > 0.5 should be True after thresholding"
def test_no_masks_aug_leaves_masks_key_unchanged(self):
"""When masks_aug=None, existing masks key in target is preserved as-is."""
from rfdetr.datasets.kornia_transforms import unpack_boxes
boxes_aug = torch.tensor([[[5.0, 5.0, 25.0, 25.0]]])
valid = torch.tensor([[True]])
original_mask = torch.ones(1, 8, 8, dtype=torch.bool)
targets = [
{
"boxes": torch.tensor([[5.0, 5.0, 25.0, 25.0]]),
"labels": torch.tensor([1]),
"masks": original_mask,
}
]
result = unpack_boxes(boxes_aug, valid, targets, 100, 100, masks_aug=None)
assert "masks" in result[0], "masks key must still be present when masks_aug=None"
assert result[0]["masks"] is original_mask, "Original masks object must be preserved unchanged"
class TestGaussNoiseStdRangeWarning:
"""_make_gauss_noise warns when the configured std range is non-degenerate (GPU uses a fixed upper-bound std)."""
@pytest.fixture(autouse=True)
def _require_kornia(self):
pytest.importorskip("kornia")
def test_warns_for_unequal_std_range(self):
"""A non-degenerate std_range emits a divergence warning at build time."""
from unittest import mock
from rfdetr.datasets import kornia_transforms
with mock.patch.object(kornia_transforms.logger, "warning") as mock_warning:
kornia_transforms._make_gauss_noise({"std_range": (0.01, 0.05), "p": 0.5})
mock_warning.assert_called_once()
def test_no_warning_for_degenerate_std_range(self):
"""An equal-bound std_range matches the CPU path exactly and stays silent."""
from unittest import mock
from rfdetr.datasets import kornia_transforms
with mock.patch.object(kornia_transforms.logger, "warning") as mock_warning:
kornia_transforms._make_gauss_noise({"std_range": (0.05, 0.05), "p": 0.5})
mock_warning.assert_not_called()
+20
View File
@@ -0,0 +1,20 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for the Object365 dataset module."""
import PIL.Image
def test_o365_import_keeps_finite_decompression_bomb_guard() -> None:
"""Importing ``o365`` must not disable PIL's decompression-bomb guard process-wide."""
from rfdetr.datasets import o365 # noqa: F401
assert PIL.Image.MAX_IMAGE_PIXELS is not None, (
"o365 must set a finite MAX_IMAGE_PIXELS cap, not None (which disables the guard globally)"
)
assert PIL.Image.MAX_IMAGE_PIXELS >= 178_956_970, (
"the cap must stay above PIL's default so legitimate large O365 images still load"
)
+62
View File
@@ -0,0 +1,62 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for DatasetGridSaver — verifies that annotated grid images are written without OpenCV layout errors across all
supported OpenCV versions."""
from pathlib import Path
import numpy as np
import torch
from PIL import Image
from torch.utils.data import DataLoader
class _FakeDataset:
"""Minimal dataset returning a single synthetic image + target."""
def __init__(self, num_samples: int = 4) -> None:
self.num_samples = num_samples
def __len__(self) -> int:
return self.num_samples
def __getitem__(self, idx):
# CHW float tensor in ImageNet-normalised range
image = torch.zeros(3, 224, 224)
target = {
"size": torch.tensor([224, 224]),
"boxes": torch.tensor([[0.25, 0.25, 0.5, 0.5], [0.6, 0.6, 0.2, 0.2]]),
"labels": torch.tensor([0, 1]),
}
return image, target
def _collate(batch):
from rfdetr.utilities import nested_tensor_from_tensor_list
images, targets = zip(*batch)
# NestedTensor expected by DatasetGridSaver
nested = nested_tensor_from_tensor_list(list(images))
return nested, list(targets)
def test_save_grid_writes_files(tmp_path: Path) -> None:
"""DatasetGridSaver must write JPEG grid files without raising OpenCV errors."""
from rfdetr.datasets.save_grids import DatasetGridSaver
dataset = _FakeDataset(num_samples=4)
loader = DataLoader(dataset, batch_size=2, collate_fn=_collate)
saver = DatasetGridSaver(loader, tmp_path, max_batches=2, dataset_type="train")
saver.save_grid()
grids = list(tmp_path.glob("train_batch*_grid.jpg"))
assert len(grids) == 2, f"Expected 2 grid files, got {len(grids)}"
for grid_path in grids:
with Image.open(grid_path) as pil_img:
img = np.array(pil_img)
assert img.ndim == 3
assert img.shape[2] == 3
+601
View File
@@ -0,0 +1,601 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
import json
import numpy as np
import pytest
import supervision as sv
from rfdetr.datasets.synthetic import (
DEFAULT_SPLIT_RATIOS,
SYNTHETIC_SHAPES,
DatasetSplitRatios,
_calculate_polygon_area,
_write_coco_json,
calculate_boundary_overlap,
draw_synthetic_shape,
generate_coco_dataset,
generate_synthetic_sample,
)
class TestCalculateBoundaryOverlap:
@pytest.mark.parametrize(
"bbox,expected_overlap",
[
pytest.param(np.array([40.0, 40.0, 60.0, 60.0]), 0.0, id="fully_inside"),
pytest.param(np.array([-10.0, 40.0, 10.0, 60.0]), 0.5, id="half_outside_horizontally"),
pytest.param(np.array([110.0, 40.0, 130.0, 60.0]), 1.0, id="fully_outside"),
pytest.param(np.array([0.0, 0.0, 50.0, 50.0]), 0.0, id="exactly_at_boundary"),
pytest.param(np.array([50.0, 50.0, 100.0, 100.0]), 0.0, id="exactly_at_max_boundary"),
],
)
def test_overlap_values(self, bbox, expected_overlap):
result = calculate_boundary_overlap(bbox, img_size=100)
assert result == pytest.approx(expected_overlap)
class TestDrawSyntheticShape:
@pytest.mark.parametrize(
"shape,color",
[
pytest.param("square", sv.Color.RED, id="square_red"),
pytest.param("triangle", sv.Color.GREEN, id="triangle_green"),
pytest.param("circle", sv.Color.BLUE, id="circle_blue"),
],
)
def test_pixels_are_modified(self, shape, color):
img = np.zeros((100, 100, 3), dtype=np.uint8)
img_modified, polygon = draw_synthetic_shape(img.copy(), shape, color, (50, 50), 20)
assert not np.array_equal(img, img_modified)
assert len(polygon) >= 6
assert len(polygon) % 2 == 0
@pytest.mark.parametrize(
"shape,cx,cy,size",
[
pytest.param("square", 50, 50, 20, id="square"),
pytest.param("triangle", 50, 50, 20, id="triangle"),
pytest.param("circle", 50, 50, 20, id="circle"),
],
)
def test_polygon_min_points(self, shape, cx, cy, size):
"""Returned polygon must have at least 3 points (6 values) for COCO."""
img = np.zeros((100, 100, 3), dtype=np.uint8)
_, poly = draw_synthetic_shape(img, shape, sv.Color.WHITE, (cx, cy), size)
assert len(poly) >= 6, f"{shape} polygon has fewer than 6 values: {poly}"
assert len(poly) % 2 == 0, f"{shape} polygon has an odd number of values: {poly}"
@pytest.mark.parametrize(
"shape,cx,cy,size,expected_n_coords",
[
pytest.param("square", 50, 50, 20, 8, id="square_4pts"),
pytest.param("triangle", 50, 50, 20, 6, id="triangle_3pts"),
pytest.param("circle", 50, 50, 20, 64, id="circle_32pts"),
],
)
def test_polygon_coord_count(self, shape, cx, cy, size, expected_n_coords):
"""Each shape must return the expected number of flat coordinate values."""
img = np.zeros((100, 100, 3), dtype=np.uint8)
_, poly = draw_synthetic_shape(img, shape, sv.Color.WHITE, (cx, cy), size)
assert len(poly) == expected_n_coords
def test_square_polygon_matches_bbox(self):
"""Square polygon corners must align with the drawn rectangle bounds."""
cx, cy, size = 60, 40, 30
img = np.zeros((100, 100, 3), dtype=np.uint8)
_, poly = draw_synthetic_shape(img, "square", sv.Color.WHITE, (cx, cy), size)
hs = size // 2
expected = [
float(cx - hs),
float(cy - hs),
float(cx - hs + size),
float(cy - hs),
float(cx - hs + size),
float(cy - hs + size),
float(cx - hs),
float(cy - hs + size),
]
assert poly == pytest.approx(expected)
def test_unknown_shape_returns_empty_polygon(self):
"""An unrecognised shape name must return an empty polygon without crashing."""
img = np.zeros((100, 100, 3), dtype=np.uint8)
_, poly = draw_synthetic_shape(img, "hexagon", sv.Color.WHITE, (50, 50), 20)
assert poly == []
class TestGenerateSyntheticSample:
@pytest.mark.parametrize(
"img_size,min_objects,max_objects,class_mode",
[
pytest.param(100, 1, 3, "shape", id="small_shape_mode"),
pytest.param(200, 2, 5, "color", id="medium_color_mode"),
pytest.param(100, 1, 1, "shape", id="single_object"),
pytest.param(100, 0, 0, "shape", id="zero_objects"),
],
)
def test_output_shape_and_detection_count(self, img_size, min_objects, max_objects, class_mode):
img, detections = generate_synthetic_sample(
img_size=img_size, min_objects=min_objects, max_objects=max_objects, class_mode=class_mode
)
assert img.shape == (img_size, img_size, 3)
assert min_objects <= len(detections) <= max_objects
assert hasattr(detections, "xyxy")
assert hasattr(detections, "class_id")
def test_polygon_data_present(self):
"""detections.data must contain a 'polygons' array with one entry per detection."""
_, detections = generate_synthetic_sample(img_size=100, min_objects=2, max_objects=4, class_mode="shape")
assert "polygons" in detections.data
assert len(detections.data["polygons"]) == len(detections)
def test_polygon_data_non_empty(self):
"""Each stored polygon must be a non-empty list of floats."""
_, detections = generate_synthetic_sample(img_size=100, min_objects=1, max_objects=3, class_mode="shape")
for poly in detections.data["polygons"]:
assert isinstance(poly, list)
assert len(poly) >= 6
def test_zero_objects_polygon_data(self):
"""With zero objects the polygon data array must be present but empty."""
_, detections = generate_synthetic_sample(img_size=100, min_objects=0, max_objects=0, class_mode="shape")
assert "polygons" in detections.data
assert len(detections.data["polygons"]) == 0
def test_polygon_bbox_consistency(self):
"""detections.xyxy must match the min/max of the corresponding polygon."""
_, detections = generate_synthetic_sample(img_size=200, min_objects=3, max_objects=5, class_mode="shape")
for i in range(len(detections)):
poly = detections.data["polygons"][i]
poly_array = np.asarray(poly, dtype=float).reshape(-1, 2)
expected_x_min = float(np.min(poly_array[:, 0]))
expected_y_min = float(np.min(poly_array[:, 1]))
expected_x_max = float(np.max(poly_array[:, 0]))
expected_y_max = float(np.max(poly_array[:, 1]))
x_min, y_min, x_max, y_max = detections.xyxy[i]
assert x_min == pytest.approx(expected_x_min), f"detection {i} x_min mismatch"
assert y_min == pytest.approx(expected_y_min), f"detection {i} y_min mismatch"
assert x_max == pytest.approx(expected_x_max), f"detection {i} x_max mismatch"
assert y_max == pytest.approx(expected_y_max), f"detection {i} y_max mismatch"
class TestGenerateCocoDataset:
@pytest.mark.parametrize(
"num_images,img_size,class_mode,split_ratios,expected_splits",
[
# Test with dictionary (legacy support)
pytest.param(
5,
100,
"shape",
{"train": 0.6, "val": 0.2, "test": 0.2},
["train", "val", "test"],
id="shape_mode_all_splits_dict",
),
pytest.param(
3,
64,
"color",
{"train": 0.5, "val": 0.5},
["train", "val"],
id="color_mode_two_splits_dict",
),
pytest.param(
2,
128,
"shape",
{"train": 1.0},
["train"],
id="single_split_only_dict",
),
# Test with DatasetSplitRatios dataclass
pytest.param(
4,
100,
"shape",
DatasetSplitRatios(train=0.7, val=0.2, test=0.1),
["train", "val", "test"],
id="split_ratios_dataclass",
),
pytest.param(
3,
64,
"color",
DatasetSplitRatios(train=0.8, val=0.2, test=0.0),
["train", "val"],
id="split_ratios_no_test",
),
# Test with tuple
pytest.param(
4,
100,
"shape",
(0.7, 0.2, 0.1),
["train", "val", "test"],
id="split_ratios_tuple_three",
),
pytest.param(
3,
64,
"color",
(0.8, 0.2),
["train", "val"],
id="split_ratios_tuple_two",
),
# Test with default
pytest.param(
10,
64,
"shape",
DEFAULT_SPLIT_RATIOS,
["train", "val", "test"],
id="split_ratios_default",
),
],
)
def test_splits_created(self, num_images, img_size, class_mode, split_ratios, expected_splits, tmp_path):
output_dir = tmp_path / "test_dataset"
generate_coco_dataset(
output_dir=str(output_dir),
num_images=num_images,
img_size=img_size,
class_mode=class_mode,
split_ratios=split_ratios,
)
assert output_dir.exists()
for split in expected_splits:
split_dir = output_dir / split
assert split_dir.exists()
assert (split_dir / "_annotations.coco.json").exists()
with open(split_dir / "_annotations.coco.json") as f:
data = json.load(f)
assert "images" in data
assert "annotations" in data
assert "categories" in data
for img_info in data["images"]:
assert (split_dir / img_info["file_name"]).exists()
@pytest.mark.parametrize(
"num_images,split_ratios",
[
pytest.param(10, (0.33, 0.33, 0.34), id="truncating_ratios"),
pytest.param(7, (0.7, 0.2, 0.1), id="standard_ratios"),
pytest.param(5, (0.8, 0.2), id="two_split"),
],
)
def test_split_image_count_equals_total(self, num_images, split_ratios, tmp_path):
"""Total images assigned across all splits must equal num_images."""
output_dir = tmp_path / "test_dataset"
generate_coco_dataset(
output_dir=str(output_dir),
num_images=num_images,
img_size=64,
class_mode="shape",
split_ratios=split_ratios,
)
total_images = 0
for split_dir in output_dir.iterdir():
ann_file = split_dir / "_annotations.coco.json"
if ann_file.exists():
with open(ann_file) as fh:
total_images += len(json.load(fh)["images"])
assert total_images == num_images
@pytest.mark.parametrize(
"split_ratios,error_message",
[
pytest.param(
(1.1, -0.1),
"Split ratios must be non-negative",
id="tuple_negative_ratio",
),
pytest.param(
{"train": 1.1, "val": -0.1},
"Split ratios must be non-negative",
id="dict_negative_ratio",
),
pytest.param(
(0.5, 0.3),
"Split ratios must sum to 1.0",
id="tuple_invalid_sum",
),
],
)
def test_invalid_split_ratios(self, split_ratios, error_message, tmp_path):
output_dir = tmp_path / "test_dataset"
with pytest.raises(ValueError, match=error_message):
generate_coco_dataset(
output_dir=str(output_dir),
num_images=5,
img_size=100,
class_mode="shape",
split_ratios=split_ratios,
)
class TestGenerateCocoDatasetWithSegmentation:
def test_write_coco_json_raises_when_polygons_key_missing(self, tmp_path):
"""with_segmentation=True must raise if detections.data has no 'polygons' key."""
annotations_path = tmp_path / "_annotations.coco.json"
detections = sv.Detections(
xyxy=np.array([[0.0, 0.0, 10.0, 10.0]], dtype=float),
class_id=np.array([0], dtype=int),
data={}, # intentionally no "polygons" key
)
with pytest.raises(ValueError, match="no 'polygons' found"):
_write_coco_json(
annotations_path=annotations_path,
classes=["shape"],
file_paths=["/tmp/synthetic.png"],
detections_list=[detections],
img_size=64,
with_segmentation=True,
)
def test_write_coco_json_raises_for_mismatched_inputs(self, tmp_path):
"""Mismatched file/detection list lengths must raise to avoid silent truncation."""
annotations_path = tmp_path / "_annotations.coco.json"
detections = sv.Detections(
xyxy=np.empty((0, 4), dtype=float),
class_id=np.empty((0,), dtype=int),
data={"polygons": np.empty(0, dtype=object)},
)
with pytest.raises(ValueError, match="file_paths and detections_list must have the same length"):
_write_coco_json(
annotations_path=annotations_path,
classes=["shape"],
file_paths=["/tmp/a.png", "/tmp/b.png"],
detections_list=[detections],
img_size=64,
)
def test_creates_files(self, tmp_path):
"""with_segmentation=True must create the same directory/file structure as the default."""
output_dir = tmp_path / "seg_dataset"
generate_coco_dataset(
output_dir=str(output_dir),
num_images=4,
img_size=64,
class_mode="shape",
split_ratios={"train": 0.75, "val": 0.25},
with_segmentation=True,
)
for split in ("train", "val"):
assert (output_dir / split / "_annotations.coco.json").exists()
def test_json_structure(self, tmp_path):
"""COCO JSON produced with segmentation must have the required top-level keys."""
output_dir = tmp_path / "seg_dataset"
generate_coco_dataset(
output_dir=str(output_dir),
num_images=4,
img_size=64,
class_mode="shape",
split_ratios={"train": 1.0},
with_segmentation=True,
)
with open(output_dir / "train" / "_annotations.coco.json") as fh:
data = json.load(fh)
assert "images" in data
assert "annotations" in data
assert "categories" in data
def test_has_polygon_field(self, tmp_path):
"""Every annotation must have a non-empty segmentation polygon."""
output_dir = tmp_path / "seg_dataset"
generate_coco_dataset(
output_dir=str(output_dir),
num_images=3,
img_size=64,
class_mode="shape",
min_objects=1,
max_objects=2,
split_ratios={"train": 1.0},
with_segmentation=True,
)
with open(output_dir / "train" / "_annotations.coco.json") as fh:
data = json.load(fh)
assert len(data["annotations"]) > 0, "Expected at least one annotation"
for ann in data["annotations"]:
assert "segmentation" in ann
assert isinstance(ann["segmentation"], list)
assert len(ann["segmentation"]) == 1, "Expected exactly one polygon per annotation"
assert len(ann["segmentation"][0]) >= 6, "Polygon must have at least 3 points"
def test_area_uses_polygon_when_segmentation_enabled(self, tmp_path):
"""COCO area must match polygon area when segmentation annotations are present."""
annotations_path = tmp_path / "_annotations.coco.json"
polygon_data = np.empty(1, dtype=object)
polygon_data[0] = [0.0, 0.0, 10.0, 0.0, 0.0, 10.0] # Right triangle area = 50
detections = sv.Detections(
xyxy=np.array([[0.0, 0.0, 10.0, 10.0]], dtype=float),
class_id=np.array([0], dtype=int),
data={"polygons": polygon_data},
)
_write_coco_json(
annotations_path=annotations_path,
classes=["shape"],
file_paths=["/tmp/synthetic.png"],
detections_list=[detections],
img_size=64,
with_segmentation=True,
)
with open(annotations_path) as fh:
data = json.load(fh)
assert len(data["annotations"]) == 1
assert data["annotations"][0]["area"] == pytest.approx(50.0)
def test_sparse_category_ids(self, tmp_path):
"""Category IDs must use sparse 1-based encoding (1, 3, 5, …)."""
output_dir = tmp_path / "seg_dataset"
generate_coco_dataset(
output_dir=str(output_dir),
num_images=4,
img_size=64,
class_mode="shape",
split_ratios={"train": 1.0},
with_segmentation=True,
)
with open(output_dir / "train" / "_annotations.coco.json") as fh:
data = json.load(fh)
cat_ids = {c["id"] for c in data["categories"]}
expected_ids = {idx * 2 + 1 for idx in range(len(SYNTHETIC_SHAPES))}
assert cat_ids == expected_ids
ann_cat_ids = {a["category_id"] for a in data["annotations"]}
assert ann_cat_ids.issubset(expected_ids)
def test_images_exist(self, tmp_path):
"""All images referenced in the JSON must exist on disk."""
output_dir = tmp_path / "seg_dataset"
generate_coco_dataset(
output_dir=str(output_dir),
num_images=3,
img_size=64,
class_mode="shape",
split_ratios={"train": 1.0},
with_segmentation=True,
)
split_dir = output_dir / "train"
with open(split_dir / "_annotations.coco.json") as fh:
data = json.load(fh)
for img_info in data["images"]:
assert (split_dir / img_info["file_name"]).exists()
def test_empty_polygon_falls_back_to_empty_segmentation(self, tmp_path):
"""An empty polygon entry silently falls back to ``segmentation=[]``.
The ``len(polygon_data) < len(detections)`` guard only checks array length, not contents. An element that is an
empty list passes the guard and takes the ``else`` branch producing ``segmentation=[]``. This test documents the
existing silent-fallback behaviour.
"""
annotations_path = tmp_path / "_annotations.coco.json"
polygon_data = np.empty(1, dtype=object)
polygon_data[0] = [] # empty polygon — passes length guard
detections = sv.Detections(
xyxy=np.array([[0.0, 0.0, 10.0, 10.0]], dtype=float),
class_id=np.array([0], dtype=int),
data={"polygons": polygon_data},
)
_write_coco_json(
annotations_path=annotations_path,
classes=["shape"],
file_paths=["/tmp/synthetic.png"],
detections_list=[detections],
img_size=64,
with_segmentation=True,
)
with open(annotations_path) as fh:
data = json.load(fh)
assert data["annotations"][0]["segmentation"] == []
class TestCalculatePolygonArea:
@pytest.mark.parametrize(
"polygon,expected_area",
[
pytest.param(
[0.0, 0.0, 10.0, 0.0, 0.0, 10.0],
50.0,
id="right_triangle",
),
pytest.param(
[0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0],
100.0,
id="unit_square_10x10",
),
pytest.param(
[0.0, 0.0, 5.0, 0.0, 10.0, 0.0],
0.0,
id="collinear_points_degenerate",
),
pytest.param(
[0.0, 0.0, 1.0, 1.0],
0.0,
id="fewer_than_3_points",
),
pytest.param(
[],
0.0,
id="empty_polygon",
),
],
)
def test_area(self, polygon, expected_area):
assert _calculate_polygon_area(polygon) == pytest.approx(expected_area)
class TestDrawSyntheticShapeEdgeCases:
def test_square_polygon_respects_half_size_and_image_bounds_for_odd_size(self):
"""For odd sizes, the square polygon should:
* Have all vertices within the image bounds.
* Be horizontally contained within ``cx ± size / 2``.
"""
cx, cy, size = 50, 50, 21
img = np.zeros((100, 100, 3), dtype=np.uint8)
_, poly = draw_synthetic_shape(img, "square", sv.Color.WHITE, (cx, cy), size)
half_size = size / 2.0
xs = [poly[i] for i in range(0, len(poly), 2)]
ys = [poly[i] for i in range(1, len(poly), 2)]
# All vertices must be inside the image
assert min(xs) >= 0.0
assert max(xs) <= float(img.shape[1])
assert min(ys) >= 0.0
assert max(ys) <= float(img.shape[0])
# Horizontal extent should not exceed the intended half-size around cx
assert min(xs) >= cx - half_size - 1.0
assert max(xs) <= cx + half_size + 1.0
def test_triangle_vertices_within_half_size_and_image_bounds(self):
"""Triangle vertices should:
* Have all vertices within the image bounds.
* Be vertically contained within ``cy ± size / 2`` so the apex does not
extend beyond the intended half-size boundary.
"""
cx, cy, size = 50, 50, 20
img = np.zeros((100, 100, 3), dtype=np.uint8)
_, poly = draw_synthetic_shape(img, "triangle", sv.Color.WHITE, (cx, cy), size)
half_size = size / 2.0
xs = [poly[i] for i in range(0, len(poly), 2)]
ys = [poly[i] for i in range(1, len(poly), 2)]
# All vertices must be inside the image
assert min(xs) >= 0.0
assert max(xs) <= float(img.shape[1])
assert min(ys) >= 0.0
assert max(ys) <= float(img.shape[0])
# Vertical extent should not exceed the intended half-size around cy
assert min(ys) >= cy - half_size - 1.0
assert max(ys) <= cy + half_size + 1.0
@pytest.mark.parametrize(
"shape,size,expected_n_coords",
[
pytest.param("square", 0, 8, id="square_size_0"),
pytest.param("square", 1, 8, id="square_size_1"),
pytest.param("circle", 0, 64, id="circle_size_0"),
pytest.param("circle", 1, 64, id="circle_size_1"),
],
)
def test_degenerate_size_returns_polygon_without_crashing(self, shape, size, expected_n_coords):
"""draw_synthetic_shape with size=0 or size=1 must not raise and must return the expected number of flat
coordinate values."""
img = np.zeros((100, 100, 3), dtype=np.uint8)
_, poly = draw_synthetic_shape(img, shape, sv.Color.WHITE, (50, 50), size)
assert len(poly) == expected_n_coords
File diff suppressed because it is too large Load Diff