chore: import upstream snapshot with attribution
CPU tests Workflow / Testing (ubuntu-latest, 3.12) (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.13) (push) Failing after 0s
Mypy Type Check / Type Check (push) Failing after 0s
Docs/Test WorkFlow / Test docs build (push) Failing after 1s
PR Conflict Labeler / labeling (push) Failing after 1s
Dependency resolution / Resolve [tflite] extra — Python 3.12 (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.10) (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.13) (push) Failing after 1s
CPU tests Workflow / build-pkg (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.10) (push) Failing after 0s
CPU tests Workflow / Testing (ubuntu-latest, 3.11) (push) Failing after 0s
Smoke Tests / try-all-models (macos-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (macos-latest, 3.13) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / testing-guardian (push) Has been cancelled
GPU tests Workflow / Testing (push) Has been cancelled
CPU tests Workflow / Testing (ubuntu-latest, 3.12) (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.13) (push) Failing after 0s
Mypy Type Check / Type Check (push) Failing after 0s
Docs/Test WorkFlow / Test docs build (push) Failing after 1s
PR Conflict Labeler / labeling (push) Failing after 1s
Dependency resolution / Resolve [tflite] extra — Python 3.12 (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.10) (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.13) (push) Failing after 1s
CPU tests Workflow / build-pkg (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.10) (push) Failing after 0s
CPU tests Workflow / Testing (ubuntu-latest, 3.11) (push) Failing after 0s
Smoke Tests / try-all-models (macos-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (macos-latest, 3.13) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / testing-guardian (push) Has been cancelled
GPU tests Workflow / Testing (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# ------------------------------------------------------------------------
|
||||
# RF-DETR
|
||||
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
||||
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -0,0 +1,73 @@
|
||||
# ------------------------------------------------------------------------
|
||||
# RF-DETR
|
||||
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
||||
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
import torch
|
||||
|
||||
from rfdetr.utilities.box_ops import box_iou, generalized_box_iou, masks_to_boxes
|
||||
|
||||
|
||||
def test_box_iou_zero_area_boxes_are_finite() -> None:
|
||||
"""Zero-area boxes yield finite IoU/union instead of a 0/0 NaN."""
|
||||
zero_box = torch.tensor([[10.0, 10.0, 10.0, 10.0]]) # w = h = 0
|
||||
|
||||
iou, union = box_iou(zero_box, zero_box)
|
||||
|
||||
assert torch.isfinite(iou).all()
|
||||
assert torch.isfinite(union).all()
|
||||
|
||||
|
||||
def test_generalized_box_iou_zero_area_boxes_are_finite() -> None:
|
||||
"""Degenerate zero-area boxes give finite GIoU instead of NaN/inf."""
|
||||
zero_box = torch.tensor([[10.0, 10.0, 10.0, 10.0]]) # w = h = 0
|
||||
|
||||
giou = generalized_box_iou(zero_box, zero_box)
|
||||
|
||||
assert torch.isfinite(giou).all()
|
||||
|
||||
|
||||
def test_masks_to_boxes_passes_ij_indexing_to_meshgrid(monkeypatch) -> None:
|
||||
"""`masks_to_boxes` should call `torch.meshgrid` with explicit ij indexing."""
|
||||
original_meshgrid = torch.meshgrid
|
||||
call_count = 0
|
||||
|
||||
def _meshgrid_with_indexing_assertion(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if kwargs.get("indexing") != "ij":
|
||||
raise AssertionError("torch.meshgrid must be called with indexing='ij'")
|
||||
return original_meshgrid(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(torch, "meshgrid", _meshgrid_with_indexing_assertion)
|
||||
|
||||
masks = torch.zeros((1, 2, 3), dtype=torch.bool)
|
||||
masks[0, 0, 1] = True
|
||||
masks[0, 1, 2] = True
|
||||
|
||||
boxes = masks_to_boxes(masks)
|
||||
|
||||
assert call_count == 1
|
||||
assert boxes.shape == (1, 4)
|
||||
|
||||
|
||||
def test_masks_to_boxes_builds_grid_on_masks_device(monkeypatch) -> None:
|
||||
"""`masks_to_boxes` should construct arange tensors on the same device as masks."""
|
||||
original_arange = torch.arange
|
||||
observed_devices = []
|
||||
|
||||
def _arange_with_device_capture(*args, **kwargs):
|
||||
observed_devices.append(kwargs.get("device"))
|
||||
return original_arange(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(torch, "arange", _arange_with_device_capture)
|
||||
|
||||
masks = torch.zeros((1, 2, 3), dtype=torch.bool)
|
||||
masks[0, 1, 2] = True
|
||||
|
||||
boxes = masks_to_boxes(masks)
|
||||
|
||||
assert boxes.shape == (1, 4)
|
||||
assert observed_devices
|
||||
assert all(device == masks.device for device in observed_devices)
|
||||
@@ -0,0 +1,245 @@
|
||||
# ------------------------------------------------------------------------
|
||||
# RF-DETR
|
||||
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
||||
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
||||
# ------------------------------------------------------------------------
|
||||
"""Tests for rfdetr.utilities.console — Rich console helpers for callbacks."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from rfdetr.utilities.console import (
|
||||
_IS_RICH_AVAILABLE,
|
||||
_build_summary_renderable,
|
||||
_get_rich_console,
|
||||
_has_progress_bar,
|
||||
_render_overall_merged,
|
||||
_render_summary_tables,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_trainer(callbacks: list[object] | None = None) -> MagicMock:
|
||||
"""Return a minimal mock Trainer."""
|
||||
trainer = MagicMock(name="trainer")
|
||||
trainer.callbacks = callbacks or []
|
||||
return trainer
|
||||
|
||||
|
||||
def _minimal_overall(max_dets: int = 500) -> dict:
|
||||
"""Return an overall dict with the minimal keys _render_overall_merged expects."""
|
||||
return {
|
||||
"mAP 50:95": 0.4,
|
||||
"mAP 50": 0.6,
|
||||
"mAP 75": 0.3,
|
||||
f"mAR @{max_dets}": 0.5,
|
||||
"F1": 0.55,
|
||||
"Precision": 0.6,
|
||||
"Recall": 0.5,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _render_overall_merged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRenderOverallMerged:
|
||||
"""_render_overall_merged renders a multi-line ASCII table string."""
|
||||
|
||||
def test_returns_string(self) -> None:
|
||||
"""Result is a non-empty multi-line string."""
|
||||
result = _render_overall_merged("Val", _minimal_overall(), 500)
|
||||
assert isinstance(result, str)
|
||||
assert "\n" in result
|
||||
|
||||
def test_title_prefix_in_output(self) -> None:
|
||||
"""Title prefix appears in the rendered string."""
|
||||
result = _render_overall_merged("Test", _minimal_overall(), 500)
|
||||
assert "Test" in result
|
||||
|
||||
def test_metric_values_in_output(self) -> None:
|
||||
"""Formatted metric values appear in the output."""
|
||||
result = _render_overall_merged("Val", _minimal_overall(500), 500)
|
||||
assert "0.4000" in result
|
||||
|
||||
def test_nan_renders_as_em_dash(self) -> None:
|
||||
"""NaN values render as '—' (em-dash)."""
|
||||
overall = _minimal_overall()
|
||||
overall["mAP 50:95"] = float("nan")
|
||||
result = _render_overall_merged("Val", overall, 500)
|
||||
assert "—" in result
|
||||
|
||||
def test_negative_sentinel_renders_as_em_dash(self) -> None:
|
||||
"""Pycocotools sentinel -1 renders as '—'."""
|
||||
overall = _minimal_overall()
|
||||
overall["mAP 50"] = -1.0
|
||||
result = _render_overall_merged("Val", overall, 500)
|
||||
assert "—" in result
|
||||
|
||||
def test_segm_group_present_when_key_exists(self) -> None:
|
||||
"""Segm mAP group rendered when segm keys present."""
|
||||
overall = _minimal_overall()
|
||||
overall["segm mAP 50:95"] = 0.3
|
||||
overall["segm mAP 50"] = 0.5
|
||||
result = _render_overall_merged("Val", overall, 500)
|
||||
assert "segm mAP" in result
|
||||
|
||||
def test_segm_group_absent_when_key_missing(self) -> None:
|
||||
"""Segm mAP group not rendered when keys absent."""
|
||||
result = _render_overall_merged("Val", _minimal_overall(), 500)
|
||||
assert "segm mAP" not in result
|
||||
|
||||
def test_mar_label_uses_max_dets(self) -> None:
|
||||
"""MAR column label contains the max_dets value."""
|
||||
result = _render_overall_merged("Val", _minimal_overall(100), 100)
|
||||
assert "@100" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _has_progress_bar
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHasProgressBar:
|
||||
"""_has_progress_bar detects any callback whose class name ends with ProgressBar."""
|
||||
|
||||
def test_returns_false_with_no_callbacks(self) -> None:
|
||||
"""Returns False when trainer has no callbacks."""
|
||||
assert not _has_progress_bar(_make_trainer())
|
||||
|
||||
def test_returns_true_for_tqdm_progress_bar(self) -> None:
|
||||
"""Returns True when a TQDMProgressBar callback present."""
|
||||
tqdm_bar_cls = type("TQDMProgressBar", (), {})
|
||||
trainer = _make_trainer(callbacks=[tqdm_bar_cls()])
|
||||
assert _has_progress_bar(trainer)
|
||||
|
||||
def test_returns_true_for_rich_progress_bar(self) -> None:
|
||||
"""Returns True when a RichProgressBar callback present."""
|
||||
rich_bar_cls = type("RichProgressBar", (), {})
|
||||
trainer = _make_trainer(callbacks=[rich_bar_cls()])
|
||||
assert _has_progress_bar(trainer)
|
||||
|
||||
def test_returns_false_for_non_progress_bar_callback(self) -> None:
|
||||
"""Returns False when callbacks don't end with ProgressBar."""
|
||||
trainer = _make_trainer(callbacks=[MagicMock(name="SomeOtherCallback")])
|
||||
assert not _has_progress_bar(trainer)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_rich_console
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _IS_RICH_AVAILABLE, reason="Rich not installed")
|
||||
class TestGetRichConsole:
|
||||
"""_get_rich_console returns the PTL RichProgressBar console or a fresh one."""
|
||||
|
||||
def test_returns_fresh_console_with_no_callbacks(self) -> None:
|
||||
"""Returns a Console instance when no RichProgressBar present."""
|
||||
from rich.console import Console
|
||||
|
||||
result = _get_rich_console(_make_trainer())
|
||||
assert isinstance(result, Console)
|
||||
|
||||
def test_returns_rich_progress_bar_console_when_active(self) -> None:
|
||||
"""Returns _console from RichProgressBar when callback present and _console set."""
|
||||
rich_bar_cls = type("RichProgressBar", (), {})
|
||||
expected_console = MagicMock(name="expected_console")
|
||||
cb = rich_bar_cls()
|
||||
cb._console = expected_console # type: ignore[attr-defined]
|
||||
trainer = _make_trainer(callbacks=[cb])
|
||||
|
||||
result = _get_rich_console(trainer)
|
||||
|
||||
assert result is expected_console
|
||||
|
||||
def test_falls_back_when_console_attribute_is_none(self) -> None:
|
||||
"""Falls back to fresh Console when _console is None (outside active stage)."""
|
||||
from rich.console import Console
|
||||
|
||||
rich_bar_cls = type("RichProgressBar", (), {})
|
||||
cb = rich_bar_cls()
|
||||
cb._console = None # type: ignore[attr-defined]
|
||||
trainer = _make_trainer(callbacks=[cb])
|
||||
|
||||
result = _get_rich_console(trainer)
|
||||
|
||||
assert isinstance(result, Console)
|
||||
|
||||
def test_mro_subclass_detected(self) -> None:
|
||||
"""Subclass of RichProgressBar is detected via MRO name check."""
|
||||
rich_bar_cls = type("RichProgressBar", (), {})
|
||||
themed_bar_cls = type("ThemedProgressBar", (rich_bar_cls,), {})
|
||||
expected_console = MagicMock(name="expected_console")
|
||||
cb = themed_bar_cls()
|
||||
cb._console = expected_console # type: ignore[attr-defined]
|
||||
trainer = _make_trainer(callbacks=[cb])
|
||||
|
||||
result = _get_rich_console(trainer)
|
||||
|
||||
assert result is expected_console
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_summary_renderable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _IS_RICH_AVAILABLE, reason="Rich not installed")
|
||||
class TestBuildSummaryRenderable:
|
||||
"""_build_summary_renderable returns a Rich Group for console.print()."""
|
||||
|
||||
def test_returns_group_without_per_class(self) -> None:
|
||||
"""Returns a Group renderable when per_class is empty."""
|
||||
from rich.console import Group
|
||||
|
||||
result = _build_summary_renderable("Val", "overall-text", [])
|
||||
assert isinstance(result, Group)
|
||||
|
||||
def test_returns_group_with_per_class(self) -> None:
|
||||
"""Returns a Group renderable when per_class rows present."""
|
||||
from rich.console import Group
|
||||
|
||||
per_class = [{"name": "cat", "ap": 0.5, "ar": 0.6, "f1": 0.55, "precision": 0.6, "recall": 0.5}]
|
||||
result = _build_summary_renderable("Val", "overall-text", per_class)
|
||||
assert isinstance(result, Group)
|
||||
|
||||
def test_nan_per_class_renders_as_em_dash(self) -> None:
|
||||
"""NaN in per-class metric renders without error."""
|
||||
from rich.console import Console, Group
|
||||
|
||||
per_class = [{"name": "cat", "ap": float("nan"), "ar": -1.0, "f1": 0.0, "precision": 0.0, "recall": 0.0}]
|
||||
result = _build_summary_renderable("Val", "overall-text", per_class)
|
||||
assert isinstance(result, Group)
|
||||
console = Console(force_terminal=True)
|
||||
with console.capture() as capture:
|
||||
console.print(result)
|
||||
assert "—" in capture.get()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _render_summary_tables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRenderSummaryTables:
|
||||
"""_render_summary_tables delegates to console.print when Rich available."""
|
||||
|
||||
@pytest.mark.skipif(not _IS_RICH_AVAILABLE, reason="Rich not installed")
|
||||
def test_calls_console_print_once(self) -> None:
|
||||
"""console.print called exactly once with the Group renderable."""
|
||||
console = MagicMock(name="console")
|
||||
_render_summary_tables(console, "Val", "overall-text", [])
|
||||
console.print.assert_called_once()
|
||||
|
||||
def test_no_op_when_rich_unavailable(self) -> None:
|
||||
"""No call made when Rich not installed."""
|
||||
console = MagicMock(name="console")
|
||||
with patch("rfdetr.utilities.console._IS_RICH_AVAILABLE", False):
|
||||
_render_summary_tables(console, "Val", "overall-text", [])
|
||||
console.print.assert_not_called()
|
||||
@@ -0,0 +1,27 @@
|
||||
# ------------------------------------------------------------------------
|
||||
# RF-DETR
|
||||
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
||||
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
||||
# ------------------------------------------------------------------------
|
||||
"""Tests for distributed utility helpers."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from rfdetr.utilities.distributed import all_gather
|
||||
|
||||
|
||||
def test_all_gather_supports_cpu_without_tensor_truthiness_error() -> None:
|
||||
"""all_gather should work on CPU-only setups and return gathered objects."""
|
||||
|
||||
def _fake_all_gather(output_tensors, input_tensor) -> None:
|
||||
for out in output_tensors:
|
||||
out.copy_(input_tensor)
|
||||
|
||||
with (
|
||||
patch("rfdetr.utilities.distributed.get_world_size", return_value=2),
|
||||
patch("rfdetr.utilities.distributed.dist.all_gather", side_effect=_fake_all_gather),
|
||||
patch("rfdetr.utilities.distributed.torch.cuda.is_available", return_value=False),
|
||||
):
|
||||
result = all_gather({"value": 7})
|
||||
|
||||
assert result == [{"value": 7}, {"value": 7}]
|
||||
@@ -0,0 +1,284 @@
|
||||
# ------------------------------------------------------------------------
|
||||
# RF-DETR
|
||||
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
||||
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Iterator, Literal, Optional
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from rfdetr.utilities.files import _compute_file_md5, _download_file, _validate_file_md5
|
||||
|
||||
|
||||
class _DummyTqdm:
|
||||
"""Minimal tqdm stand-in for download tests.
|
||||
|
||||
This avoids real progress bars while preserving the context manager and `update` calls used by the downloader.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
"""Store initialization kwargs for optional inspection."""
|
||||
self.kwargs = kwargs
|
||||
|
||||
def __enter__(self) -> "_DummyTqdm":
|
||||
"""Return self to satisfy context manager protocol."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
"""Propagate exceptions raised inside the context."""
|
||||
return False
|
||||
|
||||
def update(self, size: int) -> None:
|
||||
"""No-op progress update for compatibility with tqdm."""
|
||||
return None
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Test double for requests responses used by the downloader.
|
||||
|
||||
Provides headers, iterable content chunks, and optional HTTP error behavior via `raise_for_status`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content_chunks: Iterable[bytes],
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
raise_error: Optional[Exception] = None,
|
||||
) -> None:
|
||||
"""Initialize the fake response with content and metadata."""
|
||||
self._content_chunks = list(content_chunks)
|
||||
self.headers = headers or {}
|
||||
self._raise_error = raise_error
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
"""Raise the configured HTTP error, if any."""
|
||||
if self._raise_error is not None:
|
||||
raise self._raise_error
|
||||
|
||||
def iter_content(self, chunk_size: int = 1024) -> Iterator[bytes]:
|
||||
"""Yield the configured content chunks."""
|
||||
for chunk in self._content_chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
def _assert_no_download_temp_files(tmp_path: Path, filename: str = "weights.bin") -> None:
|
||||
"""Assert that randomized temporary download files were cleaned up."""
|
||||
assert not list(tmp_path.glob(f"{filename}.*.tmp"))
|
||||
|
||||
|
||||
class TestFileMD5Validation:
|
||||
"""Test MD5 hash computation and validation."""
|
||||
|
||||
def test_compute_file_md5(self):
|
||||
"""Test MD5 hash computation for a simple file."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
||||
f.write("Hello, World!")
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Known MD5 hash for "Hello, World!"
|
||||
expected_hash = "65a8e27d8879283831b664bd8b7f0ad4"
|
||||
actual_hash = _compute_file_md5(temp_file)
|
||||
assert actual_hash == expected_hash
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_validate_file_md5_success(self):
|
||||
"""Test successful MD5 validation."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
||||
f.write("Test content")
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Compute the actual hash first
|
||||
expected_hash = _compute_file_md5(temp_file)
|
||||
|
||||
# Validation should succeed
|
||||
assert _validate_file_md5(temp_file, expected_hash) is True
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_validate_file_md5_failure(self):
|
||||
"""Test MD5 validation failure with wrong hash."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
||||
f.write("Test content")
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Use a wrong hash
|
||||
wrong_hash = "0" * 32
|
||||
|
||||
# Validation should fail
|
||||
assert _validate_file_md5(temp_file, wrong_hash) is False
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hash_case", [pytest.param("lower", id="lowercase"), pytest.param("upper", id="uppercase")]
|
||||
)
|
||||
def test_validate_file_md5_case_insensitive(self, hash_case: Literal["lower", "upper"]) -> None:
|
||||
"""Test that MD5 validation is case-insensitive."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
||||
f.write("Test content")
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Get hash in lowercase
|
||||
hash_lower = _compute_file_md5(temp_file)
|
||||
test_hash = hash_lower.upper() if hash_case == "upper" else hash_lower
|
||||
|
||||
# Each case variant (lower/upper) should validate successfully
|
||||
assert _validate_file_md5(temp_file, test_hash) is True
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_validate_nonexistent_file(self):
|
||||
"""Test validation of non-existent file."""
|
||||
nonexistent_file = "/tmp/nonexistent_file_xyz.txt"
|
||||
assert _validate_file_md5(nonexistent_file, "abc123") is False
|
||||
|
||||
def test_compute_file_md5_empty_file(self):
|
||||
"""Test MD5 hash computation for empty file."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
||||
# Create empty file
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Known MD5 hash for empty file
|
||||
expected_hash = "d41d8cd98f00b204e9800998ecf8427e"
|
||||
actual_hash = _compute_file_md5(temp_file)
|
||||
assert actual_hash == expected_hash
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_compute_file_md5_large_file(self):
|
||||
"""Test MD5 computation for larger file (tests chunking)."""
|
||||
with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
|
||||
# Write 1MB of data
|
||||
data = b"A" * (1024 * 1024)
|
||||
f.write(data)
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Compute hash (should handle chunking correctly)
|
||||
hash_value = _compute_file_md5(temp_file)
|
||||
|
||||
# Verify it's a valid MD5 hash format
|
||||
assert len(hash_value) == 32
|
||||
assert all(c in "0123456789abcdef" for c in hash_value)
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
|
||||
class TestDownloadFile:
|
||||
"""Test download helper behavior and failure cleanup."""
|
||||
|
||||
@patch("rfdetr.utilities.files.tqdm", _DummyTqdm)
|
||||
@patch("rfdetr.utilities.files.requests.get")
|
||||
def test_download_file_missing_content_length(self, mock_get: Mock, tmp_path: Path):
|
||||
"""Download succeeds when content-length is missing."""
|
||||
target_path = tmp_path / "weights.bin"
|
||||
response = _FakeResponse([b"hello", b"world"], headers={})
|
||||
mock_get.return_value = response
|
||||
|
||||
_download_file("https://example.com/file.bin", str(target_path))
|
||||
|
||||
assert target_path.exists()
|
||||
assert target_path.read_bytes() == b"helloworld"
|
||||
_assert_no_download_temp_files(tmp_path)
|
||||
mock_get.assert_called_once_with("https://example.com/file.bin", stream=True, timeout=30.0)
|
||||
|
||||
@patch("rfdetr.utilities.files.requests.get")
|
||||
def test_download_file_http_error(self, mock_get: Mock, tmp_path: Path):
|
||||
"""HTTP errors raise and do not create files."""
|
||||
target_path = tmp_path / "weights.bin"
|
||||
response = _FakeResponse([], raise_error=requests.HTTPError("bad request"))
|
||||
mock_get.return_value = response
|
||||
|
||||
with pytest.raises(requests.HTTPError):
|
||||
_download_file("https://example.com/file.bin", str(target_path))
|
||||
|
||||
assert not target_path.exists()
|
||||
_assert_no_download_temp_files(tmp_path)
|
||||
|
||||
@patch("rfdetr.utilities.files.tqdm", _DummyTqdm)
|
||||
@patch("rfdetr.utilities.files.requests.get")
|
||||
def test_download_file_stream_error_cleans_temp(self, mock_get: Mock, tmp_path: Path):
|
||||
"""Streaming errors clean up temp files."""
|
||||
target_path = tmp_path / "weights.bin"
|
||||
|
||||
class _StreamErrorResponse(_FakeResponse):
|
||||
def iter_content(self, chunk_size: int = 1024) -> Iterator[bytes]:
|
||||
yield b"partial"
|
||||
raise RuntimeError("stream failure")
|
||||
|
||||
response = _StreamErrorResponse([b"partial"], headers={"content-length": "7"})
|
||||
mock_get.return_value = response
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
_download_file("https://example.com/file.bin", str(target_path))
|
||||
|
||||
assert not target_path.exists()
|
||||
_assert_no_download_temp_files(tmp_path)
|
||||
|
||||
@patch("rfdetr.utilities.files.tqdm", _DummyTqdm)
|
||||
@patch("rfdetr.utilities.files.requests.get")
|
||||
def test_download_file_md5_failure_cleans_temp(self, mock_get: Mock, tmp_path: Path):
|
||||
"""MD5 failure removes temp file and target is not created."""
|
||||
target_path = tmp_path / "weights.bin"
|
||||
response = _FakeResponse([b"data"], headers={"content-length": "4"})
|
||||
mock_get.return_value = response
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_download_file(
|
||||
"https://example.com/file.bin",
|
||||
str(target_path),
|
||||
expected_md5="0" * 32,
|
||||
)
|
||||
|
||||
assert not target_path.exists()
|
||||
_assert_no_download_temp_files(tmp_path)
|
||||
|
||||
@patch("rfdetr.utilities.files.tqdm", _DummyTqdm)
|
||||
@patch("rfdetr.utilities.files.requests.get")
|
||||
def test_download_file_replace_failure_cleans_temp(self, mock_get: Mock, tmp_path: Path):
|
||||
"""Replace failure removes temp file and target is not created."""
|
||||
target_path = tmp_path / "weights.bin"
|
||||
response = _FakeResponse([b"data"], headers={"content-length": "4"})
|
||||
mock_get.return_value = response
|
||||
|
||||
with (
|
||||
patch("rfdetr.utilities.files.os.replace", side_effect=PermissionError("replace denied")),
|
||||
pytest.raises(PermissionError, match="replace denied"),
|
||||
):
|
||||
_download_file("https://example.com/file.bin", str(target_path))
|
||||
|
||||
assert not target_path.exists()
|
||||
_assert_no_download_temp_files(tmp_path)
|
||||
|
||||
@patch("rfdetr.utilities.files.tqdm", _DummyTqdm)
|
||||
@patch("rfdetr.utilities.files.open", side_effect=AssertionError("must not reopen temp filename"))
|
||||
@patch("rfdetr.utilities.files.requests.get")
|
||||
def test_download_file_uses_fdopen_without_reopen(
|
||||
self,
|
||||
mock_get: Mock,
|
||||
mock_open: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Download writes through mkstemp file descriptor to avoid TOCTOU reopen."""
|
||||
target_path = tmp_path / "weights.bin"
|
||||
response = _FakeResponse([b"data"], headers={"content-length": "4"})
|
||||
mock_get.return_value = response
|
||||
|
||||
_download_file("https://example.com/file.bin", str(target_path))
|
||||
|
||||
assert target_path.exists()
|
||||
assert target_path.read_bytes() == b"data"
|
||||
_assert_no_download_temp_files(tmp_path)
|
||||
mock_open.assert_not_called()
|
||||
@@ -0,0 +1,163 @@
|
||||
# ------------------------------------------------------------------------
|
||||
# RF-DETR
|
||||
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
||||
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
||||
# ------------------------------------------------------------------------
|
||||
"""Tests for keypoint utility functions in rfdetr.utilities.keypoints."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from rfdetr.utilities.keypoints import (
|
||||
_is_bg_first_schema,
|
||||
_to_active_first,
|
||||
_to_bg_first,
|
||||
precision_cholesky_to_pixel_covariance,
|
||||
schemas_semantically_equal,
|
||||
)
|
||||
|
||||
|
||||
class TestIsBgFirstSchema:
|
||||
"""Group: is_bg_first_schema — schema classification."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("schema", "expected"),
|
||||
[
|
||||
pytest.param([0, 17], True, id="bg-first-single-class"),
|
||||
pytest.param([0, 17, 4], True, id="bg-first-multi-class"),
|
||||
pytest.param([0], True, id="bg-only-slot"),
|
||||
pytest.param([17], False, id="active-first-single"),
|
||||
pytest.param([17, 4], False, id="active-first-multi"),
|
||||
pytest.param([], False, id="empty-schema"),
|
||||
],
|
||||
)
|
||||
def test_classification(self, schema: list[int], expected: bool) -> None:
|
||||
"""is_bg_first_schema returns expected bool for each schema form."""
|
||||
assert _is_bg_first_schema(schema) == expected
|
||||
|
||||
|
||||
class TestToActiveFirst:
|
||||
"""Group: to_active_first — strip leading background slot."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("schema", "expected"),
|
||||
[
|
||||
pytest.param([0, 17], [17], id="bg-first-to-active"),
|
||||
pytest.param([0, 17, 4], [17, 4], id="bg-first-multi-class"),
|
||||
pytest.param([0], [], id="bg-only-to-empty"),
|
||||
pytest.param([17], [17], id="already-active-first"),
|
||||
pytest.param([17, 4], [17, 4], id="already-active-multi"),
|
||||
pytest.param([], [], id="empty-schema"),
|
||||
pytest.param([0, 0, 17], [0, 17], id="multi-leading-zero-strips-one"),
|
||||
],
|
||||
)
|
||||
def test_conversion(self, schema: list[int], expected: list[int]) -> None:
|
||||
"""to_active_first strips only the first leading zero slot."""
|
||||
assert _to_active_first(schema) == expected
|
||||
|
||||
def test_returns_new_list(self) -> None:
|
||||
"""to_active_first always returns a new list, never the input object."""
|
||||
schema = [17]
|
||||
result = _to_active_first(schema)
|
||||
assert result is not schema
|
||||
|
||||
|
||||
class TestToBgFirst:
|
||||
"""Group: to_bg_first — prepend background slot."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("schema", "expected"),
|
||||
[
|
||||
pytest.param([17], [0, 17], id="active-first-to-bg"),
|
||||
pytest.param([17, 4], [0, 17, 4], id="active-first-multi"),
|
||||
pytest.param([0, 17], [0, 17], id="already-bg-first-no-op"),
|
||||
pytest.param([], [], id="empty-schema-no-op"),
|
||||
],
|
||||
)
|
||||
def test_conversion(self, schema: list[int], expected: list[int]) -> None:
|
||||
"""to_bg_first prepends 0 only when schema is active-first and non-empty."""
|
||||
assert _to_bg_first(schema) == expected
|
||||
|
||||
def test_returns_new_list(self) -> None:
|
||||
"""to_bg_first always returns a new list, never the input object."""
|
||||
schema = [0, 17]
|
||||
result = _to_bg_first(schema)
|
||||
assert result is not schema
|
||||
|
||||
|
||||
class TestSchemasSemanticallyEqual:
|
||||
"""Group: schemas_semantically_equal — cross-form equality."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("a", "b", "expected"),
|
||||
[
|
||||
pytest.param([0, 17], [17], True, id="bg-first-eq-active-first"),
|
||||
pytest.param([17], [17], True, id="identical-active-first"),
|
||||
pytest.param([0, 17], [0, 17], True, id="identical-bg-first"),
|
||||
pytest.param([0, 17], [0, 33], False, id="different-keypoint-counts"),
|
||||
pytest.param([17], [33], False, id="active-first-mismatch"),
|
||||
pytest.param([0], [], True, id="bg-only-eq-empty"),
|
||||
pytest.param([], [], True, id="empty-eq-empty"),
|
||||
pytest.param([0, 17, 4], [17, 4], True, id="multi-class-cross-form"),
|
||||
],
|
||||
)
|
||||
def test_equality(self, a: list[int], b: list[int], expected: bool) -> None:
|
||||
"""schemas_semantically_equal returns expected result for each pair."""
|
||||
assert schemas_semantically_equal(a, b) == expected
|
||||
|
||||
def test_symmetric(self) -> None:
|
||||
"""schemas_semantically_equal(a, b) == schemas_semantically_equal(b, a)."""
|
||||
assert schemas_semantically_equal([0, 17], [17]) == schemas_semantically_equal([17], [0, 17])
|
||||
|
||||
|
||||
class TestPrecisionCholeskyToPixelCovariance:
|
||||
"""Group: precision_cholesky_to_pixel_covariance — non-finite input handling."""
|
||||
|
||||
def test_nan_in_single_slot_produces_nan_only_in_that_slot(self) -> None:
|
||||
"""NaN params in one detection slot should propagate NaN only to that slot's output."""
|
||||
# N=2, K=1: first slot valid, second slot has NaN in all three params.
|
||||
params = np.array(
|
||||
[[[0.0, 0.0, 0.0]], [[np.nan, 0.0, 0.0]]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
source_shape = np.array([[10.0, 20.0], [10.0, 20.0]], dtype=np.float32)
|
||||
|
||||
covariance = precision_cholesky_to_pixel_covariance(
|
||||
precision_cholesky=params,
|
||||
source_shape=source_shape,
|
||||
)
|
||||
|
||||
# First slot (valid) should be all-finite.
|
||||
assert np.isfinite(covariance[0, 0]).all(), f"First slot expected all-finite, got {covariance[0, 0]}"
|
||||
# Second slot (NaN input) should be all-NaN.
|
||||
assert np.isnan(covariance[1, 0]).all(), f"Second slot expected all-NaN, got {covariance[1, 0]}"
|
||||
|
||||
def test_all_inf_params_produce_all_nan_covariance(self) -> None:
|
||||
"""Infinite precision params should produce all-NaN pixel covariances."""
|
||||
params = np.full((1, 1, 3), np.inf, dtype=np.float32)
|
||||
source_shape = np.array([[10.0, 20.0]], dtype=np.float32)
|
||||
|
||||
covariance = precision_cholesky_to_pixel_covariance(
|
||||
precision_cholesky=params,
|
||||
source_shape=source_shape,
|
||||
)
|
||||
|
||||
assert np.isnan(covariance).all(), f"Expected all-NaN output for all-inf inputs, got {covariance}"
|
||||
|
||||
def test_mixed_valid_and_nan_rows_isolates_nan_to_bad_row(self) -> None:
|
||||
"""First detection valid, second detection NaN — only second row should be NaN."""
|
||||
params = np.array(
|
||||
[[[0.0, 0.0, 0.0]], [[np.nan, np.nan, np.nan]]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
source_shape = np.array([[10.0, 20.0], [5.0, 8.0]], dtype=np.float32)
|
||||
|
||||
covariance = precision_cholesky_to_pixel_covariance(
|
||||
precision_cholesky=params,
|
||||
source_shape=source_shape,
|
||||
)
|
||||
|
||||
# Row 0 — valid identity input, covariance should be finite.
|
||||
assert np.isfinite(covariance[0]).all(), f"Row 0 expected all-finite, got {covariance[0]}"
|
||||
# Row 1 — NaN input, covariance should be all-NaN.
|
||||
assert np.isnan(covariance[1]).all(), f"Row 1 expected all-NaN, got {covariance[1]}"
|
||||
@@ -0,0 +1,90 @@
|
||||
# ------------------------------------------------------------------------
|
||||
# RF-DETR
|
||||
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
||||
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from rfdetr.utilities.state_dict import strip_checkpoint
|
||||
|
||||
|
||||
class TestStripCheckpoint:
|
||||
def test_strip_checkpoint_keeps_only_model_and_args(self, tmp_path):
|
||||
checkpoint_path = tmp_path / "checkpoint_best_total.pth"
|
||||
torch.save(
|
||||
{
|
||||
"model": {"weight": torch.tensor([1.0])},
|
||||
"args": SimpleNamespace(class_names=["a"]),
|
||||
"optimizer": {"lr": 1e-4},
|
||||
},
|
||||
checkpoint_path,
|
||||
)
|
||||
|
||||
strip_checkpoint(str(checkpoint_path))
|
||||
|
||||
stripped = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
||||
assert set(stripped.keys()) == {"model", "args"}
|
||||
|
||||
def test_strip_checkpoint_preserves_model_name_when_present(self, tmp_path):
|
||||
checkpoint_path = tmp_path / "checkpoint_best_total.pth"
|
||||
torch.save(
|
||||
{
|
||||
"model": {"weight": torch.tensor([1.0])},
|
||||
"args": SimpleNamespace(class_names=["a"]),
|
||||
"model_name": "RFDETRSmall",
|
||||
"optimizer": {"lr": 1e-4},
|
||||
},
|
||||
checkpoint_path,
|
||||
)
|
||||
|
||||
strip_checkpoint(str(checkpoint_path))
|
||||
|
||||
stripped = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
||||
assert set(stripped.keys()) == {"model", "args", "model_name"}
|
||||
assert stripped["model_name"] == "RFDETRSmall"
|
||||
|
||||
def test_strip_checkpoint_omits_model_name_when_absent(self, tmp_path):
|
||||
checkpoint_path = tmp_path / "checkpoint_best_total.pth"
|
||||
torch.save(
|
||||
{
|
||||
"model": {"weight": torch.tensor([1.0])},
|
||||
"args": SimpleNamespace(class_names=["a"]),
|
||||
"optimizer": {"lr": 1e-4},
|
||||
},
|
||||
checkpoint_path,
|
||||
)
|
||||
|
||||
strip_checkpoint(str(checkpoint_path))
|
||||
|
||||
stripped = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
||||
assert "model_name" not in stripped
|
||||
|
||||
def test_strip_checkpoint_is_atomic_when_save_fails(self, tmp_path, monkeypatch):
|
||||
checkpoint_path = tmp_path / "checkpoint_best_total.pth"
|
||||
original_checkpoint = {
|
||||
"model": {"weight": torch.tensor([1.0])},
|
||||
"args": SimpleNamespace(class_names=["a"]),
|
||||
"optimizer": {"lr": 1e-4},
|
||||
}
|
||||
torch.save(original_checkpoint, checkpoint_path)
|
||||
|
||||
original_torch_save = torch.save
|
||||
|
||||
def failing_torch_save(obj, destination, *args, **kwargs):
|
||||
if str(destination) != str(checkpoint_path):
|
||||
raise RuntimeError("simulated save failure")
|
||||
return original_torch_save(obj, destination, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(torch, "save", failing_torch_save)
|
||||
|
||||
with pytest.raises(RuntimeError, match="simulated save failure"):
|
||||
strip_checkpoint(str(checkpoint_path))
|
||||
|
||||
recovered = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
||||
assert set(recovered.keys()) == set(original_checkpoint.keys())
|
||||
assert recovered["model"]["weight"].equal(original_checkpoint["model"]["weight"])
|
||||
assert recovered["optimizer"] == original_checkpoint["optimizer"]
|
||||
@@ -0,0 +1,269 @@
|
||||
# ------------------------------------------------------------------------
|
||||
# RF-DETR
|
||||
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
||||
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
||||
# ------------------------------------------------------------------------
|
||||
"""Tests for package metadata helpers and structural import paths."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from rfdetr.utilities.package import get_sha
|
||||
|
||||
|
||||
def test_get_sha_marks_dirty_worktree_when_diff_command_returns_exit_code_1() -> None:
|
||||
"""A diff exit code of 1 should report uncommitted changes, not unknown."""
|
||||
|
||||
def _fake_check_output(command: list[str], cwd: str | None = None) -> bytes:
|
||||
if command[:3] == ["git", "rev-parse", "HEAD"]:
|
||||
return b"abc123\n"
|
||||
if command[:4] == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
|
||||
return b"feature/test\n"
|
||||
raise AssertionError(f"Unexpected command: {command!r}")
|
||||
|
||||
class _RunResult:
|
||||
def __init__(self, returncode: int) -> None:
|
||||
self.returncode = returncode
|
||||
|
||||
with (
|
||||
patch("rfdetr.utilities.package.subprocess.check_output", side_effect=_fake_check_output),
|
||||
patch("rfdetr.utilities.package.subprocess.run", return_value=_RunResult(returncode=1)),
|
||||
):
|
||||
sha = get_sha()
|
||||
|
||||
assert sha == "sha: abc123, status: has uncommitted changes, branch: feature/test"
|
||||
|
||||
|
||||
def test_peft_not_imported_eagerly_on_backbone_import_characterization() -> None:
|
||||
"""Importing backbone.backbone must NOT pull peft into sys.modules (peft is optional).
|
||||
|
||||
This characterization test captures the invariant introduced in PR 1 (chore/packaging-peft-lora): after the lazy-
|
||||
import refactor, importing backbone at module-load time must not trigger a top-level ``from peft import PeftModel``.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"import sys; "
|
||||
"import rfdetr.models.backbone.backbone; "
|
||||
"assert 'peft' not in sys.modules, "
|
||||
"'peft was eagerly imported by backbone.backbone'"
|
||||
),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
"Subprocess for backbone import failed:\n"
|
||||
f"return code: {result.returncode}\n"
|
||||
f"stdout:\n{result.stdout}\n"
|
||||
f"stderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
class TestImportPaths:
|
||||
"""Structural tests for the detr.py → inference.py + variants.py split (PR 6).
|
||||
|
||||
After the split:
|
||||
- ``rfdetr.inference`` exports ``ModelContext`` and ``_build_model_context``
|
||||
- ``rfdetr.variants`` exports all 15 concrete model classes
|
||||
- ``rfdetr.detr`` re-exports both for backward compatibility
|
||||
- ``rfdetr`` (top-level) continues to export public names unchanged
|
||||
"""
|
||||
|
||||
def test_model_context_importable_from_inference(self) -> None:
|
||||
"""ModelContext must be importable from the new rfdetr.inference module."""
|
||||
from rfdetr.inference import ModelContext
|
||||
|
||||
assert ModelContext is not None
|
||||
|
||||
def test_build_model_context_importable_from_inference(self) -> None:
|
||||
"""_build_model_context must be importable from rfdetr.inference."""
|
||||
from rfdetr.inference import _build_model_context
|
||||
|
||||
assert callable(_build_model_context)
|
||||
|
||||
def test_rfdetr_large_importable_from_variants(self) -> None:
|
||||
"""RFDETRLarge must be importable from the new rfdetr.variants module."""
|
||||
from rfdetr.variants import RFDETRLarge
|
||||
|
||||
assert RFDETRLarge is not None
|
||||
|
||||
def test_variants_import_first_does_not_trigger_circular_import(self) -> None:
|
||||
"""Importing variants before detr must still preserve shared class identity."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"from rfdetr.variants import RFDETRLarge; "
|
||||
"from rfdetr.detr import RFDETRLarge as FromDetr; "
|
||||
"assert RFDETRLarge is FromDetr"
|
||||
),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
"Subprocess for variants-first import failed:\n"
|
||||
f"return code: {result.returncode}\n"
|
||||
f"stdout:\n{result.stdout}\n"
|
||||
f"stderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"class_name",
|
||||
[
|
||||
pytest.param("RFDETRBase", id="base"),
|
||||
pytest.param("RFDETRKeypointPreview", id="keypoint-preview"),
|
||||
pytest.param("RFDETRNano", id="nano"),
|
||||
pytest.param("RFDETRSmall", id="small"),
|
||||
pytest.param("RFDETRMedium", id="medium"),
|
||||
pytest.param("RFDETRLargeDeprecated", id="large-deprecated"),
|
||||
pytest.param("RFDETRLarge", id="large"),
|
||||
pytest.param("RFDETRSeg", id="seg-base"),
|
||||
pytest.param("RFDETRSegPreview", id="seg-preview"),
|
||||
pytest.param("RFDETRSegNano", id="seg-nano"),
|
||||
pytest.param("RFDETRSegSmall", id="seg-small"),
|
||||
pytest.param("RFDETRSegMedium", id="seg-medium"),
|
||||
pytest.param("RFDETRSegLarge", id="seg-large"),
|
||||
pytest.param("RFDETRSegXLarge", id="seg-xlarge"),
|
||||
pytest.param("RFDETRSeg2XLarge", id="seg-2xlarge"),
|
||||
],
|
||||
)
|
||||
def test_all_variant_classes_importable_from_variants(self, class_name: str) -> None:
|
||||
"""Every concrete variant class must be importable from rfdetr.variants."""
|
||||
import rfdetr.variants as variants_mod
|
||||
|
||||
cls = getattr(variants_mod, class_name, None)
|
||||
assert cls is not None, f"{class_name} not found in rfdetr.variants"
|
||||
|
||||
def test_model_context_backward_compat_from_detr(self) -> None:
|
||||
"""ModelContext must remain importable from rfdetr.detr (backward compat)."""
|
||||
from rfdetr.detr import ModelContext
|
||||
|
||||
assert ModelContext is not None
|
||||
|
||||
def test_rfdetr_large_backward_compat_from_detr(self) -> None:
|
||||
"""RFDETRLarge must remain importable from rfdetr.detr (backward compat)."""
|
||||
from rfdetr.detr import RFDETRLarge
|
||||
|
||||
assert RFDETRLarge is not None
|
||||
|
||||
def test_rfdetr_large_importable_from_top_level(self) -> None:
|
||||
"""RFDETRLarge must remain importable from rfdetr (top-level package)."""
|
||||
from rfdetr import RFDETRLarge
|
||||
|
||||
assert RFDETRLarge is not None
|
||||
|
||||
def test_model_context_importable_from_top_level(self) -> None:
|
||||
"""ModelContext must remain importable from rfdetr (top-level package)."""
|
||||
from rfdetr import ModelContext
|
||||
|
||||
assert ModelContext is not None
|
||||
|
||||
def test_top_level_import_sets_numpy_complex_alias(self) -> None:
|
||||
"""Verify rfdetr shim sets np.complex_ in a fresh interpreter with complex_ absent.
|
||||
|
||||
Runs in a subprocess so the shim code path is actually executed — importing rfdetr inside pytest is a no-op
|
||||
because rfdetr is already cached in sys.modules.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"import numpy\n"
|
||||
"if hasattr(numpy, 'complex_'):\n"
|
||||
" del numpy.complex_\n"
|
||||
"import rfdetr\n"
|
||||
"assert hasattr(numpy, 'complex_'), 'shim did not set numpy.complex_'\n"
|
||||
"assert numpy.complex_ is numpy.complex128, 'complex_ must alias complex128'\n"
|
||||
),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
"Subprocess for NumPy complex_ shim failed:\n"
|
||||
f"return code: {result.returncode}\n"
|
||||
f"stdout:\n{result.stdout}\n"
|
||||
f"stderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
def test_identity_across_import_paths(self) -> None:
|
||||
"""The same class object must be returned regardless of import path.
|
||||
|
||||
This ensures re-exports are true re-exports (not copies) so that isinstance() checks work across all import
|
||||
paths.
|
||||
"""
|
||||
import rfdetr
|
||||
from rfdetr.detr import ModelContext as FromDetr
|
||||
from rfdetr.detr import RFDETRLarge as LargeFromDetr
|
||||
from rfdetr.inference import ModelContext as FromInference
|
||||
from rfdetr.variants import RFDETRLarge as LargeFromVariants
|
||||
|
||||
assert FromDetr is FromInference, (
|
||||
"rfdetr.detr.ModelContext and rfdetr.inference.ModelContext must be the same object"
|
||||
)
|
||||
assert LargeFromDetr is LargeFromVariants, (
|
||||
"rfdetr.detr.RFDETRLarge and rfdetr.variants.RFDETRLarge must be the same object"
|
||||
)
|
||||
assert rfdetr.RFDETRLarge is LargeFromVariants, (
|
||||
"top-level rfdetr.RFDETRLarge and rfdetr.variants.RFDETRLarge must be the same object"
|
||||
)
|
||||
assert rfdetr.ModelContext is FromInference, (
|
||||
"top-level rfdetr.ModelContext and rfdetr.inference.ModelContext must be the same object"
|
||||
)
|
||||
|
||||
def test_build_model_context_backward_compat_from_detr(self) -> None:
|
||||
"""_build_model_context must remain importable from rfdetr.detr (backward compat)."""
|
||||
from rfdetr.detr import _build_model_context
|
||||
|
||||
assert callable(_build_model_context)
|
||||
|
||||
def test_getattr_raises_for_unknown_names(self) -> None:
|
||||
"""Accessing an unknown name via rfdetr.detr must raise AttributeError."""
|
||||
import rfdetr.detr as detr_mod
|
||||
|
||||
with pytest.raises(AttributeError, match="has no attribute"):
|
||||
_ = detr_mod._this_name_does_not_exist_12345
|
||||
|
||||
def test_dir_includes_lazy_exports(self) -> None:
|
||||
"""dir(rfdetr.detr) must include all lazy re-export names."""
|
||||
import rfdetr.detr as detr_mod
|
||||
|
||||
names = dir(detr_mod)
|
||||
assert "ModelContext" in names, "ModelContext missing from dir(rfdetr.detr)"
|
||||
assert "RFDETRLarge" in names, "RFDETRLarge missing from dir(rfdetr.detr)"
|
||||
assert "RFDETRBase" in names, "RFDETRBase missing from dir(rfdetr.detr)"
|
||||
|
||||
def test_detr_first_import_order_preserves_identity(self) -> None:
|
||||
"""Importing detr before variants must preserve object identity for variant classes."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"from rfdetr.detr import RFDETRLarge; "
|
||||
"from rfdetr.variants import RFDETRLarge as FromVariants; "
|
||||
"assert RFDETRLarge is FromVariants"
|
||||
),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
"Subprocess for detr-first import failed:\n"
|
||||
f"return code: {result.returncode}\n"
|
||||
f"stdout:\n{result.stdout}\n"
|
||||
f"stderr:\n{result.stderr}"
|
||||
)
|
||||
@@ -0,0 +1,493 @@
|
||||
# ------------------------------------------------------------------------
|
||||
# RF-DETR
|
||||
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
||||
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
||||
# ------------------------------------------------------------------------
|
||||
"""Unit tests for rfdetr.utilities.state_dict."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from pytorch_lightning import LightningModule, Trainer
|
||||
|
||||
from rfdetr.utilities.state_dict import (
|
||||
_make_fit_loop_state,
|
||||
remap_projector_to_cross_attn,
|
||||
strip_checkpoint,
|
||||
validate_checkpoint_compatibility,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _make_fit_loop_state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMakeFitLoopState:
|
||||
"""Tests for _make_fit_loop_state epoch counter encoding."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"epoch,expected_n",
|
||||
[
|
||||
pytest.param(0, 1, id="epoch_0"),
|
||||
pytest.param(4, 5, id="epoch_4"),
|
||||
pytest.param(9, 10, id="epoch_9"),
|
||||
],
|
||||
)
|
||||
def test_epoch_progress_completed_is_epoch_plus_one(self, epoch: int, expected_n: int) -> None:
|
||||
"""epoch_progress.current.completed == epoch + 1 so PTL sets current_epoch correctly."""
|
||||
state = _make_fit_loop_state(epoch)
|
||||
assert state["epoch_progress"]["current"]["completed"] == expected_n
|
||||
assert state["epoch_progress"]["total"]["completed"] == expected_n
|
||||
|
||||
def test_epoch_progress_all_counters_equal(self) -> None:
|
||||
"""All four counters in epoch_progress should be equal (epoch fully completed)."""
|
||||
state = _make_fit_loop_state(7)
|
||||
for scope in ("total", "current"):
|
||||
ep = state["epoch_progress"][scope]
|
||||
vals = [ep["ready"], ep["started"], ep["processed"], ep["completed"]]
|
||||
assert len(set(vals)) == 1, f"epoch_progress.{scope} counters differ: {ep}"
|
||||
|
||||
def test_batches_that_stepped_is_zero(self) -> None:
|
||||
"""Optimizer/scheduler state should start fresh; _batches_that_stepped must be 0."""
|
||||
state = _make_fit_loop_state(3)
|
||||
assert state["epoch_loop.state_dict"]["_batches_that_stepped"] == 0
|
||||
|
||||
def test_batch_progress_is_zero(self) -> None:
|
||||
"""Batch progress counters should be zeroed out (not mid-batch resume)."""
|
||||
state = _make_fit_loop_state(5)
|
||||
for key in ("epoch_loop.batch_progress", "epoch_loop.val_loop.batch_progress"):
|
||||
bp = state[key]
|
||||
assert bp["is_last_batch"] is False
|
||||
for scope in ("total", "current"):
|
||||
assert all(v == 0 for v in bp[scope].values()), f"{key}.{scope} not zero: {bp[scope]}"
|
||||
|
||||
def test_ptl_accepts_fit_loop_state(self) -> None:
|
||||
"""PTL's _FitLoop.load_state_dict must not raise with our synthesised state dict."""
|
||||
|
||||
class _DummyModule(LightningModule):
|
||||
def training_step(self, batch, idx):
|
||||
return torch.tensor(0.0, requires_grad=True)
|
||||
|
||||
def configure_optimizers(self):
|
||||
return torch.optim.SGD(self.parameters(), lr=1e-3)
|
||||
|
||||
trainer = Trainer(max_epochs=10, accelerator="cpu", enable_progress_bar=False, logger=False)
|
||||
trainer.strategy.connect(_DummyModule())
|
||||
|
||||
epoch = 4
|
||||
state = _make_fit_loop_state(epoch)
|
||||
trainer.fit_loop.load_state_dict(state)
|
||||
assert trainer.current_epoch == epoch + 1
|
||||
|
||||
def test_required_top_level_keys_present(self) -> None:
|
||||
"""State dict must contain all keys the FitLoop accesses during load."""
|
||||
required = {
|
||||
"state_dict",
|
||||
"epoch_loop.state_dict",
|
||||
"epoch_loop.batch_progress",
|
||||
"epoch_loop.scheduler_progress",
|
||||
"epoch_loop.automatic_optimization.state_dict",
|
||||
"epoch_loop.automatic_optimization.optim_progress",
|
||||
"epoch_loop.manual_optimization.state_dict",
|
||||
"epoch_loop.manual_optimization.optim_step_progress",
|
||||
"epoch_loop.val_loop.state_dict",
|
||||
"epoch_loop.val_loop.batch_progress",
|
||||
"epoch_progress",
|
||||
}
|
||||
state = _make_fit_loop_state(0)
|
||||
missing = required - set(state.keys())
|
||||
assert not missing, f"Missing keys: {missing}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_checkpoint_compatibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateCheckpointCompatibility:
|
||||
"""Direct unit tests for validate_checkpoint_compatibility."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Early-return / silent-skip cases
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_no_args_key_returns_without_raising(self):
|
||||
"""Checkpoint without 'args' key must return silently."""
|
||||
checkpoint = {"model": {}}
|
||||
model_args = SimpleNamespace(segmentation_head=False, patch_size=14)
|
||||
validate_checkpoint_compatibility(checkpoint, model_args) # must not raise
|
||||
|
||||
def test_ckpt_has_segmentation_head_model_does_not_skips(self):
|
||||
"""One-sided: ckpt has segmentation_head, model_args lacks it — skip, no error."""
|
||||
ckpt_args = SimpleNamespace(segmentation_head=True, patch_size=14)
|
||||
checkpoint = {"args": ckpt_args}
|
||||
model_args = SimpleNamespace(patch_size=14) # no segmentation_head attribute
|
||||
validate_checkpoint_compatibility(checkpoint, model_args) # must not raise
|
||||
|
||||
def test_ckpt_lacks_patch_size_model_has_it_skips(self):
|
||||
"""One-sided: ckpt has no patch_size, model has it — skip that check, no error."""
|
||||
ckpt_args = SimpleNamespace(segmentation_head=False) # no patch_size
|
||||
checkpoint = {"args": ckpt_args}
|
||||
model_args = SimpleNamespace(segmentation_head=False, patch_size=14)
|
||||
validate_checkpoint_compatibility(checkpoint, model_args) # must not raise
|
||||
|
||||
def test_compatible_checkpoint_no_exception(self):
|
||||
"""Checkpoint with matching segmentation_head and patch_size must not raise."""
|
||||
ckpt_args = SimpleNamespace(segmentation_head=False, patch_size=14)
|
||||
checkpoint = {"args": ckpt_args}
|
||||
model_args = SimpleNamespace(segmentation_head=False, patch_size=14)
|
||||
validate_checkpoint_compatibility(checkpoint, model_args) # must not raise
|
||||
|
||||
def test_compatible_segmentation_checkpoint_no_exception(self):
|
||||
"""Matching segmentation model (seg_head=True both sides) must not raise."""
|
||||
ckpt_args = SimpleNamespace(segmentation_head=True, patch_size=16)
|
||||
checkpoint = {"args": ckpt_args}
|
||||
model_args = SimpleNamespace(segmentation_head=True, patch_size=16)
|
||||
validate_checkpoint_compatibility(checkpoint, model_args) # must not raise
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# segmentation_head mismatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_seg_ckpt_into_detection_model_raises(self):
|
||||
"""Segmentation checkpoint loaded into a detection model must raise ValueError."""
|
||||
ckpt_args = SimpleNamespace(segmentation_head=True, patch_size=14)
|
||||
checkpoint = {"args": ckpt_args}
|
||||
model_args = SimpleNamespace(segmentation_head=False, patch_size=14)
|
||||
with pytest.raises(ValueError, match="segmentation head"):
|
||||
validate_checkpoint_compatibility(checkpoint, model_args)
|
||||
|
||||
def test_detection_ckpt_into_seg_model_raises(self):
|
||||
"""Detection checkpoint loaded into a segmentation model must raise ValueError."""
|
||||
ckpt_args = SimpleNamespace(segmentation_head=False, patch_size=14)
|
||||
checkpoint = {"args": ckpt_args}
|
||||
model_args = SimpleNamespace(segmentation_head=True, patch_size=14)
|
||||
with pytest.raises(ValueError, match="segmentation head"):
|
||||
validate_checkpoint_compatibility(checkpoint, model_args)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# patch_size mismatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_patch_size_mismatch_raises_with_both_sizes(self):
|
||||
"""patch_size mismatch must raise ValueError and mention both sizes."""
|
||||
ckpt_args = SimpleNamespace(segmentation_head=False, patch_size=12)
|
||||
checkpoint = {"args": ckpt_args}
|
||||
model_args = SimpleNamespace(segmentation_head=False, patch_size=16)
|
||||
with pytest.raises(ValueError, match=r"patch_size=12.*patch_size=16|patch_size=16.*patch_size=12"):
|
||||
validate_checkpoint_compatibility(checkpoint, model_args)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# patch_size inferred from projection weight (no "args" key)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ckpt_patch_size,model_patch_size,should_raise",
|
||||
[
|
||||
pytest.param(16, 12, True, id="ckpt_16_model_12_raises"),
|
||||
pytest.param(14, 16, True, id="ckpt_14_model_16_raises"),
|
||||
pytest.param(16, 16, False, id="matching_16_no_raise"),
|
||||
],
|
||||
)
|
||||
def test_patch_size_inferred_from_projection_weight(
|
||||
self, ckpt_patch_size: int, model_patch_size: int, should_raise: bool
|
||||
) -> None:
|
||||
"""Projection weight shape used to infer ckpt patch_size when 'args' key absent.
|
||||
|
||||
Regression test for #965 — pretrained COCO weights lack 'args', so the shape-based fallback must fire before
|
||||
load_state_dict raises a cryptic RuntimeError.
|
||||
"""
|
||||
proj_key = "backbone.0.encoder.encoder.embeddings.patch_embeddings.projection.weight"
|
||||
proj_weight = torch.zeros(384, 3, ckpt_patch_size, ckpt_patch_size)
|
||||
checkpoint = {"model": {proj_key: proj_weight}} # no "args" key
|
||||
model_args = SimpleNamespace(patch_size=model_patch_size)
|
||||
|
||||
if should_raise:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=rf"patch_size={ckpt_patch_size}.*patch_size={model_patch_size}"
|
||||
rf"|patch_size={model_patch_size}.*patch_size={ckpt_patch_size}",
|
||||
):
|
||||
validate_checkpoint_compatibility(checkpoint, model_args)
|
||||
else:
|
||||
validate_checkpoint_compatibility(checkpoint, model_args) # must not raise
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"checkpoint,model_args_kwargs",
|
||||
[
|
||||
pytest.param(
|
||||
{},
|
||||
{"patch_size": 16},
|
||||
id="no_model_key_skips",
|
||||
),
|
||||
pytest.param(
|
||||
{"model": {}},
|
||||
{"patch_size": 16},
|
||||
id="no_projection_key_skips",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"model": {
|
||||
"backbone.0.encoder.encoder.embeddings.patch_embeddings.projection.weight": torch.zeros(
|
||||
384, 3, 16, 16
|
||||
)
|
||||
}
|
||||
},
|
||||
{},
|
||||
id="model_no_patch_size_attr_skips",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"model": {
|
||||
"backbone.0.encoder.encoder.embeddings.patch_embeddings.projection.weight": torch.zeros(
|
||||
384, 3
|
||||
) # 2D — not a Conv2d weight; rank guard must skip cleanly
|
||||
}
|
||||
},
|
||||
{"patch_size": 16},
|
||||
id="proj_weight_2d_skips",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"model": {
|
||||
"backbone.0.encoder.encoder.embeddings.patch_embeddings.projection.weight": torch.zeros(
|
||||
384, 3, 16
|
||||
) # 3D — not a Conv2d weight; rank guard must skip cleanly
|
||||
}
|
||||
},
|
||||
{"patch_size": 16},
|
||||
id="proj_weight_3d_skips",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"model": {
|
||||
"backbone.0.encoder.encoder.embeddings.patch_embeddings.projection.weight": torch.zeros(
|
||||
384, 3, 16, 16, 16
|
||||
) # 5D — Conv3d-like; rank guard (== 4) must skip cleanly
|
||||
}
|
||||
},
|
||||
{"patch_size": 8},
|
||||
id="proj_weight_5d_skips",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"args": SimpleNamespace(patch_size=14),
|
||||
"model": {
|
||||
"backbone.0.encoder.encoder.embeddings.patch_embeddings.projection.weight": torch.zeros(
|
||||
384, 3, 16, 16
|
||||
) # projection suggests 16, but args.patch_size=14 takes precedence
|
||||
},
|
||||
},
|
||||
{"patch_size": 14},
|
||||
id="args_patch_size_suppresses_projection_inference",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"args": {"patch_size": 14}, # PTL-style dict args (not SimpleNamespace)
|
||||
"model": {
|
||||
"backbone.0.encoder.encoder.embeddings.patch_embeddings.projection.weight": torch.zeros(
|
||||
384, 3, 16, 16
|
||||
) # projection suggests 16, but dict args["patch_size"]=14 takes precedence
|
||||
},
|
||||
},
|
||||
{"patch_size": 14},
|
||||
id="dict_args_patch_size_suppresses_projection_inference",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_projection_inference_silently_skips_when_incomplete(
|
||||
self, checkpoint: dict, model_args_kwargs: dict
|
||||
) -> None:
|
||||
"""Shape-based patch_size check is skipped when key or attribute is absent.
|
||||
|
||||
Verifies backward compatibility: missing projection key, missing model key, model_args without patch_size
|
||||
attribute, non-4D projection weights, or an explicit args.patch_size (SimpleNamespace or dict) must all be
|
||||
handled without error.
|
||||
"""
|
||||
model_args = SimpleNamespace(**model_args_kwargs)
|
||||
validate_checkpoint_compatibility(checkpoint, model_args) # must not raise
|
||||
|
||||
def test_non_square_projection_kernel_skips_check(self) -> None:
|
||||
"""Non-square patch projection kernel is skipped — patch_size cannot be inferred reliably.
|
||||
|
||||
Guards against hypothetical future backbones with non-square Conv2d kernels where shape[-1] would not equal
|
||||
patch_size.
|
||||
"""
|
||||
proj_key = "backbone.0.encoder.encoder.embeddings.patch_embeddings.projection.weight"
|
||||
proj_weight = torch.zeros(384, 3, 16, 14) # non-square: h=16, w=14
|
||||
checkpoint = {"model": {proj_key: proj_weight}}
|
||||
model_args = SimpleNamespace(patch_size=16)
|
||||
validate_checkpoint_compatibility(checkpoint, model_args) # must not raise
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# class-count mismatch warnings
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_class_count_mismatch_backbone_pretrain_warns(self, caplog):
|
||||
"""Backbone pretrain scenario: checkpoint 91 classes, model 2 — warns about re-init."""
|
||||
ckpt_args = SimpleNamespace(segmentation_head=False, patch_size=14)
|
||||
checkpoint = {
|
||||
"args": ckpt_args,
|
||||
"model": {"class_embed.bias": torch.randn(91)},
|
||||
}
|
||||
model_args = SimpleNamespace(segmentation_head=False, patch_size=14, num_classes=2)
|
||||
|
||||
rf_detr_logger = logging.getLogger("rf-detr")
|
||||
prev_propagate = rf_detr_logger.propagate
|
||||
rf_detr_logger.propagate = True
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger="rf-detr"):
|
||||
validate_checkpoint_compatibility(checkpoint, model_args)
|
||||
finally:
|
||||
rf_detr_logger.propagate = prev_propagate
|
||||
|
||||
warning_msgs = [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING]
|
||||
assert any("re-initialized to 2 classes" in msg for msg in warning_msgs), (
|
||||
f"Expected 're-initialized to 2 classes' warning, got: {warning_msgs}"
|
||||
)
|
||||
|
||||
def test_class_count_mismatch_finetune_checkpoint_warns(self, caplog):
|
||||
"""Fine-tuned checkpoint scenario: checkpoint 3 classes, model 90 — warns with num_classes hint."""
|
||||
ckpt_args = SimpleNamespace(segmentation_head=False, patch_size=14)
|
||||
checkpoint = {
|
||||
"args": ckpt_args,
|
||||
"model": {"class_embed.bias": torch.randn(3)},
|
||||
}
|
||||
model_args = SimpleNamespace(segmentation_head=False, patch_size=14, num_classes=90)
|
||||
|
||||
rf_detr_logger = logging.getLogger("rf-detr")
|
||||
prev_propagate = rf_detr_logger.propagate
|
||||
rf_detr_logger.propagate = True
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger="rf-detr"):
|
||||
validate_checkpoint_compatibility(checkpoint, model_args)
|
||||
finally:
|
||||
rf_detr_logger.propagate = prev_propagate
|
||||
|
||||
warning_msgs = [r.getMessage() for r in caplog.records if r.name == "rf-detr" and r.levelno >= logging.WARNING]
|
||||
assert any("Pass num_classes=2" in msg for msg in warning_msgs), (
|
||||
f"Expected 'Pass num_classes=2' warning, got: {warning_msgs}"
|
||||
)
|
||||
|
||||
def test_class_count_match_no_warning(self, caplog):
|
||||
"""Matching class count — no warning emitted."""
|
||||
ckpt_args = SimpleNamespace(segmentation_head=False, patch_size=14)
|
||||
checkpoint = {
|
||||
"args": ckpt_args,
|
||||
"model": {"class_embed.bias": torch.randn(91)},
|
||||
}
|
||||
model_args = SimpleNamespace(segmentation_head=False, patch_size=14, num_classes=90)
|
||||
|
||||
rf_detr_logger = logging.getLogger("rf-detr")
|
||||
prev_propagate = rf_detr_logger.propagate
|
||||
rf_detr_logger.propagate = True
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger="rf-detr"):
|
||||
validate_checkpoint_compatibility(checkpoint, model_args)
|
||||
finally:
|
||||
rf_detr_logger.propagate = prev_propagate
|
||||
|
||||
warning_msgs = [r.getMessage() for r in caplog.records if r.name == "rf-detr" and r.levelno >= logging.WARNING]
|
||||
assert not warning_msgs, f"Expected no warnings, got: {warning_msgs}"
|
||||
|
||||
|
||||
class TestRemapProjectorToCrossAttn:
|
||||
"""Tests for dual-projector checkpoint key remapping."""
|
||||
|
||||
def test_clones_projector_weights_when_dual_projector_enabled(self) -> None:
|
||||
"""Dual-projector models clone projector keys into cross_attn_projector when missing."""
|
||||
state_dict = {
|
||||
"backbone.0.projector.0.weight": torch.randn(4, 4, 1, 1),
|
||||
"backbone.0.projector.0.bias": torch.randn(4),
|
||||
}
|
||||
model = SimpleNamespace(backbone=[SimpleNamespace(dual_projector=True)])
|
||||
|
||||
remapped = remap_projector_to_cross_attn(state_dict, model)
|
||||
|
||||
assert remapped is state_dict
|
||||
assert "backbone.0.cross_attn_projector.0.weight" in remapped
|
||||
assert "backbone.0.cross_attn_projector.0.bias" in remapped
|
||||
assert torch.equal(
|
||||
remapped["backbone.0.cross_attn_projector.0.weight"],
|
||||
state_dict["backbone.0.projector.0.weight"],
|
||||
)
|
||||
assert torch.equal(
|
||||
remapped["backbone.0.cross_attn_projector.0.bias"],
|
||||
state_dict["backbone.0.projector.0.bias"],
|
||||
)
|
||||
|
||||
def test_skips_when_cross_attn_keys_already_present(self) -> None:
|
||||
"""No remap is applied when cross_attn_projector keys already exist."""
|
||||
state_dict = {
|
||||
"backbone.0.projector.0.weight": torch.randn(4, 4, 1, 1),
|
||||
"backbone.0.cross_attn_projector.0.weight": torch.randn(4, 4, 1, 1),
|
||||
}
|
||||
model = SimpleNamespace(backbone=[SimpleNamespace(dual_projector=True)])
|
||||
|
||||
remapped = remap_projector_to_cross_attn(state_dict, model)
|
||||
|
||||
assert remapped is state_dict
|
||||
assert len([key for key in remapped if key.startswith("backbone.0.cross_attn_projector.")]) == 1
|
||||
|
||||
def test_class_count_missing_model_key_no_warning(self, caplog):
|
||||
"""Checkpoint without 'model' key — no warning (backward compat)."""
|
||||
ckpt_args = SimpleNamespace(segmentation_head=False, patch_size=14)
|
||||
checkpoint = {"args": ckpt_args}
|
||||
model_args = SimpleNamespace(segmentation_head=False, patch_size=14, num_classes=90)
|
||||
|
||||
rf_detr_logger = logging.getLogger("rf-detr")
|
||||
prev_propagate = rf_detr_logger.propagate
|
||||
rf_detr_logger.propagate = True
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger="rf-detr"):
|
||||
validate_checkpoint_compatibility(checkpoint, model_args)
|
||||
finally:
|
||||
rf_detr_logger.propagate = prev_propagate
|
||||
|
||||
warning_msgs = [r.getMessage() for r in caplog.records if r.name == "rf-detr" and r.levelno >= logging.WARNING]
|
||||
assert not warning_msgs, f"Expected no warnings, got: {warning_msgs}"
|
||||
|
||||
|
||||
class TestStripCheckpoint:
|
||||
"""Tests for strip_checkpoint loop-stub backfill."""
|
||||
|
||||
def _make_minimal_ckpt(self, tmp_path, extra: dict | None = None) -> Path:
|
||||
"""Write a minimal checkpoint to a temp file."""
|
||||
payload = {"model": {"w": torch.tensor(1.0)}, "args": {"lr": 1e-4}}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
ckpt_path = Path(tmp_path) / "ckpt.pth"
|
||||
torch.save(payload, ckpt_path)
|
||||
return ckpt_path
|
||||
|
||||
def test_strip_adds_validate_loop_stub_when_loops_present_but_missing_key(self, tmp_path) -> None:
|
||||
"""Old checkpoints with loops but no validate_loop/test_loop get stubs backfilled."""
|
||||
ckpt_path = self._make_minimal_ckpt(
|
||||
tmp_path,
|
||||
extra={"loops": {"fit_loop": {"state_dict": {}}}},
|
||||
)
|
||||
strip_checkpoint(ckpt_path)
|
||||
result = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
||||
assert result["loops"]["validate_loop"] == {"state_dict": {}}
|
||||
assert result["loops"]["test_loop"] == {"state_dict": {}}
|
||||
|
||||
def test_strip_preserves_existing_validate_loop_stub(self, tmp_path) -> None:
|
||||
"""Checkpoints with validate_loop already present are not overwritten."""
|
||||
original_stub = {"state_dict": {"some_key": 1}}
|
||||
ckpt_path = self._make_minimal_ckpt(
|
||||
tmp_path,
|
||||
extra={"loops": {"fit_loop": {"state_dict": {}}, "validate_loop": original_stub}},
|
||||
)
|
||||
strip_checkpoint(ckpt_path)
|
||||
result = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
||||
assert result["loops"]["validate_loop"] == original_stub
|
||||
|
||||
def test_strip_no_loops_key_leaves_loops_absent(self, tmp_path) -> None:
|
||||
"""Checkpoints without a loops key must not gain one after stripping."""
|
||||
ckpt_path = self._make_minimal_ckpt(tmp_path)
|
||||
strip_checkpoint(ckpt_path)
|
||||
result = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
||||
assert "loops" not in result
|
||||
@@ -0,0 +1,560 @@
|
||||
# ------------------------------------------------------------------------
|
||||
# RF-DETR
|
||||
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
||||
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
||||
# ------------------------------------------------------------------------
|
||||
"""Tests for rfdetr.utilities.tensors.
|
||||
|
||||
Covers:
|
||||
- ``_bilinear_grid_sample`` parity (manual gather path vs ``F.grid_sample``).
|
||||
- ``nested_tensor_from_tensor_list`` with ``block_size`` (backbone-aware batch rounding).
|
||||
- ``make_collate_fn`` factory.
|
||||
"""
|
||||
|
||||
import pickle
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F # noqa: N812
|
||||
import torch.testing
|
||||
|
||||
from rfdetr.utilities.tensors import (
|
||||
_bilinear_grid_sample,
|
||||
make_collate_fn,
|
||||
nested_tensor_from_tensor_list,
|
||||
)
|
||||
|
||||
|
||||
def _grid_sample_reference(
|
||||
input: torch.Tensor,
|
||||
grid: torch.Tensor,
|
||||
padding_mode: str = "zeros",
|
||||
align_corners: bool = False,
|
||||
) -> torch.Tensor:
|
||||
"""Ground-truth output from F.grid_sample for comparison."""
|
||||
return F.grid_sample(
|
||||
input,
|
||||
grid,
|
||||
mode="bilinear",
|
||||
padding_mode=padding_mode,
|
||||
align_corners=align_corners,
|
||||
)
|
||||
|
||||
|
||||
def _call_manual_path(
|
||||
input: torch.Tensor,
|
||||
grid: torch.Tensor,
|
||||
padding_mode: str = "zeros",
|
||||
align_corners: bool = False,
|
||||
) -> torch.Tensor:
|
||||
"""Force the manual gather-based code path by mocking input.device.type.
|
||||
|
||||
The function checks ``input.device.type != "mps"`` to decide which branch to take. We patch ``torch.Tensor.device``
|
||||
to return an object whose ``.type`` is ``"mps"`` so the manual path runs on a normal CPU tensor.
|
||||
"""
|
||||
|
||||
class _FakeMPSDevice:
|
||||
type = "mps"
|
||||
|
||||
def __eq__(self, other):
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return "device(type='mps')"
|
||||
|
||||
with patch.object(torch.Tensor, "device", new_callable=lambda: property(lambda self: _FakeMPSDevice())):
|
||||
return _bilinear_grid_sample(input, grid, padding_mode=padding_mode, align_corners=align_corners)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed():
|
||||
"""Fix random seed for reproducible grid/input generation."""
|
||||
torch.manual_seed(42)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test scenarios as parametrize parameters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PADDING_ALIGN_COMBOS = [
|
||||
pytest.param("zeros", False, id="zeros-no_align"),
|
||||
pytest.param("border", False, id="border-no_align"),
|
||||
pytest.param("zeros", True, id="zeros-align_corners"),
|
||||
]
|
||||
|
||||
_LOW_PRECISION_DTYPES = [
|
||||
pytest.param(torch.float16, id="float16"),
|
||||
pytest.param(torch.bfloat16, id="bfloat16"),
|
||||
]
|
||||
|
||||
_LOW_PRECISION_GRAD_TOLERANCES = {
|
||||
torch.float16: (1e-2, 2e-2),
|
||||
torch.bfloat16: (3e-2, 1e-1),
|
||||
}
|
||||
|
||||
|
||||
def _require_grid_sample_dtype_support(dtype: torch.dtype) -> None:
|
||||
"""Skip test when current backend does not support grid_sample for dtype."""
|
||||
input = torch.randn(1, 1, 2, 2, dtype=dtype, requires_grad=True)
|
||||
grid = (torch.rand(1, 1, 1, 2, dtype=dtype) * 1.6 - 0.8).requires_grad_(True)
|
||||
try:
|
||||
out = F.grid_sample(input, grid, mode="bilinear", padding_mode="zeros", align_corners=False)
|
||||
out.backward(torch.ones_like(out))
|
||||
except RuntimeError as error:
|
||||
pytest.skip(f"grid_sample dtype support missing for {dtype}: {error}")
|
||||
|
||||
|
||||
class TestBilinearGridSampleParity:
|
||||
"""Manual gather path must match F.grid_sample for all grid/padding combos."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"padding_mode, align_corners",
|
||||
_PADDING_ALIGN_COMBOS,
|
||||
)
|
||||
def test_interior_grid_coordinates(self, seed, padding_mode, align_corners):
|
||||
"""Grid values well inside [-1, 1] -- pure interpolation, no boundary effects."""
|
||||
input = torch.randn(1, 3, 8, 8)
|
||||
# Grid in [-0.8, 0.8] -- safely inside
|
||||
grid = torch.rand(1, 4, 4, 2) * 1.6 - 0.8
|
||||
|
||||
expected = _grid_sample_reference(input, grid, padding_mode, align_corners)
|
||||
actual = _call_manual_path(input, grid, padding_mode, align_corners)
|
||||
|
||||
torch.testing.assert_close(actual, expected, atol=1e-5, rtol=1e-5)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"padding_mode, align_corners",
|
||||
_PADDING_ALIGN_COMBOS,
|
||||
)
|
||||
def test_partially_outside_grid_coordinates(self, seed, padding_mode, align_corners):
|
||||
"""Grid values spanning [-1.5, 1.5] -- some samples fall outside the image."""
|
||||
input = torch.randn(1, 3, 8, 8)
|
||||
grid = torch.rand(1, 6, 6, 2) * 3.0 - 1.5
|
||||
|
||||
expected = _grid_sample_reference(input, grid, padding_mode, align_corners)
|
||||
actual = _call_manual_path(input, grid, padding_mode, align_corners)
|
||||
|
||||
torch.testing.assert_close(actual, expected, atol=1e-5, rtol=1e-5)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"padding_mode, align_corners",
|
||||
_PADDING_ALIGN_COMBOS,
|
||||
)
|
||||
def test_exact_boundary_grid_values(self, seed, padding_mode, align_corners):
|
||||
"""Grid values at exact boundaries: -1.0, 0.0, 1.0."""
|
||||
input = torch.randn(1, 2, 4, 4)
|
||||
# Manually craft grid with boundary values
|
||||
coords = torch.tensor([-1.0, 0.0, 1.0])
|
||||
grid_y, grid_x = torch.meshgrid(coords, coords, indexing="ij")
|
||||
grid = torch.stack([grid_x, grid_y], dim=-1).unsqueeze(0) # (1, 3, 3, 2)
|
||||
|
||||
expected = _grid_sample_reference(input, grid, padding_mode, align_corners)
|
||||
actual = _call_manual_path(input, grid, padding_mode, align_corners)
|
||||
|
||||
torch.testing.assert_close(actual, expected, atol=1e-5, rtol=1e-5)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"padding_mode, align_corners",
|
||||
_PADDING_ALIGN_COMBOS,
|
||||
)
|
||||
def test_single_pixel_input(self, padding_mode, align_corners):
|
||||
"""1x1 spatial input -- extreme edge case for index arithmetic."""
|
||||
input = torch.tensor([[[[3.14]]]]) # (1, 1, 1, 1)
|
||||
grid = torch.tensor([[[[0.0, 0.0]]]]) # (1, 1, 1, 2) -- center
|
||||
|
||||
expected = _grid_sample_reference(input, grid, padding_mode, align_corners)
|
||||
actual = _call_manual_path(input, grid, padding_mode, align_corners)
|
||||
|
||||
torch.testing.assert_close(actual, expected, atol=1e-5, rtol=1e-5)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"padding_mode, align_corners",
|
||||
_PADDING_ALIGN_COMBOS,
|
||||
)
|
||||
def test_batch_and_multichannel(self, seed, padding_mode, align_corners):
|
||||
"""Batch size > 1 and multiple channels."""
|
||||
input = torch.randn(3, 5, 10, 12)
|
||||
grid = torch.rand(3, 7, 9, 2) * 2.0 - 1.0 # [-1, 1]
|
||||
|
||||
expected = _grid_sample_reference(input, grid, padding_mode, align_corners)
|
||||
actual = _call_manual_path(input, grid, padding_mode, align_corners)
|
||||
|
||||
torch.testing.assert_close(actual, expected, atol=1e-5, rtol=1e-5)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"padding_mode, align_corners",
|
||||
_PADDING_ALIGN_COMBOS,
|
||||
)
|
||||
def test_all_out_of_bounds(self, padding_mode, align_corners):
|
||||
"""All grid coordinates far outside [-1, 1] -- tests OOB handling."""
|
||||
input = torch.randn(1, 2, 4, 4)
|
||||
# All coordinates at +5.0 -- far outside
|
||||
grid = torch.full((1, 3, 3, 2), 5.0)
|
||||
|
||||
expected = _grid_sample_reference(input, grid, padding_mode, align_corners)
|
||||
actual = _call_manual_path(input, grid, padding_mode, align_corners)
|
||||
|
||||
torch.testing.assert_close(actual, expected, atol=1e-5, rtol=1e-5)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"padding_mode, align_corners",
|
||||
_PADDING_ALIGN_COMBOS,
|
||||
)
|
||||
def test_negative_out_of_bounds(self, padding_mode, align_corners):
|
||||
"""All grid coordinates at -5.0 -- far outside on the negative side."""
|
||||
input = torch.randn(1, 2, 4, 4)
|
||||
grid = torch.full((1, 3, 3, 2), -5.0)
|
||||
|
||||
expected = _grid_sample_reference(input, grid, padding_mode, align_corners)
|
||||
actual = _call_manual_path(input, grid, padding_mode, align_corners)
|
||||
|
||||
torch.testing.assert_close(actual, expected, atol=1e-5, rtol=1e-5)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"padding_mode, align_corners",
|
||||
[
|
||||
pytest.param("zeros", False, id="zeros-no_align"),
|
||||
pytest.param("border", False, id="border-no_align"),
|
||||
],
|
||||
)
|
||||
def test_non_square_spatial_dimensions(self, seed, padding_mode, align_corners):
|
||||
"""Non-square H != W input -- tests that x/y coordinate handling is correct."""
|
||||
input = torch.randn(1, 2, 5, 13) # tall vs wide
|
||||
grid = torch.rand(1, 4, 6, 2) * 2.0 - 1.0
|
||||
|
||||
expected = _grid_sample_reference(input, grid, padding_mode, align_corners)
|
||||
actual = _call_manual_path(input, grid, padding_mode, align_corners)
|
||||
|
||||
torch.testing.assert_close(actual, expected, atol=1e-5, rtol=1e-5)
|
||||
|
||||
|
||||
class TestBilinearGridSampleDelegation:
|
||||
"""On non-MPS devices, the function delegates directly to F.grid_sample."""
|
||||
|
||||
def test_cpu_delegates_to_grid_sample(self, seed):
|
||||
"""On CPU, output should match F.grid_sample exactly (same code path)."""
|
||||
input = torch.randn(1, 3, 8, 8)
|
||||
grid = torch.rand(1, 4, 4, 2) * 2.0 - 1.0
|
||||
|
||||
expected = _grid_sample_reference(input, grid, "zeros", False)
|
||||
actual = _bilinear_grid_sample(input, grid, padding_mode="zeros", align_corners=False)
|
||||
|
||||
torch.testing.assert_close(actual, expected, atol=0, rtol=0)
|
||||
|
||||
def test_cpu_border_delegates_to_grid_sample(self, seed):
|
||||
"""On CPU with border padding, output matches F.grid_sample exactly."""
|
||||
input = torch.randn(2, 4, 6, 6)
|
||||
grid = torch.rand(2, 3, 3, 2) * 3.0 - 1.5
|
||||
|
||||
expected = _grid_sample_reference(input, grid, "border", False)
|
||||
actual = _bilinear_grid_sample(input, grid, padding_mode="border", align_corners=False)
|
||||
|
||||
torch.testing.assert_close(actual, expected, atol=0, rtol=0)
|
||||
|
||||
|
||||
class TestBilinearGridSampleOutputShape:
|
||||
"""Output shape must be (N, C, Hg, Wg) for all inputs."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"n, c, h, w, hg, wg",
|
||||
[
|
||||
pytest.param(1, 1, 1, 1, 1, 1, id="minimal"),
|
||||
pytest.param(1, 3, 8, 8, 4, 4, id="standard"),
|
||||
pytest.param(2, 5, 10, 12, 7, 9, id="batch_multichannel"),
|
||||
pytest.param(1, 1, 3, 7, 5, 5, id="non_square"),
|
||||
],
|
||||
)
|
||||
def test_output_shape(self, n, c, h, w, hg, wg):
|
||||
"""Manual path output shape is (N, C, Hg, Wg)."""
|
||||
input = torch.randn(n, c, h, w)
|
||||
grid = torch.rand(n, hg, wg, 2) * 2.0 - 1.0
|
||||
|
||||
actual = _call_manual_path(input, grid)
|
||||
assert actual.shape == (n, c, hg, wg), f"Expected shape ({n}, {c}, {hg}, {wg}), got {actual.shape}"
|
||||
|
||||
|
||||
class TestBilinearGridSampleGradient:
|
||||
"""Gradient correctness for the manual gather path."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"padding_mode, align_corners",
|
||||
[
|
||||
pytest.param("zeros", False, id="zeros-no_align"),
|
||||
pytest.param("border", False, id="border-no_align"),
|
||||
pytest.param("zeros", True, id="zeros-align_corners"),
|
||||
],
|
||||
)
|
||||
def test_gradient_matches_grid_sample(self, seed, padding_mode, align_corners):
|
||||
"""Gradients from manual path match those from F.grid_sample."""
|
||||
input_ref = torch.randn(1, 2, 6, 6, requires_grad=True)
|
||||
grid_ref = (torch.rand(1, 4, 4, 2) * 1.6 - 0.8).requires_grad_(True)
|
||||
|
||||
# Clone for manual path
|
||||
input_man = input_ref.detach().clone().requires_grad_(True)
|
||||
grid_man = grid_ref.detach().clone().requires_grad_(True)
|
||||
|
||||
# Forward
|
||||
out_ref = _grid_sample_reference(input_ref, grid_ref, padding_mode, align_corners)
|
||||
out_man = _call_manual_path(input_man, grid_man, padding_mode, align_corners)
|
||||
|
||||
# Backward with same upstream gradient
|
||||
upstream = torch.randn_like(out_ref)
|
||||
out_ref.backward(upstream)
|
||||
out_man.backward(upstream)
|
||||
|
||||
torch.testing.assert_close(
|
||||
input_man.grad,
|
||||
input_ref.grad,
|
||||
atol=1e-5,
|
||||
rtol=1e-5,
|
||||
msg="Input gradient mismatch between manual path and F.grid_sample",
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
grid_man.grad,
|
||||
grid_ref.grad,
|
||||
atol=1e-5,
|
||||
rtol=1e-5,
|
||||
msg="Grid gradient mismatch between manual path and F.grid_sample",
|
||||
)
|
||||
|
||||
def test_gradcheck_manual_path(self, seed):
|
||||
"""torch.autograd.gradcheck passes on the manual path (double precision)."""
|
||||
input = torch.randn(1, 1, 4, 4, dtype=torch.float64, requires_grad=True)
|
||||
grid = (torch.rand(1, 3, 3, 2, dtype=torch.float64) * 1.6 - 0.8).requires_grad_(True)
|
||||
|
||||
assert torch.autograd.gradcheck(
|
||||
lambda inp, grd: _call_manual_path(inp, grd, padding_mode="zeros", align_corners=False),
|
||||
(input, grid),
|
||||
eps=1e-6,
|
||||
atol=1e-4,
|
||||
rtol=1e-3,
|
||||
), "gradcheck failed for manual bilinear grid sample path"
|
||||
|
||||
|
||||
class TestBilinearGridSampleLowPrecision:
|
||||
"""Low-precision parity and gradients stay aligned with F.grid_sample."""
|
||||
|
||||
@pytest.mark.parametrize("dtype", _LOW_PRECISION_DTYPES)
|
||||
def test_low_precision_parity(self, seed, dtype):
|
||||
"""Manual path output matches F.grid_sample for low-precision inputs."""
|
||||
_require_grid_sample_dtype_support(dtype)
|
||||
|
||||
input = torch.randn(2, 3, 6, 6, dtype=dtype)
|
||||
grid = torch.rand(2, 4, 4, 2, dtype=dtype) * 3.0 - 1.5
|
||||
|
||||
expected = _grid_sample_reference(input, grid, padding_mode="zeros", align_corners=False)
|
||||
actual = _call_manual_path(input, grid, padding_mode="zeros", align_corners=False)
|
||||
|
||||
torch.testing.assert_close(actual, expected, atol=1e-3, rtol=1e-3)
|
||||
assert actual.dtype == dtype
|
||||
|
||||
@pytest.mark.parametrize("dtype", _LOW_PRECISION_DTYPES)
|
||||
def test_low_precision_gradient_parity(self, seed, dtype):
|
||||
"""Manual path gradients match F.grid_sample gradients for low precision."""
|
||||
_require_grid_sample_dtype_support(dtype)
|
||||
atol, rtol = _LOW_PRECISION_GRAD_TOLERANCES[dtype]
|
||||
|
||||
input_ref = torch.randn(1, 2, 6, 6, dtype=dtype, requires_grad=True)
|
||||
grid_ref = (torch.rand(1, 4, 4, 2, dtype=dtype) * 1.6 - 0.8).requires_grad_(True)
|
||||
|
||||
input_man = input_ref.detach().clone().requires_grad_(True)
|
||||
grid_man = grid_ref.detach().clone().requires_grad_(True)
|
||||
|
||||
out_ref = _grid_sample_reference(input_ref, grid_ref, padding_mode="zeros", align_corners=False)
|
||||
out_man = _call_manual_path(input_man, grid_man, padding_mode="zeros", align_corners=False)
|
||||
|
||||
upstream = torch.randn_like(out_ref)
|
||||
out_ref.backward(upstream)
|
||||
out_man.backward(upstream)
|
||||
|
||||
torch.testing.assert_close(input_man.grad, input_ref.grad, atol=atol, rtol=rtol)
|
||||
torch.testing.assert_close(grid_man.grad, grid_ref.grad, atol=atol, rtol=rtol)
|
||||
assert input_man.grad is not None
|
||||
assert grid_man.grad is not None
|
||||
assert input_man.grad.dtype == dtype
|
||||
assert grid_man.grad.dtype == dtype
|
||||
|
||||
|
||||
class TestBilinearGridSampleRealUseCases:
|
||||
"""Parity tests matching the actual call sites in the codebase."""
|
||||
|
||||
def test_ms_deform_attn_pattern(self, seed):
|
||||
"""Matches ms_deform_attn_func: padding_mode='zeros', align_corners=False.
|
||||
|
||||
The attention function passes (B*n_heads, head_dim, H, W) input and (B*n_heads, Len_q, P, 2) grid.
|
||||
"""
|
||||
# Simulate B=2, n_heads=8, head_dim=32
|
||||
input = torch.randn(16, 32, 14, 14)
|
||||
grid = torch.rand(16, 100, 4, 2) * 2.0 - 1.0
|
||||
|
||||
expected = _grid_sample_reference(input, grid, "zeros", False)
|
||||
actual = _call_manual_path(input, grid, "zeros", False)
|
||||
|
||||
torch.testing.assert_close(actual, expected, atol=1e-5, rtol=1e-5)
|
||||
|
||||
def test_point_sample_pattern(self, seed):
|
||||
"""Matches point_sample in segmentation: padding_mode='border', align_corners=False.
|
||||
|
||||
point_sample transforms point_coords via ``2.0 * point_coords - 1.0`` to map [0, 1] -> [-1, 1].
|
||||
"""
|
||||
input = torch.randn(4, 256, 28, 28)
|
||||
# Simulate point_coords in [0, 1], transformed to [-1, 1]
|
||||
point_coords_01 = torch.rand(4, 12544, 1, 2)
|
||||
grid = 2.0 * point_coords_01 - 1.0
|
||||
|
||||
expected = _grid_sample_reference(input, grid, "border", False)
|
||||
actual = _call_manual_path(input, grid, "border", False)
|
||||
|
||||
torch.testing.assert_close(actual, expected, atol=1e-5, rtol=1e-5)
|
||||
|
||||
|
||||
class TestNestedTensorBlockSize:
|
||||
"""``nested_tensor_from_tensor_list`` with block_size rounds batch max H/W up.
|
||||
|
||||
This is the collator-level pad for backbone divisibility. The rounded-up strip must be marked as padding in the
|
||||
mask so downstream attention skips it. See
|
||||
https://github.com/roboflow/rf-detr/issues/983
|
||||
for context.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _image(c: int, h: int, w: int, fill: float = 1.0) -> torch.Tensor:
|
||||
"""Return a ``(C, H, W)`` float32 tensor filled with the given value."""
|
||||
return torch.full((c, h, w), fill, dtype=torch.float32)
|
||||
|
||||
def test_block_size_none_preserves_old_behavior(self) -> None:
|
||||
"""Without block_size, the batch tensor is exactly batch-max H/W."""
|
||||
images = [self._image(3, 100, 200), self._image(3, 150, 180)]
|
||||
nested = nested_tensor_from_tensor_list(images)
|
||||
_, _, h, w = nested.tensors.shape
|
||||
assert (h, w) == (150, 200)
|
||||
# Mask reflects per-image sizes (no block rounding).
|
||||
assert nested.mask[0, :100, :200].any().item() is False
|
||||
assert nested.mask[0, 100:, :].all().item() is True
|
||||
assert nested.mask[1, :150, :180].any().item() is False
|
||||
assert nested.mask[1, :, 180:].all().item() is True
|
||||
|
||||
def test_block_size_rounds_up(self) -> None:
|
||||
"""Batch-max is rounded up to the next multiple of block_size."""
|
||||
images = [self._image(3, 100, 200), self._image(3, 150, 180)]
|
||||
nested = nested_tensor_from_tensor_list(images, block_size=32)
|
||||
_, _, h, w = nested.tensors.shape
|
||||
# max_h=150 -> 160, max_w=200 -> 224
|
||||
assert (h, w) == (160, 224)
|
||||
|
||||
def test_block_size_equal_to_max_is_noop(self) -> None:
|
||||
"""When batch max already matches a multiple of block_size, no extra rounding."""
|
||||
images = [self._image(3, 128, 256)]
|
||||
nested = nested_tensor_from_tensor_list(images, block_size=32)
|
||||
_, _, h, w = nested.tensors.shape
|
||||
assert (h, w) == (128, 256)
|
||||
|
||||
def test_divisor_pad_marked_in_mask(self) -> None:
|
||||
"""All padded cells (both batch-level and divisor round-up) are marked True in the mask."""
|
||||
images = [self._image(3, 100, 200)]
|
||||
nested = nested_tensor_from_tensor_list(images, block_size=32)
|
||||
tensor = nested.tensors[0]
|
||||
mask = nested.mask[0]
|
||||
|
||||
# Content region is the original 100x200; mask[:100, :200] must be False.
|
||||
assert mask[:100, :200].any().item() is False
|
||||
# The rounded-up strip (100:128 rows, 200:224 cols) must be True.
|
||||
assert mask[100:, :].all().item() is True
|
||||
assert mask[:, 200:].all().item() is True
|
||||
|
||||
# Content region is the original fill; pad region is zero.
|
||||
assert torch.all(tensor[:, :100, :200] == 1.0)
|
||||
assert torch.all(tensor[:, 100:, :] == 0.0)
|
||||
assert torch.all(tensor[:, :, 200:] == 0.0)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"block_size,shape,expected",
|
||||
[
|
||||
pytest.param(32, (100, 100), (128, 128), id="both-rounded"),
|
||||
pytest.param(32, (128, 200), (128, 224), id="h-aligned-w-rounded"),
|
||||
pytest.param(32, (100, 256), (128, 256), id="h-rounded-w-aligned"),
|
||||
pytest.param(56, (100, 100), (112, 112), id="patch14-num-windows4"),
|
||||
pytest.param(64, (100, 100), (128, 128), id="block-size-64"),
|
||||
],
|
||||
)
|
||||
def test_single_image_rounding_parametrized(self, block_size: int, shape: tuple, expected: tuple) -> None:
|
||||
"""Single-image batch; round-up applied correctly for various block sizes."""
|
||||
images = [self._image(3, shape[0], shape[1])]
|
||||
nested = nested_tensor_from_tensor_list(images, block_size=block_size)
|
||||
_, _, h, w = nested.tensors.shape
|
||||
assert (h, w) == expected
|
||||
|
||||
|
||||
class TestMakeCollateFn:
|
||||
"""``make_collate_fn`` returns a picklable collate callable with block_size rounding baked in."""
|
||||
|
||||
@staticmethod
|
||||
def _batch(*shapes: tuple[int, ...]) -> list[tuple[torch.Tensor, dict]]:
|
||||
"""Build a list of ``(tensor, target_dict)`` pairs with given shapes.
|
||||
|
||||
Args:
|
||||
*shapes: Variadic sequence of ``(C, H, W)`` shapes, one per image.
|
||||
|
||||
Returns:
|
||||
List of ``(image_tensor, target_dict)`` pairs ready to pass to a collate callable.
|
||||
"""
|
||||
batch = []
|
||||
for shape in shapes:
|
||||
img = torch.full(shape, 1.0, dtype=torch.float32)
|
||||
target = {"boxes": torch.zeros((0, 4)), "labels": torch.zeros((0,), dtype=torch.long)}
|
||||
batch.append((img, target))
|
||||
return batch
|
||||
|
||||
def test_default_block_size_none_behaves_like_collate_fn(self) -> None:
|
||||
"""With block_size=None, the factory returns a collate equivalent to the default."""
|
||||
collate = make_collate_fn() # block_size=None
|
||||
samples, targets = collate(self._batch((3, 100, 200), (3, 150, 180)))
|
||||
_, _, h, w = samples.tensors.shape
|
||||
assert (h, w) == (150, 200) # exact batch max
|
||||
assert len(targets) == 2
|
||||
|
||||
def test_block_size_rounds_up_batch_max(self) -> None:
|
||||
"""Factory with block_size=32 rounds batch-max up to 32-multiples."""
|
||||
collate = make_collate_fn(block_size=32)
|
||||
samples, _ = collate(self._batch((3, 100, 200), (3, 150, 180)))
|
||||
_, _, h, w = samples.tensors.shape
|
||||
assert (h, w) == (160, 224)
|
||||
|
||||
def test_targets_passed_through(self) -> None:
|
||||
"""Factory collator preserves the list-of-targets second element."""
|
||||
collate = make_collate_fn(block_size=32)
|
||||
samples, targets = collate(self._batch((3, 100, 200), (3, 150, 180)))
|
||||
assert isinstance(targets, tuple)
|
||||
assert len(targets) == 2
|
||||
for t in targets:
|
||||
assert set(t.keys()) == {"boxes", "labels"}
|
||||
|
||||
def test_mixed_landscape_portrait_batch_masked_correctly(self) -> None:
|
||||
"""Mixed-orientation batch: all pad (batch + divisor) correctly marked True in mask."""
|
||||
# landscape (H=100, W=200) and portrait (H=200, W=100). block_size=32 rounds
|
||||
# batch max (200, 200) to (224, 224).
|
||||
collate = make_collate_fn(block_size=32)
|
||||
samples, _ = collate(self._batch((3, 100, 200), (3, 200, 100)))
|
||||
_, _, h, w = samples.tensors.shape
|
||||
assert (h, w) == (224, 224)
|
||||
|
||||
# Each image's content region equals its original shape; everything else is pad.
|
||||
mask_a = samples.mask[0]
|
||||
mask_b = samples.mask[1]
|
||||
assert mask_a[:100, :200].any().item() is False
|
||||
assert mask_a[100:, :].all().item() is True
|
||||
assert mask_a[:, 200:].all().item() is True
|
||||
assert mask_b[:200, :100].any().item() is False
|
||||
assert mask_b[200:, :].all().item() is True
|
||||
assert mask_b[:, 100:].all().item() is True
|
||||
|
||||
def test_make_collate_fn_is_picklable(self) -> None:
|
||||
"""make_collate_fn returns a functools.partial picklable for num_workers > 0."""
|
||||
collate = make_collate_fn(block_size=32)
|
||||
assert pickle.dumps(collate) is not None
|
||||
Reference in New Issue
Block a user