16031aae96
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
436 lines
16 KiB
Python
436 lines
16 KiB
Python
# ------------------------------------------------------------------------
|
|
# 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]
|