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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:26:24 +08:00
commit 16031aae96
343 changed files with 88674 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
+5
View File
@@ -0,0 +1,5 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
+119
View File
@@ -0,0 +1,119 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for dual-projector backbone joiner routing."""
from __future__ import annotations
import torch
from torch import nn
from rfdetr.models.backbone import Joiner
from rfdetr.utilities.tensors import NestedTensor
class _FakeBackbone(nn.Module):
"""Backbone shim used to validate Joiner contract changes."""
def __init__(
self,
features: list[NestedTensor],
cross_attention_features: list[object] | None,
) -> None:
super().__init__()
self._features = features
self._cross_attention_features = cross_attention_features
def forward(self, tensor: torch.Tensor | NestedTensor):
if isinstance(tensor, torch.Tensor):
feats = [f.tensors for f in self._features]
masks = [f.mask for f in self._features]
return feats, masks, self._cross_attention_features
return self._features, self._cross_attention_features
class _FakePositionEncoding(nn.Module):
"""Tiny callable that behaves like a position encoder."""
def forward(self, nested_tensor: NestedTensor | torch.Tensor, align_dim_orders: bool = False) -> torch.Tensor:
if isinstance(nested_tensor, NestedTensor):
base = nested_tensor.tensors
else:
base = nested_tensor
if base.dim() == 3:
base = base[:, None]
return torch.zeros((base.shape[0], 1, base.shape[-2], base.shape[-1]), dtype=base.dtype, device=base.device)
def _feature(shape: tuple[int, ...], batch_size: int = 2) -> NestedTensor:
channels, height, width = shape
return NestedTensor(
tensors=torch.ones((batch_size, channels, height, width), dtype=torch.float32),
mask=torch.zeros((batch_size, height, width), dtype=torch.bool),
)
def _input_tensor(batch_size: int = 2) -> tuple[NestedTensor, torch.Tensor]:
return (
NestedTensor(
tensors=torch.ones((batch_size, 3, 16, 16), dtype=torch.float32),
mask=torch.zeros((batch_size, 16, 16), dtype=torch.bool),
),
torch.ones((batch_size, 3, 16, 16), dtype=torch.float32),
)
def test_joiner_dual_projector_disabled_contract() -> None:
"""Joiner should forward one feature stream and a ``None`` cross-attention stream when disabled."""
features = [_feature((256, 16, 16))]
joiner = Joiner(_FakeBackbone(features, None), _FakePositionEncoding())
input_tensor, image = _input_tensor()
_, _, cross_attention = joiner(input_tensor)
assert cross_attention is None
assert len(joiner(input_tensor)[0]) == 1
exported = joiner.forward_export(image)
assert exported[3] is None
assert len(exported[0]) == 1
assert exported[2][0].shape == (2, 16, 16)
def test_joiner_dual_projector_enabled_contract() -> None:
"""Joiner should forward cross-attention features in parallel with feature features when enabled."""
features = [_feature((256, 16, 16)), _feature((256, 8, 8))]
cross_attention_features = [_feature((256, 16, 16)), _feature((256, 8, 8))]
joiner = Joiner(_FakeBackbone(features, cross_attention_features), _FakePositionEncoding())
input_tensor, _ = _input_tensor()
feature_tensors, _, cross_attention = joiner(input_tensor)
assert len(feature_tensors) == len(cross_attention)
assert all(f.tensors.shape == c.tensors.shape for f, c in zip(feature_tensors, cross_attention))
assert all(f.mask is not None for f in cross_attention)
def test_joiner_forward_export_contract() -> None:
"""Exported joiner contracts should remain 4-tuples and preserve cross-attention stream arity."""
exported_features = [torch.ones(2, 256, 16, 16), torch.ones(2, 256, 8, 8)]
exported_masks = [torch.zeros(2, 16, 16, dtype=torch.bool), torch.zeros(2, 8, 8, dtype=torch.bool)]
export_backbone = _FakeBackbone(
[NestedTensor(t, mask) for t, mask in zip(exported_features, exported_masks)],
[torch.ones(2, 256, 16, 16), torch.ones(2, 256, 8, 8)],
)
joiner = Joiner(export_backbone, _FakePositionEncoding())
outputs = joiner.forward_export(torch.ones(2, 3, 16, 16))
feats_out, masks_out, poss, cross_attention = outputs
assert len(feats_out) == len(exported_features)
assert len(masks_out) == len(exported_masks)
assert feats_out[0].shape == exported_features[0].shape
assert masks_out[0].shape == exported_masks[0].shape
assert len(outputs) == 4
assert poss[0].shape == exported_features[0][:, :1, :, :].shape
assert isinstance(cross_attention, list)
assert all(isinstance(feature, torch.Tensor) for feature in cross_attention)
@@ -0,0 +1,52 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for backbone export behavior."""
import sys
from types import ModuleType
from rfdetr.models.backbone.backbone import Backbone
class TestBackboneExport:
"""Tests for ``Backbone.export``."""
def test_export_without_lora_encoder_skips_peft_import_and_warning(self, monkeypatch) -> None:
"""Non-LoRA exports should not warn just because peft is unavailable."""
backbone = object.__new__(Backbone)
backbone.encoder = object()
warning_messages: list[str] = []
monkeypatch.delitem(sys.modules, "peft", raising=False)
monkeypatch.setattr("rfdetr.models.backbone.backbone.logger.warning", warning_messages.append)
backbone.export()
assert warning_messages == []
def test_export_replaces_peft_encoder_with_merged_encoder(self, monkeypatch) -> None:
"""Export should replace PEFT wrapper with merged base encoder."""
class _MergedEncoder:
pass
class _FakePeftModel:
def __init__(self) -> None:
self._merged = _MergedEncoder()
def merge_and_unload(self):
return self._merged
peft_module = ModuleType("peft")
peft_module.PeftModel = _FakePeftModel
monkeypatch.setitem(sys.modules, "peft", peft_module)
backbone = object.__new__(Backbone)
backbone.encoder = _FakePeftModel()
backbone.export()
assert isinstance(backbone.encoder, _MergedEncoder)
+544
View File
@@ -0,0 +1,544 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
import pytest
import torch
from rfdetr.models.backbone.dinov2_with_windowed_attn import (
Dinov2WithRegistersAttention,
Dinov2WithRegistersSdpaAttention,
WindowedDinov2WithRegistersBackbone,
WindowedDinov2WithRegistersConfig,
WindowedDinov2WithRegistersEmbeddings,
WindowedDinov2WithRegistersModel,
_find_pruneable_heads_and_indices,
_get_aligned_output_features_output_indices,
)
def test_window_partition_forward_rectangular_preserves_shapes():
"""Regression test for WindowedDinov2WithRegistersEmbeddings.forward with rectangular input.
Ensures window partitioning logic correctly handles H != W.
"""
# Params: H_patches=6, W_patches=4, num_windows=2 -> 3x2 patches per window
batch_size, hidden_size, patch_size, num_windows = 1, 64, 16, 2
hp, wp, nr = 6, 4, 4
h, w = hp * patch_size, wp * patch_size
config = WindowedDinov2WithRegistersConfig(
hidden_size=hidden_size,
patch_size=patch_size,
num_windows=num_windows,
image_size=h, # square image_size for positional embeddings
num_register_tokens=nr,
)
model = WindowedDinov2WithRegistersEmbeddings(config)
# Input is rectangular
pixel_values = torch.randn(batch_size, 3, h, w)
result = model(pixel_values)
expected_batch = batch_size * (num_windows**2)
expected_seq_len = 1 + nr + (hp // num_windows) * (wp // num_windows)
assert result.shape == (expected_batch, expected_seq_len, hidden_size)
# Before fix in PR #448 the reshape used num_h_patches_per_window in both the height
# AND width dimension. This only fails when height and width produce different patch
# counts, so all tests below use non-square images (hp != wp).
@pytest.mark.parametrize(
"hp, wp, num_windows",
[
(4, 6, 2), # wider than tall
(6, 4, 2), # taller than wide
(6, 9, 3), # 3-window grid, non-square
(8, 4, 2), # 2:1 aspect ratio
],
)
def test_window_partition_nonsquare_does_not_raise(hp, wp, num_windows):
"""Before the fix, the reshape used num_h_patches_per_window for the width dimension, so the total element count
mismatched and PyTorch raised a RuntimeError for any non-square image.
The fix replaces that variable with num_w_patches_per_window, making the operation valid for all shapes.
"""
hidden_size, patch_size, nr = 32, 16, 0
h, w = hp * patch_size, wp * patch_size
config = WindowedDinov2WithRegistersConfig(
hidden_size=hidden_size,
patch_size=patch_size,
num_windows=num_windows,
image_size=max(h, w),
num_register_tokens=nr,
)
model = WindowedDinov2WithRegistersEmbeddings(config)
pixel_values = torch.randn(1, 3, h, w)
# This line would raise RuntimeError before the fix
result = model(pixel_values)
expected_batch = num_windows**2
expected_seq_len = 1 + (hp // num_windows) * (wp // num_windows)
assert result.shape == (expected_batch, expected_seq_len, hidden_size)
def test_window_partition_correct_window_content():
"""Verifies that after windowing each window contains the spatially correct patch tokens — not just that the shape
is right.
Layout with hp=4, wp=6, num_windows=2 (2x2 grid of windows): Window (0,0): rows 0-1, cols 0-2 Window (0,1): rows
0-1, cols 3-5 Window (1,0): rows 2-3, cols 0-2 Window (1,1): rows 2-3, cols 3-5
Before the fix the reshape used num_h_patches_per_window for the width dim so it raised an error and never produced
window content at all.
"""
hidden_size, patch_size, num_windows, nr = 1, 16, 2, 0
hp, wp = 4, 6
h, w = hp * patch_size, wp * patch_size
batch_size = 1
config = WindowedDinov2WithRegistersConfig(
hidden_size=hidden_size,
patch_size=patch_size,
num_windows=num_windows,
image_size=max(h, w),
num_register_tokens=nr,
num_hidden_layers=1,
num_attention_heads=1,
)
model = WindowedDinov2WithRegistersEmbeddings(config)
# Disable position embeddings and cls token so we can track patch identity.
# Each patch gets a unique value equal to its flat index (row * wp + col).
with torch.no_grad():
model.position_embeddings.zero_()
model.cls_token.zero_()
# Build a synthetic patch embedding: patch at (row, col) has value row*wp+col.
# Shape after patch projection: (1, hp*wp, 1) — hidden_size=1 for simplicity.
patch_ids = torch.arange(hp * wp, dtype=torch.float).view(1, hp * wp, 1)
# Bypass the full forward pass and exercise the windowing logic directly.
pixel_tokens = patch_ids # (1, 24, 1)
pixel_tokens_2d = pixel_tokens.view(batch_size, hp, wp, hidden_size) # (1,4,6,1)
num_h_patches_per_window = hp // num_windows # 2
num_w_patches_per_window = wp // num_windows # 3
# --- correct reshape (the fix) ---
windowed = pixel_tokens_2d.reshape(
batch_size * num_windows,
num_h_patches_per_window,
num_windows,
num_w_patches_per_window,
hidden_size,
)
windowed = windowed.permute(0, 2, 1, 3, 4)
windowed = windowed.reshape(
batch_size * num_windows**2,
num_h_patches_per_window * num_w_patches_per_window,
hidden_size,
)
# Expected content for each of the 4 windows (6 patches each):
expected = torch.tensor(
[
# Window 0 (rows 0-1, cols 0-2): ids 0,1,2, 6,7,8
[[0.0], [1.0], [2.0], [6.0], [7.0], [8.0]],
# Window 1 (rows 0-1, cols 3-5): ids 3,4,5, 9,10,11
[[3.0], [4.0], [5.0], [9.0], [10.0], [11.0]],
# Window 2 (rows 2-3, cols 0-2): ids 12,13,14, 18,19,20
[[12.0], [13.0], [14.0], [18.0], [19.0], [20.0]],
# Window 3 (rows 2-3, cols 3-5): ids 15,16,17, 21,22,23
[[15.0], [16.0], [17.0], [21.0], [22.0], [23.0]],
]
)
assert torch.equal(windowed, expected), f"Window content mismatch:\n{windowed}\n!=\n{expected}"
def test_buggy_reshape_raises_for_nonsquare():
"""Directly demonstrates what the pre-fix code did: using num_h_patches_per_window in the width position of the
reshape causes a RuntimeError when the element count is not divisible by the (wrong) shape.
With hidden_size=1 and hp=4, wp=6, num_windows=2 the total elements are 24 but the buggy target dims (2,2,2,2,-1)
require a non-integer last dimension, so PyTorch raises RuntimeError.
"""
hp, wp = 4, 6 # non-square: width > height
num_windows = 2
hidden_size = 1 # chosen so total / buggy-fixed-dims is non-integer
num_h_patches_per_window = hp // num_windows # 2
num_w_patches_per_window = wp // num_windows # 3
batch_size = 1
# Simulate pixel_tokens_with_pos_embed after the .view() call
pixel_tokens_2d = torch.randn(batch_size, hp, wp, hidden_size)
# The correct reshape (post-fix) must succeed
pixel_tokens_2d.reshape(
batch_size * num_windows,
num_h_patches_per_window,
num_windows,
num_w_patches_per_window, # correct
hidden_size,
)
# The buggy reshape (pre-fix) must raise RuntimeError:
# total elements = 1*4*6*1 = 24, fixed-dims product = 2*2*2*2 = 16, 16 ∤ 24.
with pytest.raises(RuntimeError):
pixel_tokens_2d.reshape(
batch_size * num_windows,
num_h_patches_per_window,
num_windows,
num_h_patches_per_window, # bug: height used for width
-1,
)
def test_buggy_reshape_silent_corruption_for_nonsquare():
"""When hidden_size happens to make the total element count divisible by the buggy target shape, PyTorch does NOT
raise — instead the last dimension is inflated, which silently corrupts the tensor layout.
Pre-fix with hp=4, wp=6, hidden_size=8, num_windows=2: total elements = 1*4*6*8 = 192 buggy fixed dims = 2*2*2*2 =
16 → last dim inferred as 192/16 = 12 (not 8)
The fix ensures the correct reshape always yields a last dim equal to hidden_size.
"""
hp, wp = 4, 6
num_windows = 2
hidden_size = 8
num_h_patches_per_window = hp // num_windows # 2
num_w_patches_per_window = wp // num_windows # 3
batch_size = 1
pixel_tokens_2d = torch.randn(batch_size, hp, wp, hidden_size)
# Buggy reshape silently infers last dim = 12 (not 8)
buggy_out = pixel_tokens_2d.reshape(
batch_size * num_windows,
num_h_patches_per_window,
num_windows,
num_h_patches_per_window, # bug
-1,
)
assert buggy_out.shape[-1] != hidden_size, "Buggy reshape should produce wrong last dim"
# Correct reshape always yields last dim == hidden_size
correct_out = pixel_tokens_2d.reshape(
batch_size * num_windows,
num_h_patches_per_window,
num_windows,
num_w_patches_per_window, # fix
hidden_size,
)
assert correct_out.shape[-1] == hidden_size
# ---------------------------------------------------------------------------
# Tests for locally-copied utility functions (removed from transformers v5 public API)
# ---------------------------------------------------------------------------
class TestGetAlignedOutputFeaturesOutputIndices:
"""Tests for the local copy of get_aligned_output_features_output_indices."""
def test_both_none_returns_last_stage(self):
stage_names = ["stage1", "stage2", "stage3"]
features, indices = _get_aligned_output_features_output_indices(None, None, stage_names)
assert features == ["stage3"]
assert indices == [2]
def test_only_out_features_derives_indices(self):
stage_names = ["stem", "layer1", "layer2", "layer3"]
features, indices = _get_aligned_output_features_output_indices(["layer1", "layer3"], None, stage_names)
assert features == ["layer1", "layer3"]
assert indices == [1, 3]
def test_only_out_indices_derives_features(self):
stage_names = ["stem", "layer1", "layer2", "layer3"]
features, indices = _get_aligned_output_features_output_indices(None, [0, 2], stage_names)
assert features == ["stem", "layer2"]
assert indices == [0, 2]
def test_both_provided_returns_as_is(self):
stage_names = ["stem", "layer1", "layer2"]
features, indices = _get_aligned_output_features_output_indices(["layer1"], [1], stage_names)
assert features == ["layer1"]
assert indices == [1]
def test_out_indices_converted_to_list(self):
"""out_indices supplied as a tuple must be returned as a list."""
stage_names = ["stem", "layer1", "layer2"]
_, indices = _get_aligned_output_features_output_indices(None, (1, 2), stage_names)
assert isinstance(indices, list)
assert indices == [1, 2]
class TestFindPruneableHeadsAndIndices:
"""Tests for the local copy of find_pruneable_heads_and_indices."""
def test_no_pruning_returns_full_index(self):
heads, index = _find_pruneable_heads_and_indices(set(), n_heads=4, head_size=3, already_pruned_heads=set())
assert len(heads) == 0
assert len(index) == 12 # 4 * 3, nothing masked
@pytest.mark.parametrize(
"head_to_prune, expected_index",
[
pytest.param({0}, list(range(3, 12)), id="prune-first-head"),
pytest.param({3}, list(range(9)), id="prune-last-head"),
],
)
def test_prune_single_head_removes_correct_rows(self, head_to_prune, expected_index):
# Head N masked → N*head_size indices removed; remaining = n_heads*head_size - head_size = 9
heads, index = _find_pruneable_heads_and_indices(
head_to_prune, n_heads=4, head_size=3, already_pruned_heads=set()
)
assert heads == head_to_prune
assert len(index) == 9
assert index.tolist() == expected_index
def test_already_pruned_head_adjusts_offset(self):
# Head 0 was already pruned. Now pruning head 1 (which is now effective head 0
# after offset adjustment) should remove 3 more indices from the effective mask.
heads, index = _find_pruneable_heads_and_indices({1}, n_heads=4, head_size=3, already_pruned_heads={0})
assert 1 in heads
assert len(index) == 9 # 4*3 - 3 pruned
# ---------------------------------------------------------------------------
# Smoke tests for WindowedDinov2WithRegistersBackbone
# ---------------------------------------------------------------------------
def _minimal_backbone_config(**kwargs) -> WindowedDinov2WithRegistersConfig:
"""Return the smallest valid config for backbone instantiation tests."""
defaults = dict(
hidden_size=32,
num_hidden_layers=1,
num_attention_heads=2,
intermediate_size=64,
patch_size=16,
image_size=64,
num_register_tokens=0,
num_windows=1,
)
defaults.update(kwargs)
return WindowedDinov2WithRegistersConfig(**defaults)
class TestWindowedDinov2WithRegistersBackbone:
"""Smoke tests that guard against _init_transformers_backbone() API regressions."""
@pytest.mark.parametrize(
"attr",
[
pytest.param("stage_names", id="stage_names"),
pytest.param("out_features", id="out_features"),
],
)
def test_instantiation_sets_list_attribute(self, attr):
config = _minimal_backbone_config()
backbone = WindowedDinov2WithRegistersBackbone(config)
assert hasattr(backbone, attr)
assert isinstance(getattr(backbone, attr), list)
assert len(getattr(backbone, attr)) > 0
def test_forward_returns_backbone_output(self):
config = _minimal_backbone_config()
backbone = WindowedDinov2WithRegistersBackbone(config)
backbone.eval()
pixel_values = torch.randn(1, 3, 64, 64)
with torch.no_grad():
output = backbone(pixel_values)
assert hasattr(output, "feature_maps")
assert len(output.feature_maps) == len(backbone.out_features)
# ---------------------------------------------------------------------------
# Test for output_attentions=True SDPA fallback path
# ---------------------------------------------------------------------------
class TestSdpaFallbackWithOutputAttentions:
"""Guards the output_attentions behaviour in windowed attention."""
def test_output_attentions_true_raises(self):
"""Windowed attention explicitly does not support output_attentions=True."""
config = _minimal_backbone_config()
model = WindowedDinov2WithRegistersModel(config)
model.eval()
pixel_values = torch.randn(1, 3, 64, 64)
with torch.no_grad():
with pytest.raises(AssertionError, match="output_attentions is not supported for windowed attention"):
model(pixel_values, output_attentions=True)
class TestSetAttnImplementation:
"""Tests for WindowedDinov2WithRegistersModel.set_attn_implementation."""
@pytest.mark.parametrize(
"switches, expected_impl, expected_cls",
[
pytest.param(["eager"], "eager", Dinov2WithRegistersAttention, id="sdpa-to-eager"),
pytest.param(["eager", "sdpa"], "sdpa", Dinov2WithRegistersSdpaAttention, id="roundtrip-back-to-sdpa"),
],
)
def test_switch_updates_config_and_layers(self, switches, expected_impl, expected_cls):
"""After each call in *switches*, config and all layer attention modules reflect the final impl."""
config = _minimal_backbone_config()
model = WindowedDinov2WithRegistersModel(config)
for impl in switches:
model.set_attn_implementation(impl)
assert model.config._attn_implementation == expected_impl
for layer in model.encoder.layer:
assert type(layer.attention) is expected_cls
def test_invalid_implementation_raises(self):
"""Passing an unknown key raises ValueError with a clear message."""
config = _minimal_backbone_config()
model = WindowedDinov2WithRegistersModel(config)
with pytest.raises(ValueError, match="Unknown attn_implementation"):
model.set_attn_implementation("flash_attention_2")
@pytest.mark.parametrize(
"h, w, num_windows, should_raise",
[
pytest.param(64, 64, 2, False, id="valid-square"),
pytest.param(64, 96, 2, False, id="valid-rectangular"),
pytest.param(32, 32, 1, False, id="num_windows-1-valid"),
pytest.param(33, 64, 2, True, id="h-not-divisible"),
pytest.param(64, 33, 2, True, id="w-not-divisible"),
pytest.param(33, 33, 2, True, id="both-not-divisible"),
],
)
def test_forward_validates_spatial_dims(h: int, w: int, num_windows: int, should_raise: bool) -> None:
"""WindowedDinov2WithRegistersEmbeddings raises ValueError for incompatible dims.
Both H and W must be divisible by patch_size * num_windows. The check must survive Python's -O flag (assert would
be silently stripped).
"""
patch_size = 16
config = WindowedDinov2WithRegistersConfig(
hidden_size=32,
patch_size=patch_size,
num_windows=num_windows,
image_size=max(h, w),
num_register_tokens=0,
)
model = WindowedDinov2WithRegistersEmbeddings(config)
pixel_values = torch.randn(1, 3, h, w)
if should_raise:
with pytest.raises(ValueError, match="divisible"):
model(pixel_values)
else:
model(pixel_values) # must not raise
def _make_small_model() -> WindowedDinov2WithRegistersModel:
"""Return the smallest valid WindowedDinov2WithRegistersModel for unit tests."""
config = WindowedDinov2WithRegistersConfig(
hidden_size=32,
num_hidden_layers=2,
num_attention_heads=4,
image_size=32,
patch_size=16,
num_register_tokens=2,
)
return WindowedDinov2WithRegistersModel(config)
class TestSetAttnImplementationPreservesWeights:
"""set_attn_implementation must transfer trained weights to the new attention module.
Before the fix the method replaced each layer's attention with a freshly constructed (randomly initialised) module,
silently discarding all trained q/k/v/output weights.
"""
@pytest.mark.parametrize(
"from_impl, to_impl",
[
pytest.param("sdpa", "eager", id="sdpa_to_eager"),
pytest.param("eager", "sdpa", id="eager_to_sdpa"),
],
)
def test_query_weight_preserved_after_switch(self, from_impl: str, to_impl: str) -> None:
"""After switching implementation the query weight tensor must be unchanged."""
model = _make_small_model()
model.set_attn_implementation(from_impl)
# Record the query weights of every layer before switching.
before = [layer.attention.attention.query.weight.clone() for layer in model.encoder.layer]
model.set_attn_implementation(to_impl)
after = [layer.attention.attention.query.weight for layer in model.encoder.layer]
for layer_idx, (w_before, w_after) in enumerate(zip(before, after)):
assert torch.equal(w_before, w_after), (
f"Layer {layer_idx}: query weight changed after set_attn_implementation({from_impl!r}{to_impl!r})"
)
def test_key_and_value_weights_preserved(self) -> None:
"""Key and value weights must also survive the implementation switch."""
model = _make_small_model()
model.set_attn_implementation("sdpa")
key_before = [layer.attention.attention.key.weight.clone() for layer in model.encoder.layer]
val_before = [layer.attention.attention.value.weight.clone() for layer in model.encoder.layer]
model.set_attn_implementation("eager")
for layer_idx, layer in enumerate(model.encoder.layer):
assert torch.equal(key_before[layer_idx], layer.attention.attention.key.weight), (
f"Layer {layer_idx}: key weight changed after implementation switch"
)
assert torch.equal(val_before[layer_idx], layer.attention.attention.value.weight), (
f"Layer {layer_idx}: value weight changed after implementation switch"
)
def test_output_dense_weight_preserved(self) -> None:
"""The output projection (dense) weight must survive the implementation switch."""
model = _make_small_model()
model.set_attn_implementation("sdpa")
dense_before = [layer.attention.output.dense.weight.clone() for layer in model.encoder.layer]
model.set_attn_implementation("eager")
for layer_idx, layer in enumerate(model.encoder.layer):
assert torch.equal(dense_before[layer_idx], layer.attention.output.dense.weight), (
f"Layer {layer_idx}: output dense weight changed after implementation switch"
)
def test_config_updated_after_switch(self) -> None:
"""config._attn_implementation must reflect the new implementation after switching."""
model = _make_small_model()
model.set_attn_implementation("eager")
assert model.config._attn_implementation == "eager"
model.set_attn_implementation("sdpa")
assert model.config._attn_implementation == "sdpa"
def test_attention_module_type_after_switch(self) -> None:
"""After switching to eager, every layer must hold a non-SDPA attention class."""
model = _make_small_model()
model.set_attn_implementation("eager")
for layer in model.encoder.layer:
assert isinstance(layer.attention, Dinov2WithRegistersAttention)
assert not isinstance(layer.attention, Dinov2WithRegistersSdpaAttention)
def test_invalid_implementation_raises_value_error(self) -> None:
"""An unknown implementation name must raise ValueError before touching any layer."""
model = _make_small_model()
with pytest.raises(ValueError, match="Unknown attn_implementation"):
model.set_attn_implementation("flash_attn")
+24
View File
@@ -0,0 +1,24 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Shared fixtures for the models test suite."""
import pytest
import torch
@pytest.fixture(autouse=True)
def reset_torch_safe_globals():
"""Reset torch serialization safe globals after each test.
Prevents cross-test state contamination caused by ``_safe_torch_load``'s Attempt 2 path, which calls
``torch.serialization.add_safe_globals``. Without this reset, globals registered by one test bleed into subsequent
tests and can mask trust-gate failures.
"""
yield
try:
torch.serialization.clear_safe_globals()
except AttributeError:
pass # torch <2.4 does not have clear_safe_globals
+5
View File
@@ -0,0 +1,5 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
+274
View File
@@ -0,0 +1,274 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Regression tests for _resize_linear(), LWDETR.reinitialize_detection_head(), and _aggregate_keypoint_class_logits().
These tests guard against the out_features staleness bug where in-place .data mutation did not update
nn.Linear.out_features, causing ONNX export to emit stale (pre-fine-tuning) class counts.
Also covers the spurious "Keypoint class-logit boost has N classes but detection head has M" warning that fired when
num_keypoints_per_class exactly covered all foreground classes (correct configuration) but the comparison was against
class_embed.out_features which includes the background slot (+1).
"""
from unittest.mock import MagicMock
import pytest
import torch
from torch import nn
from rfdetr.models.lwdetr import LWDETR, _resize_linear
def _make_minimal_lwdetr(num_classes: int = 91, two_stage: bool = False) -> LWDETR:
"""Construct the smallest viable LWDETR without loading pretrained weights.
Uses a MagicMock backbone and transformer with hidden_dim=4 so the model can be constructed in milliseconds without
any network I/O.
Args:
num_classes: Initial number of output classes passed to LWDETR.
two_stage: Whether to enable two-stage mode (creates enc_out_class_embed).
Returns:
An LWDETR instance with hidden_dim=4, num_queries=2, group_detr=1.
Examples:
>>> model = _make_minimal_lwdetr(num_classes=91)
>>> isinstance(model, LWDETR)
True
"""
hidden_dim = 4
backbone = MagicMock()
transformer = MagicMock()
transformer.d_model = hidden_dim
transformer.decoder = MagicMock()
transformer.decoder.bbox_embed = None
return LWDETR(
backbone=backbone,
transformer=transformer,
segmentation_head=None,
num_classes=num_classes,
num_queries=2,
group_detr=1,
two_stage=two_stage,
)
def _make_keypoint_lwdetr(num_classes: int, num_keypoints_per_class: list[int]) -> LWDETR:
"""Construct a minimal keypoint-capable LWDETR with detection head resized to num_classes+1.
Mirrors what happens after loading a pretrained checkpoint and fine-tuning to num_classes
foreground categories: reinitialize_detection_head is called with num_classes+1 (includes
background), so class_embed.out_features == num_classes+1 in the returned model.
Args:
num_classes: Number of foreground detection classes.
num_keypoints_per_class: Keypoint count per foreground class.
Returns:
An LWDETR with use_grouppose_keypoints=True and class_embed.out_features==num_classes+1.
Examples:
>>> model = _make_keypoint_lwdetr(num_classes=2, num_keypoints_per_class=[17, 4])
>>> model.class_embed.out_features
3
"""
hidden_dim = 4
backbone = MagicMock()
transformer = MagicMock()
transformer.d_model = hidden_dim
transformer.decoder = MagicMock()
transformer.decoder.bbox_embed = None
transformer.decoder.num_keypoints_per_class = num_keypoints_per_class
transformer.decoder.keypoint_class_mask = torch.zeros(1, 1, dtype=torch.bool)
transformer.num_keypoints_per_class = num_keypoints_per_class
model = LWDETR(
backbone=backbone,
transformer=transformer,
segmentation_head=None,
num_classes=num_classes,
num_queries=2,
group_detr=1,
use_grouppose_keypoints=True,
num_keypoints_per_class=num_keypoints_per_class,
)
# Simulate post-checkpoint-load state: detection head includes background slot.
model.reinitialize_detection_head(num_classes + 1)
return model
def _keypoint_tensor(num_keypoints_per_class: list[int], batch: int = 1, seq: int = 1) -> torch.Tensor:
"""Build a zero keypoint prediction tensor with the shape expected by _aggregate_keypoint_class_logits.
The second-to-last dimension must equal num_kp_classes * max_kp (padded layout).
Args:
num_keypoints_per_class: Keypoint schema for the model.
batch: Batch size dimension.
seq: Sequence (query) dimension.
Returns:
Zero tensor of shape (batch, seq, num_kp_classes * max_kp, 8).
Examples:
>>> t = _keypoint_tensor([17, 4])
>>> t.shape
torch.Size([1, 1, 34, 8])
"""
num_kp_classes = len(num_keypoints_per_class)
max_kp = max(num_keypoints_per_class) if any(num_keypoints_per_class) else 1
total_padded = num_kp_classes * max_kp
return torch.zeros(batch, seq, total_padded, 8)
class TestResizeLinear:
"""Unit tests for _resize_linear() — verifies out_features, weight shape, and bias shape."""
def test_shrink_out_features(self) -> None:
"""Shrink: out_features equals the requested smaller class count."""
result = _resize_linear(nn.Linear(256, 91), 8)
assert result.out_features == 8, f"Expected out_features=8, got {result.out_features}"
assert result.weight.shape == (8, 256), f"Expected weight (8, 256), got {result.weight.shape}"
assert result.bias is not None
assert result.bias.shape == (8,), f"Expected bias (8,), got {result.bias.shape}"
def test_expand_out_features(self) -> None:
"""Expand: out_features equals the requested larger class count via tiling."""
result = _resize_linear(nn.Linear(256, 10), 25)
assert result.out_features == 25, f"Expected out_features=25, got {result.out_features}"
assert result.weight.shape == (25, 256), f"Expected weight (25, 256), got {result.weight.shape}"
assert result.bias is not None
assert result.bias.shape == (25,), f"Expected bias (25,), got {result.bias.shape}"
def test_same_size_preserves_values(self) -> None:
"""Same size: shapes and weight/bias values are preserved exactly."""
linear = nn.Linear(256, 91)
result = _resize_linear(linear, 91)
assert result.out_features == 91
assert result.weight.shape == (91, 256)
assert result.bias is not None
assert result.bias.shape == (91,)
assert torch.allclose(result.weight.data, linear.weight.data)
assert torch.allclose(result.bias.data, linear.bias.data)
def test_no_bias_returns_no_bias(self) -> None:
"""Bias=False input: returned module has bias=None and out_features is correct."""
linear = nn.Linear(256, 91, bias=False)
result = _resize_linear(linear, 8)
assert result.out_features == 8, f"Expected out_features=8, got {result.out_features}"
assert result.bias is None, "Expected bias=None for bias=False input"
class TestReinitializeDetectionHead:
"""Integration tests for LWDETR.reinitialize_detection_head().
Uses a minimal LWDETR (hidden_dim=4, no real backbone) to verify that out_features is updated on the replaced
nn.Linear modules — the core invariant required for correct ONNX export.
"""
def test_updates_class_embed_out_features(self) -> None:
"""class_embed.out_features must reflect num_classes after reinitialize.
The `num_outputs_including_background` argument represents the total number of classifier outputs (foreground
classes plus background).
"""
num_outputs_including_background = 8
model = _make_minimal_lwdetr(num_classes=91)
model.reinitialize_detection_head(num_outputs_including_background)
assert model.class_embed.out_features == num_outputs_including_background, (
f"Expected class_embed.out_features={num_outputs_including_background}, "
f"got {model.class_embed.out_features}"
)
assert model.class_embed.weight.shape == (num_outputs_including_background, 4), (
f"Expected weight ({num_outputs_including_background}, 4), got {model.class_embed.weight.shape}"
)
def test_two_stage_updates_enc_out_class_embed(self) -> None:
"""enc_out_class_embed entries must also have updated out_features in two-stage mode.
The `num_outputs_including_background` argument represents the total number of classifier outputs (foreground
classes plus background).
"""
num_outputs_including_background = 8
model = _make_minimal_lwdetr(num_classes=91, two_stage=True)
model.reinitialize_detection_head(num_outputs_including_background)
enc_embeds = model.transformer.enc_out_class_embed
assert len(enc_embeds) > 0, "enc_out_class_embed should be non-empty in two-stage mode"
for i, embed in enumerate(enc_embeds):
assert embed.out_features == num_outputs_including_background, (
f"enc_out_class_embed[{i}].out_features={embed.out_features}, "
f"expected {num_outputs_including_background}"
)
assert embed.weight.shape == (num_outputs_including_background, 4), (
f"enc_out_class_embed[{i}].weight.shape={embed.weight.shape}, "
f"expected ({num_outputs_including_background}, 4)"
)
class TestAggregateKeypointClassLogits:
"""Regression tests for LWDETR._aggregate_keypoint_class_logits().
Guards against a spurious warning that fired when num_keypoints_per_class exactly covered all
foreground classes: class_embed.out_features includes background (+1), so len(schema)==num_classes
always satisfied schema_len < detection_num_classes, triggering the warning incorrectly.
Uses _kp_zero_pad_warned as a proxy for whether the warning fired — the rf-detr logger uses
propagate=False which prevents standard caplog capture.
"""
@pytest.mark.parametrize(
"num_classes,num_keypoints_per_class",
[
pytest.param(1, [17], id="coco-person-1class"),
pytest.param(2, [17, 4], id="basketball-2class"),
pytest.param(3, [17, 4, 0], id="3class-schema-covers-all"),
],
)
def test_no_warning_when_schema_covers_all_foreground_classes(
self,
num_classes: int,
num_keypoints_per_class: list[int],
) -> None:
"""No warning when num_keypoints_per_class covers exactly all foreground detection classes.
Regression: the comparison used class_embed.out_features (num_classes+1) instead of
num_classes, so a fully correct schema always triggered the spurious mismatch warning.
"""
model = _make_keypoint_lwdetr(num_classes=num_classes, num_keypoints_per_class=num_keypoints_per_class)
fake_kp = _keypoint_tensor(num_keypoints_per_class)
model._aggregate_keypoint_class_logits(fake_kp)
assert not model._kp_zero_pad_warned, (
f"Spurious warning fired for schema={num_keypoints_per_class} with num_classes={num_classes}"
)
def test_warning_fires_when_schema_shorter_than_foreground_classes(self) -> None:
"""Warning fires when schema covers fewer classes than foreground detection classes.
Scenario: 3 foreground classes but schema only covers 1 (e.g. only person has keypoints).
The two uncovered foreground classes receive zero boost — a real mismatch worth warning about.
"""
model = _make_keypoint_lwdetr(num_classes=3, num_keypoints_per_class=[17])
fake_kp = _keypoint_tensor([17])
model._aggregate_keypoint_class_logits(fake_kp)
assert model._kp_zero_pad_warned, "Expected warning flag set for schema shorter than foreground class count"
def test_output_shape_matches_detection_head(self) -> None:
"""Output shape is (batch, seq, detection_num_classes) regardless of schema length."""
num_classes = 2
schema = [17, 4]
model = _make_keypoint_lwdetr(num_classes=num_classes, num_keypoints_per_class=schema)
batch, seq = 2, 10
fake_kp = _keypoint_tensor(schema, batch=batch, seq=seq)
out = model._aggregate_keypoint_class_logits(fake_kp)
assert out.shape == (batch, seq, num_classes + 1), (
f"Expected shape {(batch, seq, num_classes + 1)}, got {out.shape}"
)
+278
View File
@@ -0,0 +1,278 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
import pytest
import torch
from rfdetr.models.heads import ConditionalQueryInitializer
from rfdetr.models.heads.keypoints import (
compute_keypoint_matching_cost,
compute_l1_keypoint_loss,
)
def test_conditional_query_initializer_shape() -> None:
"""Initializer output should have expected batch/query/out dimensions."""
initializer = ConditionalQueryInitializer(dim=32, num_queries=11, out_dim=16)
query_features = torch.randn(3, 32)
queries = initializer(query_features)
assert queries.shape == (3, 11, 16)
def test_conditional_query_initializer_zero_adaln_identity() -> None:
"""A zeroed AdaLN gate should make initializer return the unmodified learned queries."""
initializer = ConditionalQueryInitializer(dim=16, num_queries=5, out_dim=16)
query_features = torch.randn(4, 16)
output = initializer(query_features)
expected = initializer.queries.unsqueeze(0).expand_as(output)
assert torch.equal(output, expected)
def test_compute_l1_keypoint_loss_smoke() -> None:
"""Loss helper should emit four finite vectors with matching target batch shape."""
pred_keypoints = torch.randn(3, 17, 7)
target_keypoints = torch.rand(3, 17, 3)
target_keypoints[:, :, 2] = 2.0
target_classes = torch.tensor([0, 0, 0], dtype=torch.int64)
target_areas = torch.tensor([1.0, 2.0, 3.0], dtype=torch.float32)
losses = compute_l1_keypoint_loss(
all_pred_keypoints=pred_keypoints,
target_keypoints=target_keypoints,
target_classes=target_classes,
target_areas=target_areas,
num_keypoints_per_class=[17],
)
assert len(losses) == 4
for loss in losses:
assert loss.shape == (3,)
assert torch.isfinite(loss).all()
def test_compute_l1_keypoint_loss_skips_visible_zero_area_nll_residuals() -> None:
"""Visible keypoints on zero-area targets should not produce non-finite Gaussian NLL."""
pred_keypoints = torch.zeros(1, 17, 7)
target_keypoints = torch.rand(1, 17, 3)
target_keypoints[:, :, 2] = 2.0
losses = compute_l1_keypoint_loss(
all_pred_keypoints=pred_keypoints,
target_keypoints=target_keypoints,
target_classes=torch.tensor([0], dtype=torch.int64),
target_areas=torch.tensor([0.0], dtype=torch.float32),
num_keypoints_per_class=[17],
)
for loss in losses:
assert torch.isfinite(loss).all()
def test_compute_l1_keypoint_loss_uses_raw_rflow_gaussian_nll() -> None:
"""Perfect keypoints should use raw r-flow NLL without a floor shift."""
pred_keypoints = torch.zeros(1, 1, 7)
pred_keypoints[:, :, 4] = 0.3
pred_keypoints[:, :, 6] = -0.2
target_keypoints = torch.tensor([[[0.0, 0.0, 2.0]]], dtype=torch.float32)
_, _, _, nll = compute_l1_keypoint_loss(
all_pred_keypoints=pred_keypoints,
target_keypoints=target_keypoints,
target_classes=torch.tensor([0], dtype=torch.int64),
target_areas=torch.tensor([1.0], dtype=torch.float32),
num_keypoints_per_class=[1],
)
torch.testing.assert_close(nll, torch.tensor([-0.1]), rtol=1e-4, atol=1e-6)
def test_compute_l1_keypoint_loss_does_not_clamp_log_cholesky_nll() -> None:
"""Large precision log-diagonals should remain raw to match r-flow."""
pred_keypoints = torch.zeros(1, 1, 7)
pred_keypoints[:, :, 4] = 25.0
target_keypoints = torch.tensor([[[0.0, 0.0, 2.0]]], dtype=torch.float32)
_, _, _, nll = compute_l1_keypoint_loss(
all_pred_keypoints=pred_keypoints,
target_keypoints=target_keypoints,
target_classes=torch.tensor([0], dtype=torch.int64),
target_areas=torch.tensor([1.0], dtype=torch.float32),
num_keypoints_per_class=[1],
)
torch.testing.assert_close(nll, torch.tensor([-25.0]), rtol=1e-4, atol=1e-6)
def test_compute_l1_keypoint_loss_raw_nll_gradients_match_reference_formula() -> None:
"""The implemented NLL gradients should match the raw r-flow Gaussian formula."""
pred_keypoints = torch.tensor([[[0.2, -0.1, 0.0, 0.0, 0.3, 0.1, -0.2]]], requires_grad=True)
target_keypoints = torch.tensor([[[0.0, 0.0, 2.0]]], dtype=torch.float32)
target_areas = torch.tensor([1.0], dtype=torch.float32)
_, _, _, nll = compute_l1_keypoint_loss(
all_pred_keypoints=pred_keypoints,
target_keypoints=target_keypoints,
target_classes=torch.tensor([0], dtype=torch.int64),
target_areas=target_areas,
num_keypoints_per_class=[1],
)
nll.sum().backward()
grad = pred_keypoints.grad.detach().clone()
raw_pred_keypoints = pred_keypoints.detach().clone().requires_grad_(True)
dx = raw_pred_keypoints[:, :, 0] - target_keypoints[:, :, 0]
dy = raw_pred_keypoints[:, :, 1] - target_keypoints[:, :, 1]
log_l11 = raw_pred_keypoints[:, :, 4]
l21 = raw_pred_keypoints[:, :, 5]
log_l22 = raw_pred_keypoints[:, :, 6]
u0 = log_l11.exp() * dx + l21 * dy
u1 = log_l22.exp() * dy
raw_nll = 0.5 * (u0 * u0 + u1 * u1) / target_areas.unsqueeze(1) - (log_l11 + log_l22)
raw_nll.sum().backward()
torch.testing.assert_close(nll.detach(), raw_nll.detach().reshape(-1), rtol=1e-4, atol=1e-6)
torch.testing.assert_close(grad, raw_pred_keypoints.grad, rtol=1e-4, atol=1e-6)
def test_compute_l1_keypoint_loss_rejects_missing_schema() -> None:
"""Missing keypoint schema should fail before producing zero supervision."""
pred_keypoints = torch.randn(1, 17, 7)
target_keypoints = torch.rand(1, 17, 3)
with pytest.raises(ValueError, match="num_keypoints_per_class must be non-empty"):
compute_l1_keypoint_loss(
all_pred_keypoints=pred_keypoints,
target_keypoints=target_keypoints,
target_classes=torch.tensor([0], dtype=torch.int64),
target_areas=torch.tensor([1.0], dtype=torch.float32),
num_keypoints_per_class=[],
)
def test_compute_keypoint_matching_cost_smoke() -> None:
"""Matching-cost helper should return a four-term cost tensor for each target."""
all_pred_keypoints = torch.randn(2, 4, 17, 7)
target_keypoints = torch.rand(2, 17, 3)
target_keypoints[:, :, 2] = 2.0
target_classes = torch.tensor([0, 0], dtype=torch.int64)
target_areas = torch.tensor([1.0, 2.0], dtype=torch.float32)
cost_l1, cost_findable, cost_visible, cost_nll = compute_keypoint_matching_cost(
all_pred_keypoints=all_pred_keypoints,
target_keypoints=target_keypoints,
target_classes=target_classes,
target_areas=target_areas,
num_keypoints_per_class=[17],
)
assert cost_l1.shape == (2, 4, 2)
assert cost_findable.shape == (2, 4, 2)
assert cost_visible.shape == (2, 4, 2)
assert cost_nll.shape == (2, 4, 2)
assert torch.isfinite(cost_l1).all()
assert torch.isfinite(cost_findable).all()
assert torch.isfinite(cost_visible).all()
assert torch.isfinite(cost_nll).all()
def test_compute_keypoint_matching_cost_skips_zero_area_nll_residuals() -> None:
"""Zero-area targets should not produce non-finite keypoint matching costs."""
all_pred_keypoints = torch.zeros(1, 2, 17, 7)
target_keypoints = torch.rand(1, 17, 3)
target_keypoints[:, :, 2] = 2.0
costs = compute_keypoint_matching_cost(
all_pred_keypoints=all_pred_keypoints,
target_keypoints=target_keypoints,
target_classes=torch.tensor([0], dtype=torch.int64),
target_areas=torch.tensor([0.0], dtype=torch.float32),
num_keypoints_per_class=[17],
)
for cost in costs:
assert torch.isfinite(cost).all()
def test_compute_keypoint_matching_cost_does_not_clamp_log_cholesky_nll() -> None:
"""Matching NLL should use raw precision log-diagonals to match r-flow."""
all_pred_keypoints = torch.zeros(1, 1, 1, 7)
all_pred_keypoints[:, :, :, 4] = 25.0
target_keypoints = torch.tensor([[[0.0, 0.0, 2.0]]], dtype=torch.float32)
_, _, _, cost_nll = compute_keypoint_matching_cost(
all_pred_keypoints=all_pred_keypoints,
target_keypoints=target_keypoints,
target_classes=torch.tensor([0], dtype=torch.int64),
target_areas=torch.tensor([1.0], dtype=torch.float32),
num_keypoints_per_class=[1],
)
torch.testing.assert_close(cost_nll, torch.tensor([[[-25.0]]]), rtol=1e-4, atol=1e-6)
def test_compute_keypoint_matching_cost_rejects_missing_schema() -> None:
"""Missing keypoint schema should fail before matcher costs become keypoint no-ops."""
all_pred_keypoints = torch.randn(1, 2, 17, 7)
target_keypoints = torch.rand(1, 17, 3)
with pytest.raises(ValueError, match="num_keypoints_per_class must be non-empty"):
compute_keypoint_matching_cost(
all_pred_keypoints=all_pred_keypoints,
target_keypoints=target_keypoints,
target_classes=torch.tensor([0], dtype=torch.int64),
target_areas=torch.tensor([1.0], dtype=torch.float32),
num_keypoints_per_class=[],
)
class TestComputeKeypointMatchingCostSmoke:
"""Group: compute_keypoint_matching_cost — shape and boundary checks."""
def test_n_targets_zero_returns_four_empty_cost_tensors(self) -> None:
"""Empty target set should return four finite (B, Q, 0) cost tensors immediately."""
b, q = 2, 4
all_pred_keypoints = torch.randn(b, q, 17, 7)
cost_l1, cost_findable, cost_visible, cost_nll = compute_keypoint_matching_cost(
all_pred_keypoints=all_pred_keypoints,
target_keypoints=torch.empty(0, 17, 3),
target_classes=torch.empty(0, dtype=torch.int64),
target_areas=torch.empty(0),
num_keypoints_per_class=[17],
)
for cost, name in (
(cost_l1, "cost_l1"),
(cost_findable, "cost_findable"),
(cost_visible, "cost_visible"),
(cost_nll, "cost_nll"),
):
assert cost.shape == (b, q, 0), f"{name}: expected shape ({b}, {q}, 0), got {cost.shape}"
assert torch.isfinite(cost).all(), f"{name}: expected all-finite tensor, got non-finite values"
class TestComputeL1KeypointLossOobClass:
"""Group: compute_l1_keypoint_loss — out-of-range class index handling."""
def test_class_index_out_of_range_returns_zero_losses_without_raising(self) -> None:
"""Out-of-range class index should emit a warning and return zeros, not raise."""
pred_keypoints = torch.randn(1, 17, 7)
target_keypoints = torch.rand(1, 17, 3)
target_keypoints[:, :, 2] = 2.0
# class index 2 is out of range for num_keypoints_per_class=[17] (only class 0 defined)
result = compute_l1_keypoint_loss(
all_pred_keypoints=pred_keypoints,
target_keypoints=target_keypoints,
target_classes=torch.tensor([2], dtype=torch.int64),
target_areas=torch.tensor([1.0], dtype=torch.float32),
num_keypoints_per_class=[17],
)
assert len(result) == 4, f"Expected 4-tuple, got {len(result)} elements"
for i, loss in enumerate(result):
assert loss.shape == (1,), f"Loss[{i}]: expected shape (1,), got {loss.shape}"
torch.testing.assert_close(
loss,
torch.zeros(1),
msg=f"Loss[{i}]: expected all zeros for out-of-range class, got {loss}",
)
@@ -0,0 +1,263 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for DepthwiseConvBlock and _DepthwiseConvWithoutCuDNN (segmentation head)."""
from contextlib import contextmanager
import pytest
import torch
from rfdetr.models.heads.segmentation import DepthwiseConvBlock
@pytest.fixture(autouse=True)
def _reset_random_seeds() -> None:
"""Reset random seeds before each test for reproducibility."""
torch.manual_seed(42)
@pytest.mark.parametrize(
"device",
[
pytest.param("cpu", id="cpu"),
pytest.param(
"cuda",
id="gpu",
marks=[
pytest.mark.gpu,
pytest.mark.skipif(
not torch.cuda.is_available(),
reason="CUDA is not available",
),
],
),
],
)
def test_depthwise_conv_block_forward(device: str) -> None:
"""DepthwiseConvBlock forward pass produces correct output shape without error."""
block = DepthwiseConvBlock(dim=8).to(device)
x = torch.randn(1, 8, 4, 4, device=device)
y = block(x)
assert y.shape == x.shape
def test_depthwise_conv_forward_disables_cudnn(monkeypatch) -> None:
"""Depthwise conv should execute with cuDNN disabled during forward."""
block = DepthwiseConvBlock(dim=8)
enabled_calls: list[bool] = []
original_flags = torch.backends.cudnn.flags
@contextmanager
def _tracking_flags(*, enabled: bool):
enabled_calls.append(enabled)
with original_flags(enabled=enabled):
yield
monkeypatch.setattr(torch.backends.cudnn, "flags", _tracking_flags)
x = torch.randn(1, 8, 4, 4)
y = block(x)
assert y.shape == x.shape
assert enabled_calls, "torch.backends.cudnn.flags was never called"
assert all(not e for e in enabled_calls)
def test_depthwise_conv_backward_disables_cudnn(monkeypatch) -> None:
"""Backward pass must also run with cuDNN disabled (issue #731).
The previous fix (PR #728) only wrapped the forward pass in a context manager. The backward kernels ran with cuDNN
re-enabled, causing RuntimeError on T4/P100 GPUs.
"""
block = DepthwiseConvBlock(dim=8)
enabled_calls: list[bool] = []
original_flags = torch.backends.cudnn.flags
@contextmanager
def _tracking_flags(*, enabled: bool):
enabled_calls.append(enabled)
with original_flags(enabled=enabled):
yield
monkeypatch.setattr(torch.backends.cudnn, "flags", _tracking_flags)
x = torch.randn(1, 8, 4, 4, requires_grad=True)
y = block(x)
y.sum().backward()
assert x.grad is not None
assert x.grad.shape == x.shape
# cuDNN must be disabled for both forward and backward
assert len(enabled_calls) >= 2
assert all(not e for e in enabled_calls)
@pytest.mark.parametrize(
"device",
[
pytest.param("cpu", id="cpu"),
pytest.param(
"cuda",
id="gpu",
marks=[
pytest.mark.gpu,
pytest.mark.skipif(
not torch.cuda.is_available(),
reason="CUDA is not available",
),
],
),
],
)
def test_depthwise_conv_backward_produces_correct_gradients(device: str) -> None:
"""Backward pass through DepthwiseConvBlock produces valid gradients."""
block = DepthwiseConvBlock(dim=8).to(device)
x = torch.randn(1, 8, 4, 4, device=device, requires_grad=True)
y = block(x)
y.sum().backward()
assert x.grad is not None
assert x.grad.shape == x.shape
assert torch.isfinite(x.grad).all()
def test_depthwise_conv_gradients_match_reference() -> None:
"""Custom autograd Function gradients match nn.Conv2d gradients.
Verifies that _DepthwiseConvWithoutCuDNN produces the same gradients as a standard nn.Conv2d forward+backward (run
with cuDNN disabled globally).
"""
torch.manual_seed(42)
dim = 8
block = DepthwiseConvBlock(dim=dim)
# Reference: run standard nn.Conv2d with cuDNN globally disabled
x_ref = torch.randn(1, dim, 4, 4, requires_grad=True)
with torch.backends.cudnn.flags(enabled=False):
y_ref = block.dwconv(x_ref)
y_ref.sum().backward()
x_ref_grad = x_ref.grad.clone()
weight_ref_grad = block.dwconv.weight.grad.clone()
bias_ref_grad = block.dwconv.bias.grad.clone()
# Our implementation via _depthwise_conv. zero_grad() so that the second
# backward does not accumulate into weight.grad from the first run.
block.zero_grad()
x_test = x_ref.detach().clone().requires_grad_(True)
y_test = block._depthwise_conv(x_test)
y_test.sum().backward()
assert torch.allclose(y_ref, y_test, atol=1e-6)
assert torch.allclose(x_ref_grad, x_test.grad, atol=1e-6)
assert torch.allclose(weight_ref_grad, block.dwconv.weight.grad, atol=1e-6)
assert torch.allclose(bias_ref_grad, block.dwconv.bias.grad, atol=1e-6)
def test_depthwise_conv_backward_fp16_grad_output() -> None:
"""Backward must not crash when grad_output is fp16 (AMP 16-mixed on T4/P100).
On T4/P100, trainer resolves amp=True to '16-mixed'. In that mode the backward receives fp16 grad_output while the
saved weight stays fp32. Without explicit dtype casting, conv2d_input raises:
RuntimeError: expected scalar type Half but found Float
"""
dim = 8
block = DepthwiseConvBlock(dim=dim)
x = torch.randn(1, dim, 4, 4, requires_grad=True)
# Simulate 16-mixed backward: forward in fp32, grad_output arrives as fp16
y = block._depthwise_conv(x)
grad_output = torch.ones_like(y, dtype=torch.float16)
y.backward(grad_output)
assert x.grad is not None
assert x.grad.dtype == torch.float32
assert torch.isfinite(x.grad).all()
def test_depthwise_conv_backward_bf16_activation_keeps_grads_fp32() -> None:
"""grad_input and weight.grad must be fp32 when saved activation x is bf16 (issue #959).
Under bf16-mixed AMP, the activation x entering _DepthwiseConvWithoutCuDNN is bf16 while weight stays fp32. The old
code cast grad_input back to x.dtype (bf16), propagating bf16 gradients to fp32 backbone parameters so that
param.grad.dtype became bf16. Fused AdamW then crashed with 'params, grads, exp_avgs, and exp_avg_sqs must have
same dtype, device, and layout' (see issue #959). The fix keeps grad_input in weight.dtype (fp32).
This test drives the backward directly with a bf16 saved activation to reproduce the dtype that is present at
training time without requiring a GPU.
"""
import types
from rfdetr.models.heads.segmentation import _DepthwiseConvWithoutCuDNN
dim = 8
weight = torch.randn(dim, 1, 3, 3, requires_grad=True) # fp32 parameter (never cast by AMP)
x_bf16 = torch.randn(1, dim, 4, 4, dtype=torch.bfloat16) # bf16 activation (cast by AMP forward)
grad_output = torch.ones(1, dim, 4, 4, dtype=torch.bfloat16) # bf16 grad (from bf16 backward)
# Build a minimal context mirroring what ctx would contain after the AMP forward pass.
ctx = types.SimpleNamespace()
ctx.saved_tensors = (x_bf16, weight)
ctx.has_bias = False
ctx.stride = (1, 1)
ctx.padding = (1, 1)
ctx.dilation = (1, 1)
ctx.groups = dim
ctx.needs_input_grad = [True, True, False, False, False, False, False]
grad_input, grad_weight, *_ = _DepthwiseConvWithoutCuDNN.backward(ctx, grad_output)
assert grad_input is not None, "grad_input should not be None when needs_input_grad[0] is True"
assert grad_input.dtype == torch.float32, (
f"grad_input is {grad_input.dtype} — bf16 grad_input propagates to fp32 backbone params "
"and crashes fused AdamW (issue #959)"
)
assert grad_weight is not None, "grad_weight should not be None when needs_input_grad[1] is True"
assert grad_weight.dtype == torch.float32, (
f"grad_weight is {grad_weight.dtype} — weight grad must stay fp32 to match param dtype"
)
def test_depthwise_conv_no_cudnn_bias_none() -> None:
"""_DepthwiseConvWithoutCuDNN forward and backward work correctly with bias=None.
Exercises the ctx.has_bias=False branch in forward and the grad_bias=None return in backward — never reached via
DepthwiseConvBlock (always has bias).
"""
from rfdetr.models.heads.segmentation import _DepthwiseConvWithoutCuDNN
dim = 8
weight = torch.randn(dim, 1, 3, 3, requires_grad=True)
x = torch.randn(1, dim, 4, 4, requires_grad=True)
y = _DepthwiseConvWithoutCuDNN.apply(x, weight, None, (1, 1), (1, 1), (1, 1), dim)
y_ref = torch.nn.functional.conv2d(x.detach(), weight.detach(), None, stride=1, padding=1, dilation=1, groups=dim)
assert torch.allclose(y.detach(), y_ref, atol=1e-6)
y.sum().backward()
assert x.grad is not None
assert x.grad.shape == x.shape
assert torch.isfinite(x.grad).all()
assert weight.grad is not None
assert weight.grad.shape == weight.shape
assert torch.isfinite(weight.grad).all()
@pytest.mark.parametrize(
"layer_scale_init_value", [pytest.param(0, id="no_gamma"), pytest.param(1e-6, id="with_gamma")]
)
def test_depthwise_conv_block_layer_scale(layer_scale_init_value: float) -> None:
"""DepthwiseConvBlock with and without layer scaling produces valid output and gradients.
Exercises the gamma=None (layer_scale_init_value=0) and gamma!=None (layer_scale_init_value>0) branches in
DepthwiseConvBlock.forward().
"""
block = DepthwiseConvBlock(dim=8, layer_scale_init_value=layer_scale_init_value)
x = torch.randn(1, 8, 4, 4, requires_grad=True)
y = block(x)
assert y.shape == x.shape
y.sum().backward()
assert x.grad is not None
assert torch.isfinite(x.grad).all()
if layer_scale_init_value > 0:
assert block.gamma is not None
assert block.gamma.grad is not None
@@ -0,0 +1,433 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Characterization tests for build_model() and build_criterion_and_postprocessors().
These tests pin the current behavior of the legacy namespace-based builder functions. They serve as a safety net during
the config-native builder refactoring: any change that alters these outputs is a regression.
All tests in this file must pass against the CURRENT codebase.
"""
import pytest
import torch
from rfdetr._namespace import _namespace_from_configs
from rfdetr.config import (
RFDETRBaseConfig,
RFDETRKeypointPreviewConfig,
RFDETRNanoConfig,
RFDETRSegNanoConfig,
SegmentationTrainConfig,
TrainConfig,
)
from rfdetr.models.criterion import SetCriterion
from rfdetr.models.lwdetr import LWDETR, build_criterion_and_postprocessors, build_model
from rfdetr.models.postprocess import PostProcess
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_ns(mc=None, tc=None):
"""Build a namespace suitable for builder functions."""
mc = mc or RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
tc = tc or TrainConfig(dataset_dir="/tmp")
return _namespace_from_configs(mc, tc)
# ---------------------------------------------------------------------------
# build_model characterization
# ---------------------------------------------------------------------------
class TestBuildModelCharacterization:
"""Pin current build_model() behaviour for the standard code path."""
def test_returns_lwdetr_instance(self) -> None:
ns = _make_ns()
model = build_model(ns)
assert isinstance(model, LWDETR)
def test_num_classes_plus_one(self) -> None:
"""build_model applies the +1 background class convention."""
mc = RFDETRBaseConfig(num_classes=5, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
model = build_model(ns)
assert model.class_embed.out_features == 6
def test_num_queries_forwarded(self) -> None:
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
model = build_model(ns)
assert model.num_queries == mc.num_queries
@pytest.mark.parametrize(
"config_class, expected_queries",
[
pytest.param(RFDETRBaseConfig, 300, id="base"),
pytest.param(RFDETRNanoConfig, 300, id="nano"),
pytest.param(RFDETRSegNanoConfig, 100, id="seg_nano"),
],
)
def test_num_queries_per_config_variant(self, config_class, expected_queries) -> None:
mc = config_class(pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
model = build_model(ns)
assert model.num_queries == expected_queries
def test_segmentation_head_none_for_detection(self) -> None:
ns = _make_ns()
model = build_model(ns)
assert model.segmentation_head is None
def test_segmentation_head_present_for_seg_config(self) -> None:
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
model = build_model(ns)
assert model.segmentation_head is not None
def test_aux_loss_enabled_by_default(self) -> None:
ns = _make_ns()
model = build_model(ns)
assert model.aux_loss is True
def test_group_detr_forwarded(self) -> None:
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
model = build_model(ns)
assert model.group_detr == mc.group_detr
def test_num_feature_levels_set_on_args(self) -> None:
"""build_model mutates args.num_feature_levels = len(projector_scale)."""
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
build_model(ns)
assert ns.num_feature_levels == len(mc.projector_scale)
@pytest.mark.parametrize(
"config_class, expected_param_count_range",
[
pytest.param(RFDETRBaseConfig, (25_000_000, 40_000_000), id="base"),
pytest.param(RFDETRNanoConfig, (25_000_000, 40_000_000), id="nano"),
],
)
def test_param_count_in_expected_range(self, config_class, expected_param_count_range) -> None:
"""Sanity check that the model has a plausible number of parameters."""
mc = config_class(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
model = build_model(ns)
total = sum(p.numel() for p in model.parameters())
low, high = expected_param_count_range
assert low <= total <= high, f"Expected param count in [{low}, {high}], got {total}"
def test_encoder_only_returns_triple(self) -> None:
"""When encoder_only=True, build_model returns (encoder, None, None)."""
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
ns.encoder_only = True
result = build_model(ns)
assert isinstance(result, tuple), f"Expected tuple, got {type(result)}"
assert len(result) == 3
encoder, second, third = result
assert second is None
assert third is None
assert encoder is not None
def test_backbone_only_returns_triple(self) -> None:
"""When backbone_only=True, build_model returns (backbone, None, None)."""
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
ns.backbone_only = True
result = build_model(ns)
assert isinstance(result, tuple), f"Expected tuple, got {type(result)}"
assert len(result) == 3
backbone, second, third = result
assert second is None
assert third is None
assert backbone is not None
# ---------------------------------------------------------------------------
# build_criterion_and_postprocessors characterization
# ---------------------------------------------------------------------------
class TestBuildCriterionCharacterization:
"""Pin current build_criterion_and_postprocessors() behaviour."""
def test_returns_criterion_and_postprocess(self) -> None:
ns = _make_ns()
criterion, postprocess = build_criterion_and_postprocessors(ns)
assert isinstance(criterion, SetCriterion)
assert isinstance(postprocess, PostProcess)
def test_detection_losses_list(self) -> None:
"""Detection-only config has exactly ['labels', 'boxes', 'cardinality']."""
ns = _make_ns()
criterion, _ = build_criterion_and_postprocessors(ns)
assert criterion.losses == ["labels", "boxes", "cardinality"]
def test_segmentation_losses_include_masks(self) -> None:
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
tc = SegmentationTrainConfig(dataset_dir="/tmp")
ns = _make_ns(mc=mc, tc=tc)
criterion, _ = build_criterion_and_postprocessors(ns)
assert "masks" in criterion.losses
def test_num_select_forwarded_to_postprocess(self) -> None:
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
_, postprocess = build_criterion_and_postprocessors(ns)
assert postprocess.num_select == 100
def test_num_select_default_for_base(self) -> None:
ns = _make_ns()
_, postprocess = build_criterion_and_postprocessors(ns)
assert postprocess.num_select == 300
def test_weight_dict_contains_base_losses(self) -> None:
ns = _make_ns()
criterion, _ = build_criterion_and_postprocessors(ns)
assert "loss_ce" in criterion.weight_dict
assert "loss_bbox" in criterion.weight_dict
assert "loss_giou" in criterion.weight_dict
def test_weight_dict_values_match_namespace(self) -> None:
ns = _make_ns()
criterion, _ = build_criterion_and_postprocessors(ns)
assert criterion.weight_dict["loss_ce"] == ns.cls_loss_coef
assert criterion.weight_dict["loss_bbox"] == ns.bbox_loss_coef
assert criterion.weight_dict["loss_giou"] == ns.giou_loss_coef
def test_segmentation_weight_dict_contains_mask_losses(self) -> None:
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
tc = SegmentationTrainConfig(dataset_dir="/tmp")
ns = _make_ns(mc=mc, tc=tc)
criterion, _ = build_criterion_and_postprocessors(ns)
assert "loss_mask_ce" in criterion.weight_dict
assert "loss_mask_dice" in criterion.weight_dict
def test_aux_loss_expands_weight_dict(self) -> None:
"""With aux_loss=True and 3 dec_layers, weight_dict has aux entries _0 and _1."""
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
assert ns.aux_loss is True
criterion, _ = build_criterion_and_postprocessors(ns)
# dec_layers=3 -> 2 aux layers (0 and 1)
assert "loss_ce_0" in criterion.weight_dict
assert "loss_ce_1" in criterion.weight_dict
def test_two_stage_adds_enc_losses(self) -> None:
"""With two_stage=True, weight_dict has '_enc' suffix entries."""
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
assert ns.two_stage is True
criterion, _ = build_criterion_and_postprocessors(ns)
assert "loss_ce_enc" in criterion.weight_dict
assert "loss_bbox_enc" in criterion.weight_dict
assert "loss_giou_enc" in criterion.weight_dict
def test_criterion_num_classes_plus_one(self) -> None:
mc = RFDETRBaseConfig(num_classes=5, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
criterion, _ = build_criterion_and_postprocessors(ns)
assert criterion.num_classes == 6
def test_focal_alpha_forwarded(self) -> None:
ns = _make_ns()
criterion, _ = build_criterion_and_postprocessors(ns)
assert criterion.focal_alpha == pytest.approx(0.25)
def test_group_detr_forwarded_to_criterion(self) -> None:
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
criterion, _ = build_criterion_and_postprocessors(ns)
assert criterion.group_detr == mc.group_detr
def test_segmentation_criterion_has_mask_point_sample_ratio(self) -> None:
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
tc = SegmentationTrainConfig(dataset_dir="/tmp")
ns = _make_ns(mc=mc, tc=tc)
criterion, _ = build_criterion_and_postprocessors(ns)
assert criterion.mask_point_sample_ratio == 16
def test_ia_bce_loss_forwarded(self) -> None:
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
criterion, _ = build_criterion_and_postprocessors(ns)
assert criterion.ia_bce_loss == mc.ia_bce_loss
# ---------------------------------------------------------------------------
# _build_model_context characterization
# ---------------------------------------------------------------------------
class TestBuildModelContextCharacterization:
"""Pin current _build_model_context() behaviour.
_build_model_context is the inference-path factory used by RFDETR.get_model(). It has zero test coverage today.
"""
def test_returns_model_context(self) -> None:
from rfdetr.detr import ModelContext, _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert isinstance(ctx, ModelContext)
def test_model_is_lwdetr(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert isinstance(ctx.model, LWDETR)
def test_postprocess_is_postprocess(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert isinstance(ctx.postprocess, PostProcess)
def test_resolution_from_config(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert ctx.resolution == mc.resolution
def test_device_from_config(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert ctx.device == torch.device("cpu")
def test_torch_device_cpu_from_config(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device=torch.device("cpu"))
ctx = _build_model_context(mc)
assert ctx.device == torch.device("cpu")
def test_class_names_none_without_pretrain(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert ctx.class_names is None
def test_num_select_on_postprocess(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert ctx.postprocess.num_select == 100
def test_keypoint_preview_postprocess_has_keypoint_schema(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRKeypointPreviewConfig(pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert ctx.postprocess.num_keypoints_per_class == [17]
def test_args_namespace_attached(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert hasattr(ctx.args, "num_classes")
assert hasattr(ctx.args, "num_select")
def test_inference_model_initially_none(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert ctx.inference_model is None
def test_args_dataset_dir_does_not_leak_cwd(self) -> None:
"""The serialized namespace must not embed the caller's realpathed CWD as dataset_dir."""
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert ctx.args.dataset_dir is None
def test_args_output_dir_does_not_leak_cwd(self) -> None:
"""The serialized namespace must keep a relative output_dir, not the caller's realpathed CWD."""
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert ctx.args.output_dir == "output"
# ---------------------------------------------------------------------------
# RFDETRModelModule.__init__ characterization
# ---------------------------------------------------------------------------
class TestRFDETRModelModuleInitCharacterization:
"""Pin RFDETRModelModule.__init__() structural outputs.
The existing test_module_model.py tests the init via mocked build_model and build_namespace. These tests exercise
the REAL init path (no mocks) to characterize what a freshly built module looks like.
"""
def _make_module(self, mc=None, tc=None):
from rfdetr.training.module_model import RFDETRModelModule
mc = mc or RFDETRBaseConfig(num_classes=5, pretrain_weights=None, device="cpu")
tc = tc or TrainConfig(dataset_dir="/tmp")
return RFDETRModelModule(mc, tc)
def test_model_attribute_is_lwdetr(self) -> None:
module = self._make_module()
# model could be wrapped by torch.compile, so check the underlying type
underlying = getattr(module.model, "_orig_mod", module.model)
assert isinstance(underlying, LWDETR)
def test_criterion_is_set_criterion(self) -> None:
module = self._make_module()
assert isinstance(module.criterion, SetCriterion)
def test_postprocess_is_postprocess(self) -> None:
module = self._make_module()
assert isinstance(module.postprocess, PostProcess)
def test_strict_loading_false(self) -> None:
"""strict_loading=False allows partial state-dict loading."""
module = self._make_module()
assert module.strict_loading is False
def test_configs_stored(self) -> None:
mc = RFDETRBaseConfig(num_classes=5, pretrain_weights=None, device="cpu")
tc = TrainConfig(dataset_dir="/tmp")
module = self._make_module(mc=mc, tc=tc)
assert module.model_config is mc
assert module.train_config is tc
def test_criterion_num_classes_matches_model(self) -> None:
"""Criterion and model must agree on num_classes (both use +1 convention)."""
mc = RFDETRBaseConfig(num_classes=5, pretrain_weights=None, device="cpu")
module = self._make_module(mc=mc)
underlying = getattr(module.model, "_orig_mod", module.model)
assert module.criterion.num_classes == underlying.class_embed.out_features
def test_postprocess_num_select_matches_config(self) -> None:
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
tc = SegmentationTrainConfig(dataset_dir="/tmp")
module = self._make_module(mc=mc, tc=tc)
assert module.postprocess.num_select == mc.num_select
def test_segmentation_criterion_with_seg_config(self) -> None:
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
tc = SegmentationTrainConfig(dataset_dir="/tmp")
module = self._make_module(mc=mc, tc=tc)
assert "masks" in module.criterion.losses
+162
View File
@@ -0,0 +1,162 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Characterization tests for config-native builder functions.
These tests validate build_model_from_config() and build_criterion_from_config() which accept Pydantic config objects
directly instead of requiring a pre-built SimpleNamespace. If these functions cannot be imported, all tests skip via the
module-level pytestmark.
"""
import pytest
from rfdetr.config import (
RFDETRBaseConfig,
RFDETRSegNanoConfig,
SegmentationTrainConfig,
TrainConfig,
)
try:
from rfdetr.models import build_criterion_from_config, build_model_from_config
HAS_CONFIG_BUILDERS = True
except ImportError:
HAS_CONFIG_BUILDERS = False
pytestmark = pytest.mark.skipif(
not HAS_CONFIG_BUILDERS,
reason="config-native builder functions are not importable",
)
class TestBuildModelFromConfig:
"""Tests for build_model_from_config(model_config, defaults=MODEL_DEFAULTS)."""
def test_returns_lwdetr_for_base_config(self) -> None:
"""build_model_from_config with RFDETRBaseConfig returns an LWDETR instance."""
from rfdetr.models.lwdetr import LWDETR
mc = RFDETRBaseConfig(num_classes=80)
model = build_model_from_config(mc)
assert isinstance(model, LWDETR), f"Expected LWDETR instance, got {type(model).__name__}"
def test_num_classes_correct(self) -> None:
"""num_classes=5 in config should produce class_embed with out_features=6.
build_model adds +1 to num_classes (background class convention).
"""
mc = RFDETRBaseConfig(num_classes=5)
model = build_model_from_config(mc)
assert model.class_embed.out_features == 6, (
f"Expected class_embed.out_features=6 (num_classes+1), got {model.class_embed.out_features}"
)
def test_parity_with_build_model_via_namespace(self) -> None:
"""Parameter count must match between config-native and namespace paths."""
from rfdetr._namespace import _namespace_from_configs
from rfdetr.models.lwdetr import build_model
mc = RFDETRBaseConfig(num_classes=80)
tc = TrainConfig(dataset_dir="/tmp")
model_config_native = build_model_from_config(mc, tc)
ns = _namespace_from_configs(mc, tc)
model_namespace = build_model(ns)
params_native = sum(p.numel() for p in model_config_native.parameters())
params_namespace = sum(p.numel() for p in model_namespace.parameters())
assert params_native == params_namespace, (
f"Parameter count mismatch: config-native={params_native}, namespace={params_namespace}"
)
def test_segmentation_head_created_when_true(self) -> None:
"""RFDETRSegNanoConfig has segmentation_head=True; model must have it."""
mc = RFDETRSegNanoConfig()
model = build_model_from_config(mc)
assert model.segmentation_head is not None, "Expected segmentation_head to be created for RFDETRSegNanoConfig"
def test_drop_path_uses_train_config_value(self) -> None:
"""Non-default TrainConfig.drop_path must reach the model builder path."""
mc = RFDETRBaseConfig(num_classes=80)
tc = TrainConfig(dataset_dir="/tmp", drop_path=0.2)
model = build_model_from_config(mc, tc)
layers = model._get_backbone_encoder_layers()
assert layers is not None
assert hasattr(layers[-1], "drop_path")
assert layers[-1].drop_path.drop_prob == pytest.approx(0.2)
def test_rejects_encoder_only_defaults(self) -> None:
"""The config-native builder guarantees an LWDETR return value."""
from dataclasses import replace
from rfdetr.models import MODEL_DEFAULTS
mc = RFDETRBaseConfig(num_classes=80)
with pytest.raises(ValueError, match="encoder_only=False"):
build_model_from_config(mc, defaults=replace(MODEL_DEFAULTS, encoder_only=True))
def test_rejects_backbone_only_defaults(self) -> None:
"""backbone_only=True in defaults must also raise ValueError."""
from dataclasses import replace
from rfdetr.models import MODEL_DEFAULTS
mc = RFDETRBaseConfig(num_classes=80)
with pytest.raises(ValueError, match="backbone_only=False"):
build_model_from_config(mc, defaults=replace(MODEL_DEFAULTS, backbone_only=True))
def test_none_train_config_uses_dummy(self) -> None:
"""build_model_from_config with train_config=None must not raise."""
mc = RFDETRBaseConfig(num_classes=80)
model = build_model_from_config(mc, train_config=None)
assert model is not None, "Expected a model, got None"
class TestBuildCriterionFromConfig:
"""Tests for build_criterion_from_config(model_config, train_config, defaults)."""
def test_returns_tuple(self) -> None:
"""build_criterion_from_config must return a 2-tuple (SetCriterion, PostProcess)."""
from rfdetr.models.criterion import SetCriterion
from rfdetr.models.postprocess import PostProcess
mc = RFDETRBaseConfig(num_classes=80)
tc = TrainConfig(dataset_dir="/tmp")
result = build_criterion_from_config(mc, tc)
assert isinstance(result, tuple), f"Expected tuple, got {type(result).__name__}"
assert len(result) == 2, f"Expected 2-tuple, got {len(result)}-tuple"
criterion, postprocess = result
assert isinstance(criterion, SetCriterion), f"Expected SetCriterion, got {type(criterion).__name__}"
assert isinstance(postprocess, PostProcess), f"Expected PostProcess, got {type(postprocess).__name__}"
def test_num_select_postprocess(self) -> None:
"""RFDETRSegNanoConfig has num_select=100; PostProcess must reflect it."""
mc = RFDETRSegNanoConfig()
tc = SegmentationTrainConfig(dataset_dir="/tmp")
_, postprocess = build_criterion_from_config(mc, tc)
assert postprocess.num_select == 100, f"Expected PostProcess.num_select=100, got {postprocess.num_select}"
def test_segmentation_losses_included(self) -> None:
"""With segmentation config, 'masks' must be in criterion.losses."""
mc = RFDETRSegNanoConfig()
tc = SegmentationTrainConfig(dataset_dir="/tmp")
criterion, _ = build_criterion_from_config(mc, tc)
assert "masks" in criterion.losses, f"Expected 'masks' in criterion.losses, got {criterion.losses}"
def test_custom_defaults_focal_alpha_applied(self) -> None:
"""Custom focal_alpha in ModelDefaults must reach SetCriterion."""
from dataclasses import replace
from rfdetr.models import MODEL_DEFAULTS
mc = RFDETRBaseConfig(num_classes=80)
tc = TrainConfig(dataset_dir="/tmp")
custom_defaults = replace(MODEL_DEFAULTS, focal_alpha=0.5)
criterion, _ = build_criterion_from_config(mc, tc, defaults=custom_defaults)
assert criterion.focal_alpha == pytest.approx(0.5), f"Expected focal_alpha=0.5, got {criterion.focal_alpha}"
+925
View File
@@ -0,0 +1,925 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
import os
import warnings
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import torch
from pydantic import ValidationError
from rfdetr.config import (
KeypointTrainConfig,
ModelConfig,
PretrainWeightsCompatibilityWarning,
RFDETRBaseConfig,
RFDETRLargeConfig,
RFDETRMediumConfig,
RFDETRNanoConfig,
RFDETRSeg2XLargeConfig,
RFDETRSegLargeConfig,
RFDETRSegMediumConfig,
RFDETRSegNanoConfig,
RFDETRSegSmallConfig,
RFDETRSegXLargeConfig,
RFDETRSmallConfig,
SegmentationTrainConfig,
TrainConfig,
_detect_device,
)
@pytest.fixture
def sample_model_config() -> dict[str, object]:
return {
"encoder": "dinov2_windowed_small",
"out_feature_indexes": [1, 2, 3],
"dec_layers": 3,
"projector_scale": ["P3"],
"hidden_dim": 256,
"patch_size": 14,
"num_windows": 2,
"sa_nheads": 8,
"ca_nheads": 8,
"dec_n_points": 4,
"resolution": 384,
"positional_encoding_size": 256,
}
class TestModelConfigValidation:
def test_rejects_unknown_fields(self, sample_model_config) -> None:
sample_model_config["unknown"] = "value"
with pytest.raises(ValidationError, match=r"Unknown parameter\(s\): 'unknown'"):
ModelConfig(**sample_model_config)
def test_rejects_unknown_attribute_assignment(self, sample_model_config) -> None:
config = ModelConfig(**sample_model_config)
with pytest.raises(ValueError, match=r"Unknown attribute: 'unknown'\."):
setattr(config, "unknown", "value")
def test_accepts_indexed_cuda_device_string(self, sample_model_config) -> None:
config = ModelConfig(**sample_model_config, device="cuda:1")
assert config.device == "cuda:1"
def test_accepts_torch_device(self, sample_model_config) -> None:
config = ModelConfig(**sample_model_config, device=torch.device("cuda:2"))
assert config.device == "cuda:2"
def test_rejects_non_string_non_torch_device_with_validation_error(self, sample_model_config) -> None:
with pytest.raises(ValidationError, match="device must be a string or torch\\.device\\."):
ModelConfig(**sample_model_config, device=123)
def test_rejects_invalid_device_string(self, sample_model_config) -> None:
with pytest.raises(ValidationError, match="Invalid device specifier: 'notadevice'\\."):
ModelConfig(**sample_model_config, device="notadevice")
@pytest.mark.parametrize(
"encoder",
[
pytest.param("dinov2_windowed_small", id="windowed_small"),
pytest.param("dinov2_windowed_base", id="windowed_base"),
pytest.param("dinov2_registers_windowed_small", id="registers_windowed_small"),
],
)
def test_accepts_valid_encoder(self, sample_model_config, encoder: str) -> None:
"""ModelConfig accepts every value in the EncoderName Literal."""
config = ModelConfig(**{**sample_model_config, "encoder": encoder})
assert config.encoder == encoder
def test_rejects_invalid_encoder(self, sample_model_config) -> None:
"""ModelConfig raises ValidationError for encoder strings outside the Literal."""
with pytest.raises(ValidationError):
ModelConfig(**{**sample_model_config, "encoder": "dinov2_invalid_backbone"})
def test_rejects_negative_postprocess_trace_alpha(self, sample_model_config) -> None:
"""ModelConfig rejects negative uncertainty score-fusion exponents."""
with pytest.raises(ValidationError):
ModelConfig(**sample_model_config, postprocess_trace_alpha=-0.1)
def test_postprocess_trace_alpha_defaults_to_keypoint_fusion_value(self, sample_model_config) -> None:
"""ModelConfig defaults to the keypoint uncertainty score-fusion exponent."""
config = ModelConfig(**sample_model_config)
assert config.postprocess_trace_alpha == 0.2
def test_pretrain_weights_absolute_path_realpath_normalised(self, tmp_path) -> None:
"""An absolute pathlib.Path for pretrain_weights is stored as the realpath-normalised string."""
weights_path = tmp_path / "weights.pth"
config = RFDETRBaseConfig(pretrain_weights=weights_path)
assert config.pretrain_weights == os.path.realpath(os.fspath(weights_path))
@pytest.mark.parametrize(
"field",
[
pytest.param("dataset_dir", id="dataset_dir"),
pytest.param("output_dir", id="output_dir"),
],
)
def test_train_dir_fields_accept_path(self, tmp_path, field: str) -> None:
"""TrainConfig dataset/output dir fields accept pathlib.Path and store the realpath-normalised string."""
path = tmp_path / "artifact"
kwargs = {"dataset_dir": str(tmp_path)}
kwargs[field] = path
config = TrainConfig(**kwargs)
assert getattr(config, field) == os.path.realpath(os.fspath(path))
def test_accepts_bare_path_object_for_pretrain_weights(self) -> None:
"""Bare pretrained weight Path values resolve the same as bare strings."""
path_config = RFDETRBaseConfig(pretrain_weights=Path("rf-detr-base.pth"))
string_config = RFDETRBaseConfig(pretrain_weights="rf-detr-base.pth")
assert path_config.pretrain_weights == string_config.pretrain_weights
@pytest.mark.parametrize(
"value",
[
# PTL trainer.fit(ckpt_path=...) sentinels — must pass through verbatim.
pytest.param("best", id="sentinel_best"),
pytest.param("last", id="sentinel_last"),
pytest.param("hpc", id="sentinel_hpc"),
pytest.param("registry:model-name", id="sentinel_registry"),
# A relative Path proves os.fspath coercion without realpath resolution.
pytest.param(Path("checkpoints/last.ckpt"), id="path_object"),
],
)
def test_resume_coerced_via_fspath_without_realpath(self, value) -> None:
"""``resume`` accepts pathlib.Path and is coerced to ``str`` via ``os.fspath`` only.
Unlike ``dataset_dir``/``output_dir``, ``resume`` is forwarded verbatim to PyTorch Lightning's
``trainer.fit(ckpt_path=...)``, which also accepts sentinels such as ``"last"``. Realpath-normalising it would
rewrite those sentinels (and relative paths) into spurious absolute paths, so the value must be preserved.
"""
config = TrainConfig(dataset_dir="/tmp", resume=value)
assert config.resume == os.fspath(value)
class TestRFDETRBaseConfigEncoder:
"""Encoder field validation on RFDETRBaseConfig (no fixture needed — has defaults)."""
def test_accepts_registers_windowed_small(self) -> None:
"""RFDETRBaseConfig accepts the new dinov2_registers_windowed_small encoder."""
config = RFDETRBaseConfig(encoder="dinov2_registers_windowed_small", pretrain_weights=None)
assert config.encoder == "dinov2_registers_windowed_small"
def test_rejects_invalid_encoder(self) -> None:
"""RFDETRBaseConfig raises ValidationError for unknown encoder strings."""
with pytest.raises(ValidationError):
RFDETRBaseConfig(encoder="not_a_real_encoder", pretrain_weights=None)
class TestSegmentationTrainConfigNumSelect:
"""Unit tests for SegmentationTrainConfig.num_select default and per-model values."""
def test_defaults_to_none(self) -> None:
config = SegmentationTrainConfig(dataset_dir="/tmp")
assert config.num_select is None
def test_explicit_value_is_accepted(self) -> None:
# Explicitly setting num_select on SegmentationTrainConfig is deprecated (Item #3).
with pytest.warns(DeprecationWarning, match="TrainConfig.num_select is deprecated"):
config = SegmentationTrainConfig(dataset_dir="/tmp", num_select=42)
assert config.num_select == 42
@pytest.mark.parametrize(
"config_class, expected_num_select",
[
(RFDETRSegNanoConfig, 100),
(RFDETRSegSmallConfig, 100),
(RFDETRSegMediumConfig, 200),
(RFDETRSegLargeConfig, 200),
(RFDETRSegXLargeConfig, 300),
(RFDETRSeg2XLargeConfig, 300),
],
)
def test_model_config_has_variant_specific_num_select(self, config_class, expected_num_select) -> None:
assert config_class().num_select == expected_num_select
class TestTrainConfigRejectsUnknownKwargs:
"""TrainConfig must raise on unknown/typo'd kwargs instead of silently ignoring them (extra='forbid')."""
def test_typo_kwarg_raises_with_helpful_message(self, tmp_path) -> None:
"""A typo'd kwarg (epoch instead of epochs) raises listing the unknown and available parameters."""
with pytest.raises(ValidationError, match=r"Unknown parameter\(s\): 'epoch'"):
TrainConfig(dataset_dir=str(tmp_path), output_dir=str(tmp_path), epoch=5)
def test_typo_error_lists_available_parameters(self, tmp_path) -> None:
"""The rejection message includes the available parameter list so the typo is easy to fix."""
with pytest.raises(ValidationError, match=r"Available parameter\(s\):.*epochs"):
TrainConfig(dataset_dir=str(tmp_path), output_dir=str(tmp_path), epoch=5)
@pytest.mark.parametrize(
"config_class",
[
pytest.param(SegmentationTrainConfig, id="segmentation"),
pytest.param(KeypointTrainConfig, id="keypoint"),
],
)
def test_subclasses_reject_unknown_kwargs(self, tmp_path, config_class) -> None:
"""TrainConfig subclasses inherit the forbid behaviour."""
with pytest.raises(ValidationError, match=r"Unknown parameter\(s\): 'epoch'"):
config_class(dataset_dir=str(tmp_path), output_dir=str(tmp_path), epoch=5)
def test_get_train_config_raises_for_typo_kwarg(self, tmp_path) -> None:
"""The public RFDETR.get_train_config path surfaces the typo instead of swallowing it."""
from types import SimpleNamespace
from rfdetr.detr import RFDETR
stub = SimpleNamespace(_train_config_class=TrainConfig)
with pytest.raises(ValidationError, match=r"Unknown parameter\(s\): 'epoch'"):
RFDETR.get_train_config(stub, dataset_dir=str(tmp_path), output_dir=str(tmp_path), epoch=5)
class TestTrainConfigT42PromotedFields:
"""T4-2: Promoted fields exist with correct defaults; device field is absent."""
def _tc(self, tmp_path, **kwargs):
defaults = dict(dataset_dir=str(tmp_path), output_dir=str(tmp_path), tensorboard=False)
defaults.update(kwargs)
return TrainConfig(**defaults)
# --- device field removed ---
def test_device_not_in_model_fields(self):
"""Device must not appear in TrainConfig.model_fields (PTL auto-detects accelerator)."""
assert "device" not in TrainConfig.model_fields
def test_device_kwarg_rejected(self, tmp_path):
"""Passing device= directly to TrainConfig raises (extra='forbid'); RFDETR.train() pops it beforehand."""
with pytest.raises(ValidationError, match=r"Unknown parameter\(s\): 'device'"):
self._tc(tmp_path, device="cpu")
# --- promoted fields: defaults ---
def test_clip_max_norm_default(self, tmp_path):
"""clip_max_norm defaults to 0.1."""
assert self._tc(tmp_path).clip_max_norm == pytest.approx(0.1)
def test_seed_default_is_none(self, tmp_path):
"""Seed defaults to None (no seeding)."""
assert self._tc(tmp_path).seed is None
def test_sync_bn_default_is_false(self, tmp_path):
"""sync_bn defaults to False."""
assert self._tc(tmp_path).sync_bn is False
def test_fp16_eval_default_is_false(self, tmp_path):
"""fp16_eval defaults to False."""
assert self._tc(tmp_path).fp16_eval is False
def test_lr_scheduler_default_is_step(self, tmp_path):
"""lr_scheduler defaults to 'step'."""
assert self._tc(tmp_path).lr_scheduler == "step"
def test_lr_min_factor_default(self, tmp_path):
"""lr_min_factor defaults to 0.0."""
assert self._tc(tmp_path).lr_min_factor == pytest.approx(0.0)
def test_dont_save_weights_default_is_false(self, tmp_path):
"""dont_save_weights defaults to False."""
assert self._tc(tmp_path).dont_save_weights is False
def test_run_test_default_is_false(self, tmp_path):
"""run_test defaults to False to avoid extra full-dataset test passes."""
assert self._tc(tmp_path).run_test is False
def test_eval_interval_default_is_one(self, tmp_path):
"""eval_interval defaults to 1 (evaluate each epoch)."""
assert self._tc(tmp_path).eval_interval == 1
def test_skip_best_epochs_default_is_zero(self, tmp_path):
"""skip_best_epochs defaults to 0 for backward compatibility."""
assert self._tc(tmp_path).skip_best_epochs == 0
def test_ema_update_interval_default_is_one(self, tmp_path):
"""ema_update_interval defaults to 1 (update every step)."""
assert self._tc(tmp_path).ema_update_interval == 1
def test_compute_val_loss_default_is_true(self, tmp_path):
"""compute_val_loss defaults to True."""
assert self._tc(tmp_path).compute_val_loss is True
def test_compute_test_loss_default_is_true(self, tmp_path):
"""compute_test_loss defaults to True."""
assert self._tc(tmp_path).compute_test_loss is True
# --- promoted fields: accept explicit values ---
@pytest.mark.parametrize(
"field, value",
[
pytest.param("clip_max_norm", 0.5, id="clip_max_norm"),
pytest.param("seed", 42, id="seed"),
pytest.param("sync_bn", True, id="sync_bn"),
pytest.param("fp16_eval", True, id="fp16_eval"),
pytest.param("lr_scheduler", "cosine", id="lr_scheduler_cosine"),
pytest.param("lr_min_factor", 0.01, id="lr_min_factor"),
pytest.param("dont_save_weights", True, id="dont_save_weights"),
pytest.param("run_test", True, id="run_test"),
pytest.param("eval_interval", 3, id="eval_interval"),
pytest.param("skip_best_epochs", 3, id="skip_best_epochs"),
pytest.param("ema_update_interval", 4, id="ema_update_interval"),
pytest.param("compute_val_loss", False, id="compute_val_loss"),
pytest.param("compute_test_loss", False, id="compute_test_loss"),
pytest.param("train_log_sync_dist", True, id="train_log_sync_dist"),
pytest.param("train_log_on_step", True, id="train_log_on_step"),
pytest.param("log_per_class_metrics", False, id="log_per_class_metrics"),
pytest.param("prefetch_factor", 4, id="prefetch_factor"),
pytest.param("pin_memory", False, id="pin_memory"),
pytest.param("persistent_workers", False, id="persistent_workers"),
],
)
def test_promoted_field_accepts_explicit_value(self, tmp_path, field, value):
"""Each promoted field accepts an explicit value."""
tc = self._tc(tmp_path, **{field: value})
assert getattr(tc, field) == value
def test_lr_scheduler_rejects_invalid_value(self, tmp_path):
"""lr_scheduler must reject values other than 'step' and 'cosine'."""
with pytest.raises((ValueError, ValidationError)):
self._tc(tmp_path, lr_scheduler="cyclic")
@pytest.mark.parametrize(
("field", "value"),
[
pytest.param("eval_interval", 0, id="eval_interval_zero"),
pytest.param("skip_best_epochs", -1, id="skip_best_epochs_negative"),
pytest.param("ema_update_interval", 0, id="ema_update_interval_zero"),
pytest.param("prefetch_factor", 0, id="prefetch_factor_zero"),
],
)
def test_interval_and_prefetch_reject_non_positive_values(self, tmp_path, field, value):
"""Eval/EMA intervals and prefetch_factor must be >= 1 when provided."""
with pytest.raises((ValueError, ValidationError)):
self._tc(tmp_path, **{field: value})
def test_batch_size_auto_is_accepted(self, tmp_path):
"""batch_size accepts the special 'auto' value."""
tc = self._tc(tmp_path, batch_size="auto")
assert tc.batch_size == "auto"
@pytest.mark.parametrize(
"field,value",
[
("batch_size", 0),
("grad_accum_steps", 0),
("auto_batch_target_effective", 0),
("auto_batch_max_targets_per_image", 0),
],
)
def test_auto_batch_related_fields_reject_non_positive_values(self, tmp_path, field, value):
"""batch/accum/target-effective/max_targets fields must be >= 1 (except batch_size='auto')."""
with pytest.raises((ValueError, ValidationError)):
self._tc(tmp_path, **{field: value})
@pytest.mark.parametrize("ema_headroom", [0.0, 1.5])
def test_auto_batch_ema_headroom_must_be_in_open_one(self, tmp_path, ema_headroom):
"""auto_batch_ema_headroom must be in (0, 1]."""
with pytest.raises((ValueError, ValidationError)):
self._tc(tmp_path, auto_batch_ema_headroom=ema_headroom)
class TestBuildTrainerUsesRealFields:
"""build_trainer() must read clip_max_norm, seed, sync_bn from real TrainConfig fields."""
def _tc(self, tmp_path, **kwargs):
defaults = dict(
dataset_dir=str(tmp_path),
output_dir=str(tmp_path),
tensorboard=False,
wandb=False,
mlflow=False,
clearml=False,
use_ema=False,
)
defaults.update(kwargs)
return TrainConfig(**defaults)
def _kp_tc(self, tmp_path, **kwargs):
defaults = dict(
dataset_dir=str(tmp_path),
output_dir=str(tmp_path),
tensorboard=False,
wandb=False,
mlflow=False,
clearml=False,
use_ema=False,
)
defaults.update(kwargs)
return KeypointTrainConfig(**defaults)
def _mc(self, **kwargs):
from rfdetr.config import RFDETRBaseConfig
defaults = dict(pretrain_weights=None, device="cpu", num_classes=3)
defaults.update(kwargs)
return RFDETRBaseConfig(**defaults)
def test_clip_max_norm_forwarded_to_trainer_for_detection(self, tmp_path):
"""Detection models use Lightning's automatic optimization, so ``gradient_clip_val`` flows through to the
Trainer from ``TrainConfig.clip_max_norm`` unchanged."""
from rfdetr.training import build_trainer
trainer = build_trainer(self._tc(tmp_path, clip_max_norm=0.25), self._mc())
assert trainer.gradient_clip_val == pytest.approx(0.25)
def test_clip_max_norm_owned_by_model_module_for_keypoints(self, tmp_path):
"""Keypoint models use manual optimization; trainer-owned clipping is disabled and ``clip_max_norm`` is applied
inside ``RFDETRModelModule._step_optimizer`` instead."""
from rfdetr.training import build_trainer
trainer = build_trainer(
self._kp_tc(tmp_path, clip_max_norm=0.25),
self._mc(use_grouppose_keypoints=True),
)
assert trainer.gradient_clip_val is None
def test_seed_not_applied_in_build_trainer_factory(self, tmp_path):
"""Seeding is deferred to RFDETRModule.on_fit_start, not build_trainer()."""
import unittest.mock as mock
from rfdetr.training import build_trainer
with mock.patch("pytorch_lightning.seed_everything") as mock_seed:
build_trainer(self._tc(tmp_path, seed=99), self._mc())
mock_seed.assert_not_called()
def test_sync_bn_forwarded_to_trainer(self, tmp_path):
"""sync_batchnorm=True is passed to Trainer when TrainConfig.sync_bn is True."""
import unittest.mock as mock
from rfdetr.training import build_trainer
captured_kwargs = {}
real_trainer_init = __import__("pytorch_lightning").Trainer.__init__
def _capture_init(self_t, **kwargs):
captured_kwargs.update(kwargs)
real_trainer_init(self_t, **kwargs)
with mock.patch("rfdetr.training.trainer.Trainer.__init__", _capture_init):
build_trainer(self._tc(tmp_path, sync_bn=True), self._mc())
assert captured_kwargs.get("sync_batchnorm") is True
class TestDeprecatedTrainConfigFields:
"""Item #3 Phase A: TrainConfig fields deprecated in favour of ModelConfig ownership."""
def _tc(self, **kwargs):
defaults = dict(dataset_dir="/tmp")
defaults.update(kwargs)
return TrainConfig(**defaults)
@pytest.mark.parametrize(
"field,value",
[
pytest.param("group_detr", 5, id="group_detr"),
pytest.param("ia_bce_loss", False, id="ia_bce_loss"),
pytest.param("segmentation_head", True, id="segmentation_head"),
pytest.param("num_select", 100, id="num_select"),
],
)
def test_explicitly_set_deprecated_field_emits_warning(self, field, value) -> None:
"""Setting a deprecated TrainConfig field explicitly must emit DeprecationWarning."""
with pytest.warns(DeprecationWarning, match=f"TrainConfig\\.{field} is deprecated"):
self._tc(**{field: value})
def test_default_group_detr_no_warning(self, recwarn) -> None:
"""TrainConfig() without explicit group_detr must NOT warn."""
self._tc()
depr_warnings = [w for w in recwarn.list if issubclass(w.category, DeprecationWarning)]
assert not depr_warnings, f"Unexpected DeprecationWarning: {depr_warnings}"
def test_segmentation_train_config_no_warning_on_default_fields(self, recwarn) -> None:
"""SegmentationTrainConfig() must NOT warn for its class-level defaults.
segmentation_head=True and num_select=None are SegmentationTrainConfig defaults, not explicitly set by the user
— they must not trigger DeprecationWarning.
"""
SegmentationTrainConfig(dataset_dir="/tmp")
depr_warnings = [w for w in recwarn.list if issubclass(w.category, DeprecationWarning)]
assert not depr_warnings, f"Unexpected DeprecationWarning: {depr_warnings}"
class TestDeprecatedModelConfigClsLossCoef:
"""Item #3 Phase A: ModelConfig.cls_loss_coef deprecated in favour of TrainConfig ownership."""
def test_explicit_cls_loss_coef_emits_warning(self) -> None:
"""Setting cls_loss_coef on ModelConfig explicitly must emit DeprecationWarning."""
sample = dict(
encoder="dinov2_windowed_small",
out_feature_indexes=[1, 2, 3],
dec_layers=3,
projector_scale=["P3"],
hidden_dim=256,
patch_size=14,
num_windows=2,
sa_nheads=8,
ca_nheads=8,
dec_n_points=4,
resolution=384,
positional_encoding_size=256,
)
with pytest.warns(DeprecationWarning, match="ModelConfig\\.cls_loss_coef is deprecated"):
ModelConfig(**sample, cls_loss_coef=2.0)
def test_default_cls_loss_coef_no_warning(self, recwarn) -> None:
"""RFDETRBaseConfig() without explicit cls_loss_coef must NOT warn."""
RFDETRBaseConfig(pretrain_weights=None, device="cpu")
depr_warnings = [w for w in recwarn.list if issubclass(w.category, DeprecationWarning)]
assert not depr_warnings, f"Unexpected DeprecationWarning: {depr_warnings}"
class TestSyncPEWithResolutionAtConstruction:
"""Tests for the _sync_pe_with_resolution model_validator.
When a user provides a custom resolution at construction time (e.g., ``RFDETRLarge(resolution=640)``),
positional_encoding_size must be updated proportionally for configs where the default PE is formula-derived
(``default_pe == default_resolution // patch_size``).
"""
@pytest.mark.parametrize(
"config_cls, new_resolution, expected_pe",
[
pytest.param(RFDETRLargeConfig, 640, 640 // 16, id="large_640"),
pytest.param(RFDETRLargeConfig, 576, 576 // 16, id="large_576"),
pytest.param(RFDETRSmallConfig, 640, 640 // 16, id="small_640"),
pytest.param(RFDETRMediumConfig, 640, 640 // 16, id="medium_640"),
pytest.param(RFDETRNanoConfig, 416, 416 // 16, id="nano_416"),
pytest.param(RFDETRSegNanoConfig, 360, 360 // 12, id="seg_nano_360"),
pytest.param(RFDETRSegSmallConfig, 480, 480 // 12, id="seg_small_480"),
pytest.param(RFDETRSegMediumConfig, 480, 480 // 12, id="seg_medium_480"),
pytest.param(RFDETRSegLargeConfig, 576, 576 // 12, id="seg_large_576"),
pytest.param(RFDETRSegXLargeConfig, 576, 576 // 12, id="seg_xlarge_576"),
pytest.param(RFDETRSeg2XLargeConfig, 720, 720 // 12, id="seg_2xlarge_720"),
],
)
def test_positional_encoding_size_updated_for_formula_derived_configs(
self,
config_cls: type,
new_resolution: int,
expected_pe: int,
) -> None:
"""PE is auto-derived from the custom resolution for formula-derived model configs."""
cfg = config_cls(resolution=new_resolution, pretrain_weights=None)
assert cfg.positional_encoding_size == expected_pe
def test_explicit_positional_encoding_size_is_not_overridden(self) -> None:
"""When positional_encoding_size is explicitly provided, the validator must not override it."""
cfg = RFDETRLargeConfig(resolution=640, positional_encoding_size=50, pretrain_weights=None)
assert cfg.positional_encoding_size == 50
def test_default_resolution_preserves_default_pe(self) -> None:
"""Constructing with default resolution (no explicit resolution) must not change PE."""
cfg = RFDETRLargeConfig(pretrain_weights=None)
assert cfg.resolution == 704
assert cfg.positional_encoding_size == 44 # 704 // 16
class TestDetectDevice:
"""Tests for _detect_device() covering PyTorch accelerator detection paths."""
@patch("rfdetr.config.torch")
def test_falls_back_to_cuda_when_accelerator_module_absent(self, mock_torch: MagicMock) -> None:
"""Returns 'cuda' via legacy fallback when torch.accelerator lacks current_accelerator (PyTorch < 2.4)."""
mock_torch.accelerator = MagicMock(spec=[]) # no current_accelerator → hasattr returns False → fallback
mock_torch.cuda.is_available.return_value = True
mock_torch.backends.mps.is_available.return_value = False
assert _detect_device() == "cuda"
@patch("rfdetr.config.torch")
def test_returns_cpu_when_current_accelerator_raises(self, mock_torch: MagicMock) -> None:
"""Returns 'cpu' directly from the except handler when current_accelerator() raises RuntimeError."""
mock_torch.accelerator.current_accelerator.side_effect = RuntimeError("no device")
assert _detect_device() == "cpu"
@patch("rfdetr.config.torch")
def test_returns_cpu_when_no_gpu_available(self, mock_torch: MagicMock) -> None:
"""Returns 'cpu' when accelerator is absent and neither CUDA nor MPS is available."""
mock_torch.accelerator = MagicMock(spec=[]) # no current_accelerator → fallback branch
mock_torch.cuda.is_available.return_value = False
mock_torch.backends.mps.is_available.return_value = False
assert _detect_device() == "cpu"
@patch("rfdetr.config.torch")
def test_returns_cpu_when_accelerator_compiled_in_but_unavailable(self, mock_torch: MagicMock) -> None:
"""Returns 'cpu' when torch was compiled with CUDA but no driver is present at runtime.
Without ``check_available=True``, ``current_accelerator()`` reports the compile-time accelerator, so the default
CUDA wheel on a driverless machine yields ``device("cuda")`` and every model build crashes with "Found no NVIDIA
driver". The runtime availability check must win.
"""
def fake_current_accelerator(check_available: bool = False) -> "torch.device | None":
return None if check_available else torch.device("cuda")
mock_torch.accelerator.current_accelerator = fake_current_accelerator
assert _detect_device() == "cpu"
@patch("rfdetr.config.torch")
def test_returns_accelerator_when_runtime_available(self, mock_torch: MagicMock) -> None:
"""Returns the accelerator when it passes the runtime availability check."""
def fake_current_accelerator(check_available: bool = False) -> "torch.device | None":
return torch.device("cuda") if check_available else None
mock_torch.accelerator.current_accelerator = fake_current_accelerator
assert _detect_device() == "cuda"
@patch("rfdetr.config.torch")
def test_legacy_signature_unavailable_accelerator_returns_cpu(self, mock_torch: MagicMock) -> None:
"""Falls back to ``is_available()`` when ``current_accelerator`` lacks ``check_available`` (PyTorch < 2.7)."""
def legacy_current_accelerator() -> "torch.device":
return torch.device("cuda")
mock_torch.accelerator.current_accelerator = legacy_current_accelerator
mock_torch.accelerator.is_available.return_value = False
assert _detect_device() == "cpu"
@patch("rfdetr.config.torch")
def test_legacy_signature_available_accelerator_is_kept(self, mock_torch: MagicMock) -> None:
"""Keeps the accelerator on pre-``check_available`` builds when ``is_available()`` confirms it."""
def legacy_current_accelerator() -> "torch.device":
return torch.device("cuda")
mock_torch.accelerator.current_accelerator = legacy_current_accelerator
mock_torch.accelerator.is_available.return_value = True
assert _detect_device() == "cuda"
@patch("rfdetr.config.torch")
def test_legacy_signature_runtime_error_on_fallback_returns_cpu(self, mock_torch: MagicMock) -> None:
"""Outer RuntimeError handler catches error from legacy fallback call.
Control-flow: ``current_accelerator(check_available=True)`` raises ``TypeError`` (inner except),
then ``current_accelerator()`` raises ``RuntimeError`` (outer except catches) → ``"cpu"``.
"""
call_count = 0
def raises_on_fallback(**kwargs: object) -> "torch.device":
nonlocal call_count
call_count += 1
if "check_available" in kwargs:
raise TypeError("unexpected keyword argument 'check_available'")
raise RuntimeError("NVML error on legacy fallback")
mock_torch.accelerator.current_accelerator = raises_on_fallback
assert _detect_device() == "cpu"
class TestPretrainWeightsCompatibilityWarning:
"""Config-time warning for overrides that prevent pretrained weights from loading.
These tests instantiate the variant *config* directly (not the wrapper class) so they do not touch the network, the
cache, or any model construction.
"""
def _capture(self, config_cls: type, **kwargs: object) -> list[warnings.WarningMessage]:
"""Instantiate ``config_cls(**kwargs)`` and return only the pretrain-compat warnings."""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
config_cls(**kwargs)
return [w for w in caught if issubclass(w.category, PretrainWeightsCompatibilityWarning)]
def test_default_construction_emits_no_warning(self) -> None:
"""Default variant construction must not warn — defaults match the published checkpoint."""
assert self._capture(RFDETRNanoConfig) == []
def test_encoder_registers_override_warns(self) -> None:
"""The dinov2-with-registers footgun: switching encoder away from the variant default."""
captured = self._capture(RFDETRNanoConfig, encoder="dinov2_registers_windowed_small")
assert len(captured) == 1
message = str(captured[0].message)
assert "encoder" in message
assert "dinov2_registers_windowed_small" in message
assert "dinov2_windowed_small" in message
@pytest.mark.parametrize(
"field, value",
[
pytest.param("hidden_dim", 384, id="hidden_dim"),
pytest.param("dec_layers", 6, id="dec_layers"),
pytest.param("num_windows", 4, id="num_windows"),
pytest.param("sa_nheads", 4, id="sa_nheads"),
pytest.param("ca_nheads", 8, id="ca_nheads"),
pytest.param("dec_n_points", 4, id="dec_n_points"),
pytest.param("out_feature_indexes", [2, 5, 8, 11], id="out_feature_indexes"),
pytest.param("projector_scale", ["P3", "P4"], id="projector_scale"),
pytest.param("bbox_reparam", False, id="bbox_reparam"),
pytest.param("lite_refpoint_refine", False, id="lite_refpoint_refine"),
pytest.param("layer_norm", False, id="layer_norm"),
pytest.param("two_stage", False, id="two_stage"),
pytest.param("num_channels", 1, id="num_channels"),
],
)
def test_load_breaking_override_warns(self, field: str, value: object) -> None:
"""Each load-breaking architecture override fires the warning."""
captured = self._capture(RFDETRNanoConfig, **{field: value})
assert len(captured) == 1
assert field in str(captured[0].message)
def test_mask_downsample_ratio_warns_on_seg_variant(self) -> None:
"""``mask_downsample_ratio`` change is silently miscalibrating; must warn at config time."""
captured = self._capture(RFDETRSegNanoConfig, mask_downsample_ratio=2)
assert len(captured) == 1
assert "mask_downsample_ratio" in str(captured[0].message)
def test_patch_size_override_warns_defense_in_depth(self) -> None:
"""patch_size already raises in load_pretrain_weights; the new warning is defense-in-depth.
We change patch_size to a value that differs from RFDETRNanoConfig's default (16).
"""
captured = self._capture(RFDETRNanoConfig, patch_size=14)
assert len(captured) == 1
assert "patch_size" in str(captured[0].message)
def test_segmentation_head_override_warns(self) -> None:
"""segmentation_head also raises at load time but warning fires first."""
# RFDETRNanoConfig has segmentation_head=False; flipping it to True is the override.
captured = self._capture(RFDETRNanoConfig, segmentation_head=True)
assert len(captured) == 1
assert "segmentation_head" in str(captured[0].message)
@pytest.mark.parametrize(
"field, value",
[
pytest.param("num_queries", 200, id="num_queries_decrease"),
pytest.param("num_queries", 300, id="num_queries_equal"),
pytest.param("group_detr", 8, id="group_detr_decrease"),
pytest.param("num_classes", 5, id="num_classes"),
pytest.param("resolution", 448, id="resolution"),
pytest.param("positional_encoding_size", 20, id="positional_encoding_size"),
],
)
def test_silent_field_overrides(self, field: str, value: object) -> None:
"""Fields that are auto-handled at load time must not emit a warning at config construction."""
assert self._capture(RFDETRNanoConfig, **{field: value}) == []
@pytest.mark.parametrize(
"field, value",
[
pytest.param("num_queries", 400, id="num_queries"),
pytest.param("group_detr", 20, id="group_detr"),
],
)
def test_increase_field_warns(self, field: str, value: object) -> None:
"""Increasing an integer field above the variant default warns — extra slots are randomly initialised."""
captured = self._capture(RFDETRNanoConfig, **{field: value})
assert len(captured) == 1
assert field in str(captured[0].message)
def test_pretrain_weights_none_warns(self) -> None:
"""Explicitly opting out of pretrained weights warns about training from scratch."""
captured = self._capture(RFDETRNanoConfig, pretrain_weights=None)
assert len(captured) == 1
message = str(captured[0].message)
assert "from scratch" in message
assert "rf-detr-nano.pth" in message
def test_pretrain_weights_none_only_one_warning(self) -> None:
"""When pretrain_weights=None, the architecture-overrides warning is suppressed.
The from-scratch warning is the dominant message; we don't pile on with arch warnings.
"""
captured = self._capture(
RFDETRNanoConfig,
pretrain_weights=None,
encoder="dinov2_registers_windowed_small",
hidden_dim=384,
)
assert len(captured) == 1
assert "from scratch" in str(captured[0].message)
def test_custom_pretrain_weights_path_suppresses_arch_warning(self) -> None:
"""Custom pretrain_weights path → defer to load-time detector — no config-time arch warning."""
captured = self._capture(
RFDETRNanoConfig,
pretrain_weights="/tmp/my_custom.pth",
encoder="dinov2_registers_windowed_small",
)
assert captured == []
def test_multiple_overrides_consolidated_into_one_warning(self) -> None:
"""All overrides are listed in a single warning, not one warning per field."""
captured = self._capture(
RFDETRNanoConfig,
encoder="dinov2_registers_windowed_small",
hidden_dim=384,
num_queries=400,
)
assert len(captured) == 1
message = str(captured[0].message)
for needle in ("encoder", "hidden_dim", "num_queries"):
assert needle in message, f"expected {needle!r} in consolidated warning message"
def test_warning_is_user_warning_subclass(self) -> None:
"""Confirms downstream filtering via UserWarning works."""
assert issubclass(PretrainWeightsCompatibilityWarning, UserWarning)
def test_modelconfig_with_required_fields_does_not_warn(self, sample_model_config: dict[str, object]) -> None:
"""Constructing the abstract ModelConfig with required fields cannot compare to defaults — no warning."""
assert self._capture(ModelConfig, **sample_model_config) == []
def test_breaking_field_with_default_factory_skips_comparison(self) -> None:
"""A subclass whose breaking field uses ``default_factory`` (so ``.default`` is ``PydanticUndefined``) must be
silently skipped — we have nothing to compare against."""
from pydantic import Field
class _DefaultFactoryConfig(RFDETRNanoConfig):
# Field uses default_factory → FieldInfo.default is PydanticUndefined,
# but is_required() is False. Hits the `continue` on the
# PydanticUndefined check.
encoder: str = Field(default_factory=lambda: "dinov2_windowed_small")
assert self._capture(_DefaultFactoryConfig, encoder="dinov2_registers_windowed_small") == []
def test_increase_field_when_required_skips_comparison(self) -> None:
"""A subclass where ``num_queries`` becomes required (no default) must be skipped."""
class _RequiredNumQueriesConfig(RFDETRNanoConfig):
num_queries: int # type: ignore[misc] # no default → required
assert self._capture(_RequiredNumQueriesConfig, num_queries=400) == []
def test_increase_field_with_non_int_default_skips_comparison(self) -> None:
"""A subclass where ``num_queries`` has a non-int default must be skipped (can't ``>`` compare)."""
from typing import Any
class _NonIntDefaultConfig(RFDETRNanoConfig):
num_queries: Any = "300" # type: ignore[assignment] # non-int default
assert self._capture(_NonIntDefaultConfig, num_queries="400") == []
def test_explicit_variant_default_path_runs_arch_override_check(self) -> None:
"""Passing the variant's own published-default path string must still check arch overrides.
Before the case-2 fix, any non-None explicit pretrain_weights bypassed the architecture-override check entirely
— including when the user passed the exact variant default string such as "rf-detr-nano.pth".
"""
captured = self._capture(
RFDETRNanoConfig,
pretrain_weights="rf-detr-nano.pth",
encoder="dinov2_registers_windowed_small",
)
assert len(captured) == 1
assert "encoder" in str(captured[0].message)
def test_product_preserving_group_detr_increase_still_warns(self) -> None:
"""Increasing group_detr while halving num_queries still warns — check is per-field, not product-aware.
This documents known current behaviour: the validator compares each field to its variant default independently,
not the combined query-slot product. A product- preserving change (group_detr=26, num_queries=150 vs defaults
13, 300) warns for group_detr because 26 > 13, regardless of whether total slots are the same.
"""
captured = self._capture(RFDETRNanoConfig, num_queries=150, group_detr=26)
assert len(captured) == 1
assert "group_detr" in str(captured[0].message)
class TestBreakingListIntegrity:
"""Guards against stale entries in the pretrain-compatibility breaking-field lists."""
def test_all_breaking_fields_exist_in_model_config(self) -> None:
"""Every field guarded by the pretrain-compatibility check must exist in ModelConfig.model_fields.
Catches typos and fields renamed/removed without updating the breaking lists.
"""
all_breaking = {
"encoder",
"hidden_dim",
"dec_layers",
"num_windows",
"sa_nheads",
"ca_nheads",
"dec_n_points",
"out_feature_indexes",
"projector_scale",
"bbox_reparam",
"lite_refpoint_refine",
"layer_norm",
"two_stage",
"patch_size",
"segmentation_head",
"num_channels",
"num_queries",
"group_detr",
}
stale = all_breaking - set(ModelConfig.model_fields.keys())
assert not stale, f"Fields in breaking lists not in ModelConfig.model_fields: {stale}"
+112
View File
@@ -0,0 +1,112 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Regression tests for keypoint config defaults and namespace forwarding."""
import pytest
from rfdetr._namespace import _namespace_from_configs
from rfdetr.config import (
KeypointTrainConfig,
RFDETRBaseConfig,
RFDETRKeypointPreviewConfig,
SegmentationTrainConfig,
)
def test_keypoint_config_defaults() -> None:
"""Default model/train keypoint configuration values should match the preview contract."""
model = RFDETRKeypointPreviewConfig()
train = KeypointTrainConfig(dataset_dir="/tmp")
assert model.use_grouppose_keypoints is True
assert model.dual_projector is True
assert model.dual_projector_kp_only is True
assert model.num_keypoints_per_class == [17]
assert model.positional_encoding_size == 576 // 12
assert train.keypoint_l1_loss_coef == pytest.approx(1.0)
assert train.keypoint_findable_loss_coef == pytest.approx(1.0)
assert train.keypoint_visible_loss_coef == pytest.approx(1.0)
assert train.keypoint_nll_loss_coef == pytest.approx(1.0)
assert train.cls_loss_coef == pytest.approx(2.0)
def test_keypoint_preview_config_person_schema() -> None:
"""Person-keypoint preview config must expose a person-only schema."""
model = RFDETRKeypointPreviewConfig()
assert model.num_keypoints_per_class == [17]
assert sum(model.num_keypoints_per_class) == 17
assert model.out_feature_indexes == [3, 6, 9, 12]
assert model.num_windows == 2
assert model.dec_layers == 4
assert model.patch_size == 12
assert model.resolution == 576
assert model.pretrain_weights == "rf-detr-keypoint-preview-xlarge.pth"
def test_keypoint_fields_propagate_to_namespace(tmp_path) -> None:
"""All keypoint config fields are forwarded through _namespace_from_configs."""
model = RFDETRKeypointPreviewConfig()
train = KeypointTrainConfig(
dataset_dir=str(tmp_path),
keypoint_flip_pairs=[0, 1, 2, 3],
keypoint_l1_loss_coef=1.5,
keypoint_findable_loss_coef=2.5,
keypoint_visible_loss_coef=3.5,
keypoint_nll_loss_coef=4.5,
)
namespace = _namespace_from_configs(model, train)
assert namespace.use_grouppose_keypoints is True
assert namespace.keypoint_cross_attn is True
assert namespace.inter_instance_kp_attn is False
assert namespace.grouppose_keypoint_dim_downscale == 1
assert namespace.dual_projector is True
assert namespace.dual_projector_kp_only is True
assert namespace.num_keypoints_per_class == [17]
assert namespace.keypoint_flip_pairs == [0, 1, 2, 3]
assert namespace.keypoint_l1_loss_coef == pytest.approx(1.5)
assert namespace.keypoint_findable_loss_coef == pytest.approx(2.5)
assert namespace.keypoint_visible_loss_coef == pytest.approx(3.5)
assert namespace.keypoint_nll_loss_coef == pytest.approx(4.5)
def test_keypoint_nll_loss_coef_default_restored_to_1_0() -> None:
"""keypoint_nll_loss_coef must default to 1.0 after the 0.5 revert.
The 0.5 default was introduced to dampen OKS@75 oscillation. It was later reverted to 1.0 to align with all other
keypoint loss terms (l1, findable, visible). This test guards against silent regressions.
"""
train = KeypointTrainConfig(dataset_dir="/tmp")
assert train.keypoint_nll_loss_coef == pytest.approx(1.0)
def test_segmentation_train_config_cls_loss_coef_default() -> None:
"""SegmentationTrainConfig.cls_loss_coef must default to 1.0, not the erroneous 5.0.
The 5.0 value was always present in SegmentationTrainConfig but was dead code pre-v1.7 (namespace builder read from
ModelConfig=1.0). The v1.7 TrainConfig ownership migration silently activated it. This test guards against re-
introducing that regression.
"""
tc = SegmentationTrainConfig(dataset_dir="/tmp")
assert tc.cls_loss_coef == pytest.approx(1.0)
def test_unknown_keypoint_fields_are_not_public_config_fields() -> None:
"""Private keypoint implementation fields are not accepted as public model config."""
with pytest.raises(ValueError, match="Unknown parameter"):
RFDETRBaseConfig(num_classes=1, keypoint_private_hidden_dim=256)
# KeypointTrainConfig (a TrainConfig subclass) uses extra="forbid", so unknown
# kwargs raise with a helpful message rather than being silently dropped.
with pytest.raises(ValueError, match="Unknown parameter"):
KeypointTrainConfig(
dataset_dir="/tmp",
keypoint_private_hidden_dim=256,
keypoint_private_loss_coef=1.0,
)
+134
View File
@@ -0,0 +1,134 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Unit tests for SetCriterion edge paths: _output_device and num_boxes_for_targets."""
import pytest
import torch
from rfdetr.models.criterion import SetCriterion
class _MatcherStub:
"""Minimal matcher that returns identity indices for every target in the batch."""
def __call__(self, outputs, targets, group_detr=1):
return [(torch.arange(len(t["labels"])), torch.arange(len(t["labels"]))) for t in targets]
def _bare_criterion() -> SetCriterion:
"""Return a SetCriterion with no losses so forward() is a no-op."""
criterion = SetCriterion.__new__(SetCriterion)
criterion.training = True
criterion.group_detr = 1
criterion.sum_group_losses = False
criterion.losses = []
criterion.weight_dict = {}
criterion.matcher = _MatcherStub()
criterion.num_keypoints_per_class = []
return criterion
class TestOutputDevice:
"""Tests for SetCriterion._output_device — probes top-level tensor values only."""
def test_returns_device_of_first_tensor(self):
"""Device inferred from the first tensor value in outputs."""
outputs = {"pred_logits": torch.zeros(1, 1, 1)}
device = SetCriterion._output_device(outputs)
assert device == torch.device("cpu")
def test_raises_when_no_tensor_present(self):
"""ValueError raised when no top-level value is a tensor."""
outputs = {"meta": "string_value", "count": 42}
with pytest.raises(ValueError, match="at least one tensor"):
SetCriterion._output_device(outputs)
def test_skips_non_tensor_values(self):
"""Non-tensor entries at the top level are skipped; first tensor wins."""
outputs = {"meta": "ignored", "pred_logits": torch.zeros(1, 1, 1)}
device = SetCriterion._output_device(outputs)
assert device == torch.device("cpu")
class TestNumBoxesForTargets:
"""Tests for SetCriterion.num_boxes_for_targets — clamp and empty-target edge cases."""
def test_returns_tensor_gte_one(self):
"""Result must be clamped to >= 1.0 to prevent division by zero."""
criterion = _bare_criterion()
outputs = {"pred_logits": torch.zeros(1, 1, 1)}
targets = [{"labels": torch.tensor([0, 1])}]
result = criterion.num_boxes_for_targets(outputs, targets)
assert result.item() >= 1.0
def test_clamps_zero_box_count_to_one(self):
"""Empty targets (no labels) must clamp to 1.0 to avoid zero denominator."""
criterion = _bare_criterion()
outputs = {"pred_logits": torch.zeros(1, 1, 1)}
targets = [{"labels": torch.zeros(0, dtype=torch.int64)}]
result = criterion.num_boxes_for_targets(outputs, targets)
assert result.item() == pytest.approx(1.0)
def test_clamps_empty_target_list(self):
"""Empty target list (batch_size=0 edge case) must also clamp to 1.0."""
criterion = _bare_criterion()
outputs = {"pred_logits": torch.zeros(1, 1, 1)}
targets = []
result = criterion.num_boxes_for_targets(outputs, targets)
assert result.item() == pytest.approx(1.0)
def test_counts_labels_correctly(self):
"""Box count equals total number of labels across all targets in the batch."""
criterion = _bare_criterion()
outputs = {"pred_logits": torch.zeros(1, 1, 1)}
targets = [
{"labels": torch.tensor([0, 1])},
{"labels": torch.tensor([0])},
]
result = criterion.num_boxes_for_targets(outputs, targets)
# 2 + 1 = 3 boxes; single-process so no all-reduce
assert result.item() == pytest.approx(3.0)
class TestLossMasksEmptyMatch:
"""Tests for the dict-path zero-GT branch of SetCriterion.loss_masks."""
def test_dict_path_zero_gt_stays_connected_to_graph(self):
"""Zero-match dict path returns a loss that back-propagates to every segmentation-head output."""
criterion = _bare_criterion()
spatial_features = torch.randn(1, 4, 8, 8, requires_grad=True)
query_features = torch.randn(1, 5, 4, requires_grad=True)
bias = torch.randn(1, requires_grad=True)
outputs = {
"pred_masks": {
"spatial_features": spatial_features,
"query_features": query_features,
"bias": bias,
}
}
empty = torch.empty(0, dtype=torch.long)
indices = [(empty, empty)]
losses = criterion.loss_masks(outputs, targets=[{}], indices=indices, num_boxes=1)
assert losses["loss_mask_ce"].requires_grad
(losses["loss_mask_ce"] + losses["loss_mask_dice"]).backward()
assert spatial_features.grad is not None
assert query_features.grad is not None
assert bias.grad is not None
+161
View File
@@ -0,0 +1,161 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Unit tests for keypoint losses in SetCriterion."""
import torch
from rfdetr.models.criterion import SetCriterion
class _MatcherStub:
"""Matcher stub used to avoid depending on Hungarian matching internals."""
def __call__(self, outputs, targets, group_detr=1):
indices = []
for target in targets:
num_targets = int(target["labels"].shape[0])
idx = torch.arange(num_targets, dtype=torch.int64)
indices.append((idx, idx))
return indices
def _make_outputs(
batch_size: int,
num_queries: int,
num_keypoints: int,
) -> dict[str, torch.Tensor]:
return {
"pred_logits": torch.zeros(batch_size, num_queries, 2),
"pred_boxes": torch.rand(batch_size, num_queries, 4).clamp(0.05, 0.95),
"pred_keypoints": torch.randn(batch_size, num_queries, num_keypoints, 8),
}
def test_loss_keypoints_list_of_dicts_targets() -> None:
"""Keypoint loss should consume list-of-dicts targets used by public training."""
criterion = SetCriterion(
num_classes=2,
matcher=_MatcherStub(),
weight_dict={},
focal_alpha=0.25,
losses=["keypoints"],
num_keypoints_per_class=[17],
)
outputs = _make_outputs(batch_size=1, num_queries=1, num_keypoints=17)
targets = [
{
"labels": torch.tensor([0], dtype=torch.int64),
"boxes": torch.tensor([[0.5, 0.5, 0.4, 0.4]], dtype=torch.float32),
"keypoints": torch.cat(
[
torch.rand(1, 17, 2),
torch.full((1, 17, 1), 2.0),
],
dim=-1,
),
}
]
losses = criterion(outputs, targets)
assert "loss_keypoints_l1" in losses
assert "loss_keypoints_findable" in losses
assert "loss_keypoints_visible" in losses
assert "loss_keypoints_nll" in losses
assert all(torch.isfinite(value) for value in losses.values())
def test_loss_keypoints_empty_targets() -> None:
"""Empty target batches should produce finite zero-valued keypoint losses."""
criterion = SetCriterion(
num_classes=2,
matcher=_MatcherStub(),
weight_dict={},
focal_alpha=0.25,
losses=["keypoints"],
num_keypoints_per_class=[17],
)
outputs = _make_outputs(batch_size=1, num_queries=1, num_keypoints=17)
targets = [
{
"labels": torch.zeros((0,), dtype=torch.int64),
"boxes": torch.zeros((0, 4), dtype=torch.float32),
"keypoints": torch.zeros((0, 17, 3), dtype=torch.float32),
}
]
losses = criterion(outputs, targets)
assert losses["loss_keypoints_l1"].item() == 0.0
assert losses["loss_keypoints_findable"].item() == 0.0
assert losses["loss_keypoints_visible"].item() == 0.0
assert losses["loss_keypoints_nll"].item() == 0.0
def test_loss_keypoints_person_schema_shape() -> None:
"""Person-only schema `[17]` should be consumed without shape mismatches."""
criterion = SetCriterion(
num_classes=2,
matcher=_MatcherStub(),
weight_dict={},
focal_alpha=0.25,
losses=["keypoints"],
num_keypoints_per_class=[17],
)
outputs = _make_outputs(batch_size=2, num_queries=2, num_keypoints=17)
targets = [
{
"labels": torch.tensor([0], dtype=torch.int64),
"boxes": torch.tensor([[0.5, 0.5, 0.4, 0.4]], dtype=torch.float32),
"keypoints": torch.rand(1, 17, 3),
},
{
"labels": torch.tensor([0], dtype=torch.int64),
"boxes": torch.tensor([[0.4, 0.6, 0.3, 0.5]], dtype=torch.float32),
"keypoints": torch.rand(1, 17, 3),
},
]
losses = criterion(outputs, targets)
assert losses["loss_keypoints_l1"].ndim == 0
assert losses["loss_keypoints_findable"].ndim == 0
assert losses["loss_keypoints_visible"].ndim == 0
assert losses["loss_keypoints_nll"].ndim == 0
def test_loss_keypoints_multiclass_schema_kmax_targets() -> None:
"""Heterogeneous keypoint classes should consume Kmax-padded targets."""
criterion = SetCriterion(
num_classes=3,
matcher=_MatcherStub(),
weight_dict={},
focal_alpha=0.25,
losses=["keypoints"],
num_keypoints_per_class=[2, 1],
)
outputs = _make_outputs(batch_size=1, num_queries=2, num_keypoints=4)
targets = [
{
"labels": torch.tensor([0, 1], dtype=torch.int64),
"boxes": torch.tensor([[0.5, 0.5, 0.4, 0.4], [0.4, 0.6, 0.3, 0.5]], dtype=torch.float32),
"keypoints": torch.tensor(
[
[[0.2, 0.3, 2.0], [0.4, 0.5, 2.0]],
[[0.6, 0.7, 2.0], [0.0, 0.0, 0.0]],
],
dtype=torch.float32,
),
}
]
losses = criterion(outputs, targets)
assert losses["loss_keypoints_l1"].ndim == 0
assert losses["loss_keypoints_findable"].ndim == 0
assert losses["loss_keypoints_visible"].ndim == 0
assert losses["loss_keypoints_nll"].ndim == 0
assert all(torch.isfinite(value) for value in losses.values())
@@ -0,0 +1,60 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Regression tests proving LWDETR forward contract survives joiner return-shape changes."""
from unittest.mock import MagicMock
import torch
from rfdetr.models.lwdetr import LWDETR
from rfdetr.utilities.tensors import NestedTensor
def test_lwdetr_default_detection_forward_after_backbone_change() -> None:
"""LWDETR should accept the updated 3-tuple backbone output in non-keypoint mode."""
batch_size = 2
num_queries = 3
hidden_dim = 4
num_classes = 7
features = [
NestedTensor(
torch.zeros(batch_size, hidden_dim, 4, 4),
torch.zeros(batch_size, 4, 4, dtype=torch.bool),
)
]
poss = [torch.zeros(batch_size, 4, 4, dtype=torch.bool)]
backbone = MagicMock()
backbone.return_value = (features, poss, None)
transformer = MagicMock()
transformer.d_model = hidden_dim
transformer_out = (
torch.zeros(1, batch_size, num_queries, hidden_dim),
torch.zeros(1, batch_size, num_queries, hidden_dim),
torch.zeros(batch_size, num_queries, hidden_dim),
torch.zeros(batch_size, num_queries, hidden_dim),
)
transformer.return_value = transformer_out
model = LWDETR(
backbone=backbone,
transformer=transformer,
segmentation_head=None,
num_classes=num_classes,
num_queries=num_queries,
aux_loss=False,
group_detr=1,
two_stage=False,
lite_refpoint_refine=False,
bbox_reparam=False,
)
outputs = model(torch.ones(batch_size, 3, 8, 8))
assert outputs["pred_logits"].shape == (batch_size, num_queries, num_classes)
assert outputs["pred_boxes"].shape == (batch_size, num_queries, 4)
+226
View File
@@ -0,0 +1,226 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Unit tests for GroupPose keypoint output wiring in LWDETR."""
from unittest.mock import MagicMock
import torch
from torch import nn
from rfdetr.models.heads import ConditionalQueryInitializer
from rfdetr.models.lwdetr import LWDETR
from rfdetr.utilities.tensors import NestedTensor
def _build_feature_batch(batch_size: int, hidden_dim: int) -> list[NestedTensor]:
return [
NestedTensor(
torch.zeros(batch_size, hidden_dim, 4, 4),
torch.zeros(batch_size, 4, 4, dtype=torch.bool),
)
]
class _DummyKeypointDecoder(nn.Module):
"""Minimal decoder surface needed for keypoint schema resizing."""
def __init__(self, hidden_dim: int, num_keypoints_per_class: list[int]) -> None:
super().__init__()
self.num_keypoints_per_class = num_keypoints_per_class
self.keypoint_pos_embed = nn.Parameter(torch.randn(sum(num_keypoints_per_class), hidden_dim))
self.register_buffer(
"keypoint_class_mask",
torch.zeros(1 + sum(num_keypoints_per_class), 1 + sum(num_keypoints_per_class), dtype=torch.bool),
)
class _DummyKeypointTransformer(nn.Module):
"""Minimal transformer surface needed for LWDETR construction and keypoint schema resizing."""
def __init__(self, hidden_dim: int, num_keypoints_per_class: list[int]) -> None:
super().__init__()
self.d_model = hidden_dim
self.num_keypoints_per_class = num_keypoints_per_class
self.decoder = _DummyKeypointDecoder(hidden_dim, num_keypoints_per_class)
self.keypoint_query_initializer = ConditionalQueryInitializer(hidden_dim, sum(num_keypoints_per_class))
self.keypoint_query_initializer_enc = ConditionalQueryInitializer(hidden_dim, sum(num_keypoints_per_class))
def test_lwdetr_keypoint_forward_outputs() -> None:
"""GroupPose mode should expose keypoint tensors in model outputs."""
batch_size = 2
num_queries = 3
hidden_dim = 8
num_classes = 6
features = _build_feature_batch(batch_size=batch_size, hidden_dim=hidden_dim)
poss = [torch.zeros(batch_size, hidden_dim, 4, 4)]
backbone = MagicMock()
backbone.return_value = (features, poss, None)
transformer = MagicMock()
transformer.d_model = hidden_dim
transformer.return_value = (
torch.zeros(2, batch_size, num_queries, hidden_dim), # hs
torch.zeros(2, batch_size, num_queries, 4), # ref_unsigmoid
torch.zeros(batch_size, num_queries, hidden_dim), # hs_enc
torch.zeros(batch_size, num_queries, 4), # ref_enc
torch.zeros(2, batch_size, num_queries, 17, hidden_dim), # keypoint_hs
torch.zeros(batch_size, num_queries, 17, 8), # enc_kp_predictions
torch.zeros(batch_size, num_queries, 17, hidden_dim), # unused keypoint encoder hidden state
)
model = LWDETR(
backbone=backbone,
transformer=transformer,
segmentation_head=None,
num_classes=num_classes,
num_queries=num_queries,
aux_loss=True,
group_detr=1,
two_stage=False,
lite_refpoint_refine=False,
bbox_reparam=False,
use_grouppose_keypoints=True,
num_keypoints_per_class=[17],
grouppose_keypoint_dim_downscale=1,
)
outputs = model(torch.ones(batch_size, 3, 8, 8))
assert outputs["pred_logits"].shape == (batch_size, num_queries, num_classes)
assert outputs["pred_boxes"].shape == (batch_size, num_queries, 4)
assert outputs["pred_keypoints"].shape == (batch_size, num_queries, 17, 8)
assert "keypoint_hidden_states" not in outputs
assert "pred_keypoints" in outputs["aux_outputs"][0]
assert "keypoint_hidden_states" not in outputs["aux_outputs"][0]
def test_lwdetr_reinitialize_keypoint_head_updates_schema_dependent_state() -> None:
"""Keypoint schema reinit should resize masks and learned keypoint query embeddings."""
hidden_dim = 8
transformer = _DummyKeypointTransformer(hidden_dim=hidden_dim, num_keypoints_per_class=[17])
model = LWDETR(
backbone=MagicMock(),
transformer=transformer,
segmentation_head=None,
num_classes=3,
num_queries=2,
aux_loss=False,
group_detr=1,
two_stage=True,
lite_refpoint_refine=True,
bbox_reparam=False,
use_grouppose_keypoints=True,
num_keypoints_per_class=[17],
grouppose_keypoint_dim_downscale=1,
)
model.reinitialize_keypoint_head([2, 1])
assert model.num_keypoints_per_class == [2, 1]
assert model.get_num_keypoints_per_class() == [2, 1]
assert model._kp_active_mask.shape == (2, 2)
assert model._kp_active_mask.tolist() == [[True, True], [True, False]]
assert transformer.num_keypoints_per_class == [2, 1]
assert transformer.decoder.num_keypoints_per_class == [2, 1]
assert transformer.decoder.keypoint_pos_embed.shape == (3, hidden_dim)
assert transformer.decoder.keypoint_class_mask.shape == (4, 4)
assert transformer.keypoint_query_initializer.queries.shape == (3, hidden_dim)
assert transformer.keypoint_query_initializer_enc.queries.shape == (3, hidden_dim)
def test_lwdetr_reset_keypoint_gaussian_parameters_preserves_non_gaussian_rows() -> None:
"""Gaussian reset should only zero precision-Cholesky output rows on decoder and encoder keypoint heads."""
hidden_dim = 8
transformer = _DummyKeypointTransformer(hidden_dim=hidden_dim, num_keypoints_per_class=[17])
model = LWDETR(
backbone=MagicMock(),
transformer=transformer,
segmentation_head=None,
num_classes=3,
num_queries=2,
aux_loss=False,
group_detr=1,
two_stage=True,
lite_refpoint_refine=True,
bbox_reparam=False,
use_grouppose_keypoints=True,
num_keypoints_per_class=[17],
grouppose_keypoint_dim_downscale=1,
)
with torch.no_grad():
model.keypoint_embed.layers[-1].weight.fill_(3.0)
model.keypoint_embed.layers[-1].bias.fill_(4.0)
model.transformer.enc_out_keypoint_embed[0].layers[-1].weight.fill_(5.0)
model.transformer.enc_out_keypoint_embed[0].layers[-1].bias.fill_(6.0)
model.reset_keypoint_gaussian_parameters()
torch.testing.assert_close(model.keypoint_embed.layers[-1].weight[:4], torch.full((4, hidden_dim), 3.0))
torch.testing.assert_close(model.keypoint_embed.layers[-1].weight[4:7], torch.zeros(3, hidden_dim))
torch.testing.assert_close(model.keypoint_embed.layers[-1].weight[7:], torch.full((1, hidden_dim), 3.0))
torch.testing.assert_close(model.keypoint_embed.layers[-1].bias[:4], torch.full((4,), 4.0))
torch.testing.assert_close(model.keypoint_embed.layers[-1].bias[4:7], torch.zeros(3))
torch.testing.assert_close(model.keypoint_embed.layers[-1].bias[7:], torch.full((1,), 4.0))
torch.testing.assert_close(
model.transformer.enc_out_keypoint_embed[0].layers[-1].weight[4:7], torch.zeros(3, hidden_dim)
)
torch.testing.assert_close(model.transformer.enc_out_keypoint_embed[0].layers[-1].bias[4:7], torch.zeros(3))
def test_lwdetr_get_num_keypoints_per_class_from_checkpoint() -> None:
"""Checkpoint keypoint schema should be recoverable from `_kp_active_mask`."""
state_dict = {"_kp_active_mask": torch.tensor([[True, True], [True, False]])}
assert LWDETR.get_num_keypoints_per_class_from_checkpoint(state_dict) == [2, 1]
def test_lwdetr_default_detection_contract_unchanged() -> None:
"""Default detection mode should not expose keypoint outputs."""
batch_size = 2
num_queries = 3
hidden_dim = 8
num_classes = 6
features = _build_feature_batch(batch_size=batch_size, hidden_dim=hidden_dim)
poss = [torch.zeros(batch_size, hidden_dim, 4, 4)]
backbone = MagicMock()
backbone.return_value = (features, poss, None)
transformer = MagicMock()
transformer.d_model = hidden_dim
transformer.return_value = (
torch.zeros(1, batch_size, num_queries, hidden_dim),
torch.zeros(1, batch_size, num_queries, 4),
torch.zeros(batch_size, num_queries, hidden_dim),
torch.zeros(batch_size, num_queries, 4),
)
model = LWDETR(
backbone=backbone,
transformer=transformer,
segmentation_head=None,
num_classes=num_classes,
num_queries=num_queries,
aux_loss=False,
group_detr=1,
two_stage=False,
lite_refpoint_refine=False,
bbox_reparam=False,
use_grouppose_keypoints=False,
num_keypoints_per_class=[],
grouppose_keypoint_dim_downscale=1,
)
outputs = model(torch.ones(batch_size, 3, 8, 8))
assert outputs["pred_logits"].shape == (batch_size, num_queries, num_classes)
assert outputs["pred_boxes"].shape == (batch_size, num_queries, 4)
assert "pred_keypoints" not in outputs
assert "keypoint_hidden_states" not in outputs
+401
View File
@@ -0,0 +1,401 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
import pytest
import torch
from rfdetr.models import matcher as matcher_module
from rfdetr.models.matcher import HungarianMatcher
@pytest.fixture()
def matcher() -> HungarianMatcher:
"""Shared HungarianMatcher instance."""
return HungarianMatcher()
@pytest.fixture()
def standard_target() -> dict[str, torch.Tensor]:
"""Single-class target with one box at (0.5, 0.5, 0.2, 0.2)."""
return {
"labels": torch.tensor([0], dtype=torch.int64),
"boxes": torch.tensor([[0.5, 0.5, 0.2, 0.2]], dtype=torch.float32),
}
class TestHungarianMatcherNonFiniteCosts:
"""Tests for non-finite cost matrix sanitization in the Hungarian matcher."""
@pytest.mark.parametrize(
"invalid_value",
[
pytest.param(float("nan"), id="nan"),
pytest.param(float("inf"), id="inf"),
pytest.param(float("-inf"), id="-inf"),
],
)
def test_replaces_non_finite_costs_before_assignment(
self,
matcher: HungarianMatcher,
standard_target: dict[str, torch.Tensor],
invalid_value: float,
) -> None:
"""Matcher should sanitize non-finite costs so assignment still succeeds."""
outputs = {
"pred_logits": torch.tensor([[[0.0], [10.0]]], dtype=torch.float32),
"pred_boxes": torch.tensor(
[
[
[invalid_value, 0.5, 0.2, 0.2],
[0.5, 0.5, 0.2, 0.2],
]
],
dtype=torch.float32,
),
}
matched_queries, matched_targets = matcher(outputs, [standard_target])[0]
assert matched_queries.tolist() == [1]
assert matched_targets.tolist() == [0]
def test_all_nonfinite_produces_valid_assignment(
self,
matcher: HungarianMatcher,
standard_target: dict[str, torch.Tensor],
) -> None:
"""When ALL costs are non-finite, the fallback sentinel (``dtype_info.max``)
should allow ``linear_sum_assignment`` to complete with a valid 1-to-1
assignment: exactly one match, query index in [0, num_queries), target index 0.
This exercises the ``else: replacement_cost = C.new_tensor(dtype_info.max)`` branch.
"""
nan = float("nan")
outputs = {
"pred_logits": torch.tensor([[[nan], [nan]]], dtype=torch.float32),
"pred_boxes": torch.tensor(
[
[
[nan, nan, nan, nan],
[nan, nan, nan, nan],
]
],
dtype=torch.float32,
),
}
matched_queries, matched_targets = matcher(outputs, [standard_target])[0]
assert len(matched_queries) == len(matched_targets) == 1
assert 0 <= matched_queries.item() < 2
assert matched_targets.item() == 0
def test_negative_costs_with_nan_selects_valid_query(
self,
matcher: HungarianMatcher,
standard_target: dict[str, torch.Tensor],
) -> None:
"""Regression test: when all finite costs are negative and one query produces NaN, the matcher must select the
valid query, not the NaN one.
This guards against the bug where ``max_cost * 2`` (the old replacement formula) could be smaller than
``max_cost`` when all costs are negative, causing the NaN query to appear cheaper than valid queries.
"""
nan = float("nan")
# Query 0: NaN box coordinates -> produces non-finite costs
# Query 1: valid box, low logit -> all-negative but finite costs
outputs = {
"pred_logits": torch.tensor([[[0.0], [-10.0]]], dtype=torch.float32),
"pred_boxes": torch.tensor(
[
[
[nan, nan, nan, nan],
[0.5, 0.5, 0.2, 0.2],
]
],
dtype=torch.float32,
),
}
matched_queries, matched_targets = matcher(outputs, [standard_target])[0]
# The valid query (index 1) must be matched, not the NaN query.
assert matched_queries.tolist() == [1]
assert matched_targets.tolist() == [0]
@pytest.mark.parametrize(
"image_idx, expected_query_idx",
[
pytest.param(0, 1, id="image0"),
pytest.param(1, 0, id="image1"),
],
)
def test_batch_size_greater_than_one(
self,
matcher: HungarianMatcher,
image_idx: int,
expected_query_idx: int,
) -> None:
"""Exercises the ``C.split(sizes, -1)`` loop with batch_size > 1.
Each image has 2 queries and 1 target. One query per image has NaN coordinates; the matcher must select the
valid query in each case.
"""
nan = float("nan")
outputs = {
"pred_logits": torch.tensor(
[
[[0.0], [10.0]], # image 0: query 1 is valid
[[10.0], [0.0]], # image 1: query 0 is valid
],
dtype=torch.float32,
),
"pred_boxes": torch.tensor(
[
[
[nan, 0.5, 0.2, 0.2], # image 0, query 0: NaN
[0.5, 0.5, 0.2, 0.2], # image 0, query 1: valid
],
[
[0.5, 0.5, 0.2, 0.2], # image 1, query 0: valid
[nan, 0.5, 0.2, 0.2], # image 1, query 1: NaN
],
],
dtype=torch.float32,
),
}
targets = [
{
"labels": torch.tensor([0], dtype=torch.int64),
"boxes": torch.tensor([[0.5, 0.5, 0.2, 0.2]], dtype=torch.float32),
},
{
"labels": torch.tensor([0], dtype=torch.int64),
"boxes": torch.tensor([[0.5, 0.5, 0.2, 0.2]], dtype=torch.float32),
},
]
results = matcher(outputs, targets)
assert len(results) == 2
matched_queries, matched_targets = results[image_idx]
assert matched_queries.tolist() == [expected_query_idx]
assert matched_targets.tolist() == [0]
def test_group_detr_with_nonfinite_costs(
self,
matcher: HungarianMatcher,
standard_target: dict[str, torch.Tensor],
) -> None:
"""Sanitization runs on the full cost matrix before splitting by group, so non-finite entries must be handled
correctly when ``group_detr > 1``.
4 queries, 2 groups of 2. Query 0 has a NaN box; query 2 (the best valid match in group 1) must be selected
across groups.
"""
nan = float("nan")
outputs = {
"pred_logits": torch.tensor(
[[[0.0], [10.0], [0.0], [10.0]]],
dtype=torch.float32,
),
"pred_boxes": torch.tensor(
[
[
[nan, nan, nan, nan], # group 0, query 0: NaN
[0.5, 0.5, 0.2, 0.2], # group 0, query 1: valid
[nan, nan, nan, nan], # group 1, query 0: NaN
[0.5, 0.5, 0.2, 0.2], # group 1, query 1: valid
]
],
dtype=torch.float32,
),
}
results = matcher(outputs, [standard_target], group_detr=2)
assert len(results) == 1
matched_queries, matched_targets = results[0]
# Each group contributes one match; both must map to target 0
assert matched_targets.tolist() == [0, 0]
# The valid query in each group (indices 1 and 3) must be selected
assert set(matched_queries.tolist()) == {1, 3}
def test_warns_once_per_matcher_instance(
self, standard_target: dict[str, torch.Tensor], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Non-finite-cost warning should be emitted once per matcher instance."""
expected_warning = (
"Non-finite values detected in matcher cost matrix; "
"replacing with finite sentinel. "
"Check for numerical instability."
)
warning_messages: list[str] = []
def record_warning(msg: str, *args: object, **kwargs: object) -> None:
warning_messages.append(msg)
monkeypatch.setattr(matcher_module.logger, "warning", record_warning)
outputs = {
"pred_logits": torch.tensor([[[0.0], [10.0]]], dtype=torch.float32),
"pred_boxes": torch.tensor(
[
[
[float("nan"), 0.5, 0.2, 0.2],
[0.5, 0.5, 0.2, 0.2],
]
],
dtype=torch.float32,
),
}
first_matcher = HungarianMatcher()
second_matcher = HungarianMatcher()
first_matcher(outputs, [standard_target])
first_matcher(outputs, [standard_target])
second_matcher(outputs, [standard_target])
assert warning_messages == [expected_warning, expected_warning]
class TestHungarianMatcherSanitization:
"""Unit tests for the private matcher cost sanitization helper."""
def test_sanitize_cost_matrix_replaces_non_finite_entries(self) -> None:
"""Non-finite entries should be replaced with a larger finite sentinel."""
cost_matrix = torch.tensor(
[
[1.0, float("nan")],
[float("inf"), -2.0],
],
dtype=torch.float32,
)
sanitized = HungarianMatcher._sanitize_cost_matrix(cost_matrix)
assert torch.isfinite(sanitized).all()
assert sanitized[0, 1] == 4.0
assert sanitized[1, 0] == 4.0
assert sanitized[0, 0] == 1.0
assert sanitized[1, 1] == -2.0
def test_sanitize_cost_matrix_all_non_finite_fallback(self) -> None:
"""All-non-finite matrices should fall back to the dtype maximum."""
cost_matrix = torch.tensor(
[
[float("nan"), float("inf")],
[float("-inf"), float("nan")],
],
dtype=torch.float32,
)
sanitized = HungarianMatcher._sanitize_cost_matrix(cost_matrix)
assert torch.isfinite(sanitized).all()
assert torch.all(sanitized == torch.finfo(cost_matrix.dtype).max)
def test_sanitize_cost_matrix_clamps_overflowing_replacement_cost(self) -> None:
"""Overflow in the computed replacement cost should clamp to dtype max."""
dtype_max = torch.finfo(torch.float32).max
cost_matrix = torch.tensor(
[
[dtype_max, float("nan")],
[0.0, 1.0],
],
dtype=torch.float32,
)
sanitized = HungarianMatcher._sanitize_cost_matrix(cost_matrix)
assert torch.isfinite(sanitized).all()
assert sanitized[0, 1] == dtype_max
class TestHungarianMatcherFocalAlpha:
"""The configured ``focal_alpha`` must drive the classification matching cost."""
def test_focal_alpha_changes_assignment(self) -> None:
"""Two matchers differing only in ``focal_alpha`` must be able to produce different assignments.
``focal_alpha`` is accepted, documented as "used in the classification cost", and stored on the matcher, so it
must actually influence matching. This input is chosen so the optimal query->target pairing flips between
``focal_alpha=0.25`` and ``focal_alpha=0.90``; if the cost ignores the configured alpha, both assignments
collapse to the same result.
"""
outputs = {
"pred_logits": torch.tensor(
[[[2.3936, -1.4217], [2.3731, -2.1974]]],
dtype=torch.float32,
),
"pred_boxes": torch.tensor(
[[[0.3898, 0.4340, 0.5331, 0.1901], [0.4256, 0.1002, 0.6955, 0.7815]]],
dtype=torch.float32,
),
}
targets = [
{
"labels": torch.tensor([0, 1], dtype=torch.int64),
"boxes": torch.tensor(
[[0.2111, 0.6630, 0.7569, 0.8855], [0.7750, 0.4393, 0.8838, 0.8792]],
dtype=torch.float32,
),
}
]
def assignment(focal_alpha: float) -> list[int]:
matcher = HungarianMatcher(cost_class=2.0, cost_bbox=5.0, cost_giou=2.0, focal_alpha=focal_alpha)
matched_queries, matched_targets = matcher(outputs, targets)[0]
# Queries ordered by the target index they are matched to.
return matched_queries[matched_targets.argsort()].tolist()
assert assignment(0.25) != assignment(0.90)
# Pin the exact expected mappings so a misapplied-alpha refactor is caught even when
# the two values remain different for unrelated reasons.
assert assignment(0.25) == [0, 1]
assert assignment(0.90) == [1, 0]
@pytest.mark.parametrize(
"focal_alpha, expected",
[
pytest.param(0.0, [0, 1], id="alpha_zero_pos_cost_zeroed"),
pytest.param(1.0, [1, 0], id="alpha_one_neg_cost_zeroed"),
],
)
def test_focal_alpha_boundary_values_no_nan(self, focal_alpha: float, expected: list[int]) -> None:
"""Degenerate focal_alpha values (0.0 and 1.0) must not produce NaN and must yield a valid assignment.
focal_alpha=0.0 zeroes ``pos_cost_class``; focal_alpha=1.0 zeroes ``neg_cost_class``. Neither path touches
``log(prob)`` directly (formula uses logsigmoid of logits), so no division-by-zero or NaN can occur.
"""
outputs = {
"pred_logits": torch.tensor(
[[[2.3936, -1.4217], [2.3731, -2.1974]]],
dtype=torch.float32,
),
"pred_boxes": torch.tensor(
[[[0.3898, 0.4340, 0.5331, 0.1901], [0.4256, 0.1002, 0.6955, 0.7815]]],
dtype=torch.float32,
),
}
targets = [
{
"labels": torch.tensor([0, 1], dtype=torch.int64),
"boxes": torch.tensor(
[[0.2111, 0.6630, 0.7569, 0.8855], [0.7750, 0.4393, 0.8838, 0.8792]],
dtype=torch.float32,
),
}
]
matcher = HungarianMatcher(cost_class=2.0, cost_bbox=5.0, cost_giou=2.0, focal_alpha=focal_alpha)
matched_queries, matched_targets = matcher(outputs, targets)[0]
assert not matcher._warned_non_finite_costs, "boundary focal_alpha produced non-finite costs"
result = matched_queries[matched_targets.argsort()].tolist()
assert result == expected
+112
View File
@@ -0,0 +1,112 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for keypoint matching costs in HungarianMatcher."""
import torch
from rfdetr.models.matcher import HungarianMatcher
def _base_outputs(num_queries: int = 2) -> dict[str, torch.Tensor]:
"""Build minimal detection outputs used across matcher keypoint tests."""
pred_logits = torch.full((1, num_queries, 1), 5.0, dtype=torch.float32)
pred_boxes = torch.tensor([0.5, 0.5, 0.2, 0.2], dtype=torch.float32).view(1, 1, 4).repeat(1, num_queries, 1)
return {
"pred_logits": pred_logits,
"pred_boxes": pred_boxes,
}
def test_matcher_keypoint_cost_list_of_dicts_targets() -> None:
"""Keypoint matching costs should work with public list-of-dicts targets."""
matcher = HungarianMatcher(
cost_class=0.0,
cost_bbox=1.0,
cost_giou=0.0,
num_keypoints_per_class=[1],
keypoint_l1_loss_coef=10.0,
keypoint_findable_loss_coef=0.0,
keypoint_visible_loss_coef=0.0,
keypoint_nll_loss_coef=0.0,
)
outputs = _base_outputs()
outputs["pred_keypoints"] = torch.zeros((1, 2, 1, 8), dtype=torch.float32)
outputs["pred_keypoints"][0, 0, 0, :2] = torch.tensor([0.5, 0.5], dtype=torch.float32)
outputs["pred_keypoints"][0, 1, 0, :2] = torch.tensor([0.0, 0.0], dtype=torch.float32)
targets = [
{
"labels": torch.tensor([0], dtype=torch.int64),
"boxes": torch.tensor([[0.5, 0.5, 0.2, 0.2]], dtype=torch.float32),
"keypoints": torch.tensor([[[0.5, 0.5, 2.0]]], dtype=torch.float32),
}
]
matched_queries, matched_targets = matcher(outputs, targets)[0]
assert matched_queries.tolist() == [0]
assert matched_targets.tolist() == [0]
def test_matcher_keypoint_cost_coefficients_off() -> None:
"""Zero keypoint coefficients should preserve non-keypoint matching behavior."""
base_matcher = HungarianMatcher(cost_class=1.0, cost_bbox=1.0, cost_giou=1.0)
keypoint_matcher = HungarianMatcher(
cost_class=1.0,
cost_bbox=1.0,
cost_giou=1.0,
num_keypoints_per_class=[1],
keypoint_l1_loss_coef=0.0,
keypoint_findable_loss_coef=0.0,
keypoint_visible_loss_coef=0.0,
keypoint_nll_loss_coef=0.0,
)
outputs = _base_outputs()
outputs["pred_logits"][0, 0, 0] = 10.0
outputs["pred_logits"][0, 1, 0] = -10.0
outputs["pred_boxes"][0, 1, :] = torch.tensor([0.1, 0.1, 0.1, 0.1], dtype=torch.float32)
targets = [
{
"labels": torch.tensor([0], dtype=torch.int64),
"boxes": torch.tensor([[0.5, 0.5, 0.2, 0.2]], dtype=torch.float32),
"keypoints": torch.tensor([[[0.0, 0.0, 2.0]]], dtype=torch.float32),
}
]
outputs_with_keypoints = dict(outputs)
outputs_with_keypoints["pred_keypoints"] = torch.zeros((1, 2, 1, 8), dtype=torch.float32)
base_indices = base_matcher(outputs, targets)[0]
keypoint_indices = keypoint_matcher(outputs_with_keypoints, targets)[0]
assert base_indices[0].tolist() == keypoint_indices[0].tolist()
assert base_indices[1].tolist() == keypoint_indices[1].tolist()
def test_matcher_keypoint_empty_targets() -> None:
"""Empty keypoint targets should return valid empty match results."""
matcher = HungarianMatcher(
cost_class=1.0,
cost_bbox=1.0,
cost_giou=1.0,
num_keypoints_per_class=[1],
keypoint_l1_loss_coef=1.0,
keypoint_findable_loss_coef=1.0,
keypoint_visible_loss_coef=1.0,
keypoint_nll_loss_coef=1.0,
)
outputs = _base_outputs(num_queries=3)
outputs["pred_keypoints"] = torch.zeros((1, 3, 1, 8), dtype=torch.float32)
targets = [
{
"labels": torch.zeros((0,), dtype=torch.int64),
"boxes": torch.zeros((0, 4), dtype=torch.float32),
"keypoints": torch.zeros((0, 1, 3), dtype=torch.float32),
}
]
matched_queries, matched_targets = matcher(outputs, targets)[0]
assert matched_queries.numel() == 0
assert matched_targets.numel() == 0
+102
View File
@@ -0,0 +1,102 @@
# ------------------------------------------------------------------------
# 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.models.math utility functions."""
from __future__ import annotations
import pytest
import torch
from rfdetr.models.math import accuracy, interpolate, inverse_sigmoid
class TestInterpolate:
"""Verify interpolate() delegates to F.interpolate across torchvision versions."""
def test_resizes_to_target_size(self) -> None:
"""Interpolate() upsamples a 4-D tensor to the requested spatial size."""
x = torch.randn(2, 3, 4, 4)
out = interpolate(x, size=[8, 8], mode="bilinear", align_corners=False)
assert out.shape == (2, 3, 8, 8)
def test_handles_empty_batch(self) -> None:
"""Interpolate() supports an empty batch dimension without error."""
x = torch.randn(0, 3, 4, 4)
out = interpolate(x, size=[8, 8], mode="nearest")
assert out.shape == (0, 3, 8, 8)
class TestAccuracy:
"""Verify accuracy() computes precision@k correctly."""
def test_top1_perfect_batch(self) -> None:
"""All predictions correct returns top-1 accuracy of 100.0."""
output = torch.tensor([[0.0, 10.0], [10.0, 0.0], [0.0, 10.0]])
target = torch.tensor([1, 0, 1])
result = accuracy(output, target, topk=(1,))
assert len(result) == 1
assert result[0].item() == pytest.approx(100.0)
def test_top1_zero_accuracy(self) -> None:
"""All predictions wrong returns top-1 accuracy of 0.0."""
output = torch.tensor([[10.0, 0.0], [0.0, 10.0]])
target = torch.tensor([1, 0])
result = accuracy(output, target, topk=(1,))
assert result[0].item() == pytest.approx(0.0)
def test_topk_returns_list_of_correct_length(self) -> None:
"""Topk=(1, 5) returns a list of length 2."""
output = torch.randn(10, 10)
target = torch.zeros(10, dtype=torch.long)
result = accuracy(output, target, topk=(1, 5))
assert len(result) == 2
def test_empty_target_returns_single_zero_regardless_of_topk(self) -> None:
"""Empty target returns list of length 1 with value 0 regardless of topk length."""
output = torch.zeros(0, 5)
target = torch.zeros(0, dtype=torch.long)
result = accuracy(output, target, topk=(1, 5))
assert len(result) == 1
assert result[0].item() == pytest.approx(0.0)
class TestInverseSigmoid:
"""Verify inverse_sigmoid() computes the logit function correctly."""
def test_identity_at_half(self) -> None:
"""inverse_sigmoid(0.5) equals 0.0 since sigmoid(0.0) = 0.5."""
x = torch.tensor([0.5])
result = inverse_sigmoid(x)
assert result.item() == pytest.approx(0.0, abs=1e-5)
def test_clamping_at_zero_is_finite(self) -> None:
"""inverse_sigmoid(0.0) is finite due to eps clamping."""
x = torch.tensor([0.0])
result = inverse_sigmoid(x)
assert torch.isfinite(result).all()
def test_clamping_at_one_is_finite(self) -> None:
"""inverse_sigmoid(1.0) is finite due to eps clamping."""
x = torch.tensor([1.0])
result = inverse_sigmoid(x)
assert torch.isfinite(result).all()
def test_output_shape_matches_input(self) -> None:
"""Output shape matches input shape for a multi-dimensional tensor."""
x = torch.rand(3, 4)
result = inverse_sigmoid(x)
assert result.shape == x.shape
def test_gradient_flows_for_non_saturated_input(self) -> None:
"""Gradients are non-zero for a non-saturated input value."""
x = torch.tensor([0.3], requires_grad=True)
inverse_sigmoid(x).sum().backward()
assert x.grad is not None
assert x.grad.abs().item() > 0.0
+54
View File
@@ -0,0 +1,54 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
import pytest
import torch
from rfdetr import RFDETRBase, RFDETRLarge
def _get_patch_embed_projection(model) -> torch.nn.Conv2d:
"""Return the patch-embedding projection layer for an RF-DETR model.
RFDETR wrappers are not nn.Module; the underlying PyTorch module lives at ``model.model.model``. Walk
named_modules() on that object.
Args:
model: Instantiated RF-DETR wrapper (RFDETRBase / RFDETRLarge).
Returns:
The convolution used to project image channels into patch embeddings.
Raises:
AssertionError: If the patch-embedding projection cannot be located.
"""
# model.model → model context; model.model.model → nn.Module
nn_model = model.model.model
proj = nn_model.backbone[0].encoder.encoder.embeddings.patch_embeddings.projection
if isinstance(proj, torch.nn.Conv2d):
return proj
# Fallback: scan named_modules on the underlying nn.Module
for name, module in nn_model.named_modules():
if "patch_embeddings" in name and "projection" in name and isinstance(module, torch.nn.Conv2d):
return module
msg = "Could not find patch embedding projection on model"
raise AssertionError(msg)
@pytest.mark.parametrize("model_class", [RFDETRBase, RFDETRLarge])
@pytest.mark.parametrize("channels", [1, 4])
def test_multispectral_support(model_class, channels: int) -> None:
model = model_class(
num_channels=channels,
device="cpu",
pretrain_weights=None,
)
patch_embed_projection = _get_patch_embed_projection(model)
assert patch_embed_projection.in_channels == channels
+189
View File
@@ -0,0 +1,189 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Regression tests for _namespace_from_configs() config forwarding."""
import sys
from typing import Any
import pytest
from rfdetr._namespace import _namespace_from_configs
from rfdetr.config import RFDETRBaseConfig, RFDETRSegNanoConfig, SegmentationTrainConfig, TrainConfig
from rfdetr.models._types import BuilderArgs
class TestNamespaceForwarding:
"""Verify that _namespace_from_configs() forwards TrainConfig fields that were previously hardcoded to wrong
defaults."""
def _make_ns(self: "TestNamespaceForwarding", **tc_kwargs: Any) -> Any:
"""Build a namespace for tests with minimal default TrainConfig values."""
mc = RFDETRBaseConfig(num_classes=80)
tc_kwargs.setdefault("dataset_dir", "/tmp")
tc = TrainConfig(**tc_kwargs)
return _namespace_from_configs(mc, tc)
def test_aug_config_forwarded_when_set(self: "TestNamespaceForwarding") -> None:
aug = {"hsv_h": 0.015, "hsv_s": 0.7}
ns = self._make_ns(aug_config=aug)
assert ns.aug_config == aug
def test_aug_config_none_by_default(self: "TestNamespaceForwarding") -> None:
ns = self._make_ns()
assert ns.aug_config is None
def test_use_ema_forwarded_true(self: "TestNamespaceForwarding") -> None:
ns = self._make_ns(use_ema=True)
assert ns.use_ema is True
def test_use_ema_forwarded_false(self: "TestNamespaceForwarding") -> None:
ns = self._make_ns(use_ema=False)
assert ns.use_ema is False
def test_early_stopping_use_ema_forwarded_true(self: "TestNamespaceForwarding") -> None:
ns = self._make_ns(early_stopping_use_ema=True)
assert ns.early_stopping_use_ema is True
def test_early_stopping_use_ema_forwarded_false(self: "TestNamespaceForwarding") -> None:
ns = self._make_ns(early_stopping_use_ema=False)
assert ns.early_stopping_use_ema is False
class TestNamespaceProtocol:
"""_namespace_from_configs() output must satisfy the BuilderArgs Protocol."""
def _make_ns(self, mc=None, tc=None):
mc = mc or RFDETRBaseConfig(num_classes=80)
tc = tc or TrainConfig(dataset_dir="/tmp")
return _namespace_from_configs(mc, tc)
@pytest.mark.skipif(
sys.version_info < (3, 12),
reason="Runtime Protocol attribute checks require Python 3.12+",
)
def test_namespace_satisfies_builderargs_protocol_py312(self) -> None:
"""On Python 3.12+, isinstance() verifies data-attribute presence."""
ns = self._make_ns()
assert isinstance(ns, BuilderArgs)
def test_namespace_is_builderargs_instance(self) -> None:
"""Isinstance() check passes on all supported Python versions.
On Python 3.10/3.11 this is a structural no-op (no method members to check). On 3.12+ it verifies attribute
presence. The test documents the intent regardless of Python version.
"""
ns = self._make_ns()
assert isinstance(ns, BuilderArgs)
class TestNamespaceFieldOwnership:
"""Verify that the namespace reads each field from the authoritative owner."""
def _make_ns(self, mc=None, tc=None):
mc = mc or RFDETRBaseConfig(num_classes=80)
tc = tc or TrainConfig(dataset_dir="/tmp")
return _namespace_from_configs(mc, tc)
# --- cls_loss_coef must come from TrainConfig ---
def test_cls_loss_coef_from_train_config(self) -> None:
"""ns.cls_loss_coef must reflect TrainConfig.cls_loss_coef, not ModelConfig."""
mc = RFDETRBaseConfig(num_classes=80) # cls_loss_coef=1.0 (ModelConfig default)
tc = TrainConfig(dataset_dir="/tmp", cls_loss_coef=2.5)
ns = _namespace_from_configs(mc, tc)
assert ns.cls_loss_coef == pytest.approx(2.5)
def test_cls_loss_coef_segmentation_default_matches_pre_1_7_effective_value(self) -> None:
"""SegmentationTrainConfig default must preserve the pre-1.7 effective loss_ce weight."""
mc = RFDETRSegNanoConfig()
tc = SegmentationTrainConfig(dataset_dir="/tmp")
ns = _namespace_from_configs(mc, tc)
assert ns.cls_loss_coef == pytest.approx(1.0)
def test_cls_loss_coef_segmentation_explicit_train_config_value_wins(self) -> None:
"""Explicit SegmentationTrainConfig.cls_loss_coef values must propagate to namespace."""
mc = RFDETRSegNanoConfig()
tc = SegmentationTrainConfig(dataset_dir="/tmp", cls_loss_coef=5.0)
ns = _namespace_from_configs(mc, tc)
assert ns.cls_loss_coef == pytest.approx(5.0)
def test_cls_loss_coef_train_config_wins_over_explicit_model_config(self) -> None:
"""When both are explicitly set, TrainConfig.cls_loss_coef takes precedence."""
with pytest.warns(DeprecationWarning, match="ModelConfig\\.cls_loss_coef is deprecated"):
mc = RFDETRBaseConfig(num_classes=80, cls_loss_coef=0.5)
tc = TrainConfig(dataset_dir="/tmp", cls_loss_coef=3.0)
ns = _namespace_from_configs(mc, tc)
assert ns.cls_loss_coef == pytest.approx(3.0)
def test_cls_loss_coef_model_config_explicit_is_preserved_during_deprecation(self) -> None:
"""Explicit ModelConfig.cls_loss_coef remains effective until removal."""
with pytest.warns(DeprecationWarning, match="ModelConfig\\.cls_loss_coef is deprecated"):
mc = RFDETRBaseConfig(num_classes=80, cls_loss_coef=2.5)
tc = TrainConfig(dataset_dir="/tmp")
ns = _namespace_from_configs(mc, tc)
assert ns.cls_loss_coef == pytest.approx(2.5)
# --- num_select must come from ModelConfig unconditionally ---
def test_num_select_from_model_config(self) -> None:
"""ns.num_select must equal mc.num_select regardless of tc.num_select."""
mc = RFDETRSegNanoConfig() # num_select=100
tc = TrainConfig(dataset_dir="/tmp") # num_select=300 (default — was the bug)
ns = _namespace_from_configs(mc, tc)
assert ns.num_select == 100
@pytest.mark.parametrize(
"config_class, expected_num_select",
[
pytest.param(RFDETRSegNanoConfig, 100, id="seg_nano"),
pytest.param(RFDETRBaseConfig, 300, id="base"),
],
)
def test_num_select_matches_model_config_variant(self, config_class, expected_num_select) -> None:
"""ns.num_select must equal the model config's num_select for each variant."""
mc = config_class()
tc = TrainConfig(dataset_dir="/tmp")
ns = _namespace_from_configs(mc, tc)
assert ns.num_select == expected_num_select
class TestBuildNamespaceDeprecated:
"""build_namespace() is a deprecated shim — verify the warning fires."""
def test_emits_deprecation_warning(self, reset_build_namespace_warning_state) -> None:
"""Every call to build_namespace() must emit a DeprecationWarning."""
from rfdetr._namespace import build_namespace
mc = RFDETRBaseConfig(num_classes=80)
tc = TrainConfig(dataset_dir="/tmp")
with pytest.warns(FutureWarning, match="build_namespace"):
build_namespace(mc, tc)
def test_result_identical_to_namespace_from_configs(self, reset_build_namespace_warning_state) -> None:
"""build_namespace output must equal _namespace_from_configs output."""
from rfdetr._namespace import build_namespace
from rfdetr.models._defaults import MODEL_DEFAULTS
mc = RFDETRBaseConfig(num_classes=80)
tc = TrainConfig(dataset_dir="/tmp")
with pytest.warns(FutureWarning):
ns_legacy = build_namespace(mc, tc)
ns_new = _namespace_from_configs(mc, tc, MODEL_DEFAULTS)
legacy_attrs = vars(ns_legacy)
new_attrs = vars(ns_new)
assert set(legacy_attrs.keys()) == set(new_attrs.keys()), (
f"Key mismatch: "
f"legacy_only={set(legacy_attrs) - set(new_attrs)}, "
f"new_only={set(new_attrs) - set(legacy_attrs)}"
)
for key in sorted(legacy_attrs):
assert legacy_attrs[key] == new_attrs[key], (
f"Value mismatch for '{key}': legacy={legacy_attrs[key]!r}, new={new_attrs[key]!r}"
)
+88
View File
@@ -0,0 +1,88 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for rfdetr.models.position_encoding.build_position_encoding.
Covers:
- Supported aliases ``"sine"`` / ``"v2"`` return a ``PositionEmbeddingSine`` instance.
- Unsupported but previously accepted aliases ``"learned"`` / ``"v3"`` now raise
``ValueError`` with a message that names supported alternatives.
- Fully unsupported values raise ``ValueError`` with the same pattern.
"""
import pytest
from rfdetr.models.position_encoding import (
PositionEmbeddingSine,
build_position_encoding,
)
class TestBuildPositionEncodingSupportedValues:
"""build_position_encoding returns valid modules for supported aliases."""
@pytest.mark.parametrize(
"alias",
[
pytest.param("sine", id="sine"),
pytest.param("v2", id="v2"),
],
)
def test_returns_sine_embedding(self, alias: str) -> None:
"""Supported aliases produce a PositionEmbeddingSine with normalized=True."""
enc = build_position_encoding(hidden_dim=256, position_embedding=alias)
assert isinstance(enc, PositionEmbeddingSine)
assert enc.normalize is True
@pytest.mark.parametrize(
"hidden_dim, expected_num_pos_feats",
[
pytest.param(256, 128, id="dim256"),
pytest.param(512, 256, id="dim512"),
],
)
def test_num_pos_feats_is_half_hidden_dim(self, hidden_dim: int, expected_num_pos_feats: int) -> None:
"""The sine encoding uses hidden_dim // 2 positional feature dimensions."""
enc = build_position_encoding(hidden_dim=hidden_dim, position_embedding="sine")
assert enc.num_pos_feats == expected_num_pos_feats
class TestBuildPositionEncodingUnsupportedValues:
"""build_position_encoding raises ValueError for broken or unknown aliases."""
@pytest.mark.parametrize(
"alias",
[
pytest.param("learned", id="learned"),
pytest.param("v3", id="v3"),
],
)
def test_learned_raises_value_error(self, alias: str) -> None:
"""'learned' and 'v3' are doubly broken and must raise ValueError immediately.
The PositionEmbeddingLearned class has two bugs:
1. forward() signature is incompatible with Joiner.forward() (no align_dim_orders param).
2. h, w = x.shape[:2] unpacks batch and channels instead of height and width.
Rejecting them at build time is preferable to a silent or confusing runtime failure.
"""
with pytest.raises(ValueError, match="not supported"):
build_position_encoding(hidden_dim=256, position_embedding=alias)
def test_unknown_value_raises_value_error(self) -> None:
"""A fully unknown alias raises ValueError naming the supported alternatives."""
with pytest.raises(ValueError, match="not supported"):
build_position_encoding(hidden_dim=256, position_embedding="unknown_variant")
@pytest.mark.parametrize(
"alias",
[
pytest.param("learned", id="learned"),
pytest.param("v3", id="v3"),
],
)
def test_error_message_mentions_supported_alternatives(self, alias: str) -> None:
"""Error message for 'learned'/'v3' mentions at least one supported alternative."""
with pytest.raises(ValueError, match="sine"):
build_position_encoding(hidden_dim=256, position_embedding=alias)
+170
View File
@@ -0,0 +1,170 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for PostProcess box clamping behaviour."""
import pytest
import torch
from rfdetr.models.postprocess import PostProcess
class TestGatherAndScaleBoxes:
"""Tests for :meth:`PostProcess._gather_and_scale_boxes`."""
def test_clamps_boxes_to_image_bounds(self):
"""Boxes that extrapolate beyond [0, 1] in normalized space are clamped to pixel-space image dimensions after
scaling."""
# Three synthetic boxes in cxcywh normalized coords:
# [0] cx=0.01, w=0.10 → x1 = (0.01 - 0.05) * 640 = -25.6 ← negative
# [1] cx=0.99, w=0.10 → x2 = (0.99 + 0.05) * 640 = 665.6 ← overflow
# [2] cx=0.50, w=0.20 → fully in-bounds
out_bbox = torch.tensor(
[
[
[0.01, 0.01, 0.10, 0.10], # negative x1, y1 after scale
[0.99, 0.99, 0.10, 0.10], # x2 > img_w, y2 > img_h after scale
[0.50, 0.50, 0.20, 0.20], # in-bounds control
]
]
) # shape (B=1, Q=3, 4)
topk_boxes = torch.tensor([[0, 1, 2]]) # select all three
target_sizes = torch.tensor([[480, 640]]) # (h, w)
boxes = PostProcess._gather_and_scale_boxes(out_bbox, topk_boxes, target_sizes)
img_h, img_w = 480, 640
# All coords must be >= 0
assert (boxes >= 0).all(), f"Negative coords present: {boxes[boxes < 0]}"
# x1, x2 must be <= image width
assert (boxes[..., 0] <= img_w).all()
assert (boxes[..., 2] <= img_w).all()
# y1, y2 must be <= image height
assert (boxes[..., 1] <= img_h).all()
assert (boxes[..., 3] <= img_h).all()
# Exact clamped values — bounds-only check cannot catch a clamp returning e.g. 1.0 instead of 0.0
# box [0]: x1_raw=-25.6, y1_raw=-19.2 → clamped to 0.0
assert boxes[0, 0, 0].item() == pytest.approx(0.0), "x1 of underflowing box must clamp to 0"
assert boxes[0, 0, 1].item() == pytest.approx(0.0), "y1 of underflowing box must clamp to 0"
# box [1]: x2_raw=665.6 → clamped to img_w=640.0; y2_raw=499.2 → clamped to img_h=480.0
assert boxes[0, 1, 2].item() == pytest.approx(640.0), "x2 of overflowing box must clamp to img_w"
assert boxes[0, 1, 3].item() == pytest.approx(480.0), "y2 of overflowing box must clamp to img_h"
def test_in_bounds_boxes_unchanged(self):
"""Boxes already within image bounds are not altered by clamping."""
out_bbox = torch.tensor(
[
[
[0.30, 0.30, 0.20, 0.20],
[0.70, 0.60, 0.30, 0.40],
]
]
)
topk_boxes = torch.tensor([[0, 1]])
target_sizes = torch.tensor([[480, 640]])
boxes = PostProcess._gather_and_scale_boxes(out_bbox, topk_boxes, target_sizes)
# Manually computed expected values (no clamping needed)
expected = torch.tensor(
[
[
[128.0, 96.0, 256.0, 192.0], # cx=0.30,cy=0.30,w=0.20,h=0.20
[352.0, 192.0, 544.0, 384.0], # cx=0.70,cy=0.60,w=0.30,h=0.40
]
]
)
assert torch.allclose(boxes, expected, atol=1e-4), (
f"In-bounds boxes were altered.\nExpected:\n{expected}\nGot:\n{boxes}"
)
def test_multiple_images_in_batch(self):
"""Clamping works correctly across a batch of mixed image sizes."""
out_bbox = torch.tensor(
[
[
[0.01, 0.50, 0.10, 0.20], # image 0: negative x1
],
[
[0.99, 0.50, 0.10, 0.20], # image 1: x2 overflow
],
]
)
topk_boxes = torch.tensor([[0], [0]])
target_sizes = torch.tensor(
[
[300, 400], # image 0: 400×300
[600, 800], # image 1: 800×600
]
)
boxes = PostProcess._gather_and_scale_boxes(out_bbox, topk_boxes, target_sizes)
# Image 0: all coords must be in [0, 400]×[0, 300]
assert (boxes[0, :, 0] >= 0).all(), "img0 x1: expected >= 0"
assert (boxes[0, :, 0] <= 400).all(), "img0 x1: expected <= img_w (400)"
assert (boxes[0, :, 1] >= 0).all(), "img0 y1: expected >= 0"
assert (boxes[0, :, 1] <= 300).all(), "img0 y1: expected <= img_h (300)"
assert (boxes[0, :, 2] >= 0).all(), "img0 x2: expected >= 0"
assert (boxes[0, :, 2] <= 400).all(), "img0 x2: expected <= img_w (400)"
assert (boxes[0, :, 3] >= 0).all(), "img0 y2: expected >= 0"
assert (boxes[0, :, 3] <= 300).all(), "img0 y2: expected <= img_h (300)"
# Image 1: all coords must be in [0, 800]×[0, 600]
assert (boxes[1, :, 0] >= 0).all(), "img1 x1: expected >= 0"
assert (boxes[1, :, 0] <= 800).all(), "img1 x1: expected <= img_w (800)"
assert (boxes[1, :, 1] >= 0).all(), "img1 y1: expected >= 0"
assert (boxes[1, :, 1] <= 600).all(), "img1 y1: expected <= img_h (600)"
assert (boxes[1, :, 2] >= 0).all(), "img1 x2: expected >= 0"
assert (boxes[1, :, 2] <= 800).all(), "img1 x2: expected <= img_w (800)"
assert (boxes[1, :, 3] >= 0).all(), "img1 y2: expected >= 0"
assert (boxes[1, :, 3] <= 600).all(), "img1 y2: expected <= img_h (600)"
class TestPostProcessForward:
"""Integration tests for :meth:`PostProcess.forward`."""
def test_forward_clamps_edge_boxes_to_bounds(self):
"""PostProcess.forward returns non-negative in-bounds boxes for edge-hugging predictions."""
postprocess = PostProcess(num_select=2)
outputs = {
"pred_logits": torch.tensor([[[10.0, -10.0], [9.0, -10.0]]]),
"pred_boxes": torch.tensor([[[0.01, 0.01, 0.10, 0.10], [0.99, 0.99, 0.10, 0.10]]]),
}
target_sizes = torch.tensor([[480, 640]])
results = postprocess(outputs, target_sizes)
boxes = results[0]["boxes"]
assert (boxes >= 0).all(), f"Negative coords present: {boxes[boxes < 0]}"
assert (boxes[..., 0] <= 640).all(), "x1 exceeds img_w (640)"
assert (boxes[..., 2] <= 640).all(), "x2 exceeds img_w (640)"
assert (boxes[..., 1] <= 480).all(), "y1 exceeds img_h (480)"
assert (boxes[..., 3] <= 480).all(), "y2 exceeds img_h (480)"
class TestPostProcessMasks:
"""Tests for :meth:`PostProcess._postprocess_masks` mask resizing."""
def test_chunked_upsample_preserves_shape_for_large_k(self):
"""Chunked upsampling of K=64 masks returns full-resolution boolean masks of shape [K, 1, H, W]."""
batch, num_queries, mask_h, mask_w = 1, 64, 16, 16
num_select, img_h, img_w = 64, 512, 512
out_masks = torch.randn(batch, num_queries, mask_h, mask_w)
scores = torch.rand(batch, num_select)
labels = torch.zeros(batch, num_select, dtype=torch.long)
boxes = torch.zeros(batch, num_select, 4)
topk_boxes = torch.arange(num_select).unsqueeze(0)
target_sizes = torch.tensor([[img_h, img_w]])
results = PostProcess._postprocess_masks(out_masks, scores, labels, boxes, topk_boxes, target_sizes)
masks = results[0]["masks"]
assert masks.shape == (num_select, 1, img_h, img_w)
assert masks.dtype == torch.bool
+174
View File
@@ -0,0 +1,174 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Unit tests for keypoint decoding in PostProcess."""
import pytest
import torch
from rfdetr.models.postprocess import PostProcess
def test_postprocess_keypoints_shape_and_scores() -> None:
"""PostProcess should emit keypoints and raw precision parameters for top detections."""
postprocess = PostProcess(num_select=2, num_keypoints_per_class=[17])
outputs = {
"pred_logits": torch.tensor([[[10.0, -10.0], [9.0, -10.0]]], dtype=torch.float32),
"pred_boxes": torch.tensor([[[0.5, 0.5, 0.5, 0.5], [0.4, 0.6, 0.2, 0.3]]], dtype=torch.float32),
"pred_keypoints": torch.zeros((1, 2, 17, 8), dtype=torch.float32),
}
outputs["pred_keypoints"][0, :, :, 0] = 0.5
outputs["pred_keypoints"][0, :, :, 1] = 0.25
outputs["pred_keypoints"][0, :, :, 2] = 3.0
outputs["pred_keypoints"][0, :, :, 4] = 0.25
outputs["pred_keypoints"][0, :, :, 5] = 0.5
outputs["pred_keypoints"][0, :, :, 6] = -0.25
target_sizes = torch.tensor([[100, 200]], dtype=torch.int64)
results = postprocess(outputs, target_sizes)
keypoints = results[0]["keypoints"]
keypoint_precision = results[0]["keypoint_precision_cholesky"]
assert keypoints.shape == (2, 17, 3)
assert torch.allclose(keypoints[:, :, 0], torch.full((2, 17), 100.0))
assert torch.allclose(keypoints[:, :, 1], torch.full((2, 17), 25.0))
assert torch.all((keypoints[:, :, 2] > 0) & (keypoints[:, :, 2] < 1))
assert keypoint_precision.shape == (2, 17, 3)
torch.testing.assert_close(keypoint_precision[:, :, 0], torch.full((2, 17), 0.25))
torch.testing.assert_close(keypoint_precision[:, :, 1], torch.full((2, 17), 0.5))
torch.testing.assert_close(keypoint_precision[:, :, 2], torch.full((2, 17), -0.25))
def test_postprocess_keypoints_class_filtering() -> None:
"""Class-specific keypoint slots should be selected from padded per-class keypoint tensors."""
postprocess = PostProcess(num_select=1, num_keypoints_per_class=[2, 1])
outputs = {
"pred_logits": torch.tensor([[[0.0, 10.0]]], dtype=torch.float32),
"pred_boxes": torch.tensor([[[0.5, 0.5, 0.5, 0.5]]], dtype=torch.float32),
"pred_keypoints": torch.zeros((1, 1, 4, 8), dtype=torch.float32),
}
# class 0 slots: [0, 1], class 1 slots: [2, 3]
outputs["pred_keypoints"][0, 0, 2, 0] = 0.25
outputs["pred_keypoints"][0, 0, 2, 1] = 0.4
outputs["pred_keypoints"][0, 0, 2, 2] = 2.0
target_sizes = torch.tensor([[100, 200]], dtype=torch.int64)
results = postprocess(outputs, target_sizes)
keypoints = results[0]["keypoints"]
keypoint_precision = results[0]["keypoint_precision_cholesky"]
assert keypoints.shape == (1, 2, 3)
assert torch.allclose(keypoints[0, 0, 0], torch.tensor(50.0))
assert torch.allclose(keypoints[0, 0, 1], torch.tensor(40.0))
assert 0.0 < keypoints[0, 0, 2].item() < 1.0
torch.testing.assert_close(keypoints[0, 1], torch.zeros(3))
torch.testing.assert_close(keypoint_precision[0, 1], torch.full((3,), float("nan")), equal_nan=True)
def test_postprocess_keypoints_trace_alpha_rescores_active_keypoints_only() -> None:
"""Trace fusion should use active keypoints for the predicted class and ignore padded slots."""
postprocess = PostProcess(num_select=1, num_keypoints_per_class=[2, 1], trace_alpha=1.0)
outputs = {
"pred_logits": torch.tensor([[[-10.0, 0.0]]], dtype=torch.float32),
"pred_boxes": torch.tensor([[[0.5, 0.5, 0.5, 0.5]]], dtype=torch.float32),
"pred_keypoints": torch.zeros((1, 1, 4, 8), dtype=torch.float32),
}
# class 1 has one active slot at flat index 2 and one padded inactive slot at flat index 3.
outputs["pred_keypoints"][0, 0, 2, 2] = 10.0
outputs["pred_keypoints"][0, 0, 2, 4] = 0.0
outputs["pred_keypoints"][0, 0, 2, 5] = 0.0
outputs["pred_keypoints"][0, 0, 2, 6] = 0.0
outputs["pred_keypoints"][0, 0, 3, 2] = 10.0
outputs["pred_keypoints"][0, 0, 3, 4] = -2.0
outputs["pred_keypoints"][0, 0, 3, 6] = -2.0
target_sizes = torch.tensor([[100, 200]], dtype=torch.int64)
results = postprocess(outputs, target_sizes)
expected_score = torch.tensor([0.2], dtype=torch.float32)
torch.testing.assert_close(results[0]["scores"], expected_score, rtol=1e-4, atol=1e-6)
def test_postprocess_keypoints_trace_alpha_normalizes_large_fused_scores() -> None:
"""Trace-fused keypoint scores should be bounded after empirical normalization."""
postprocess = PostProcess(num_select=1, num_keypoints_per_class=[1], trace_alpha=1.0)
outputs = {
"pred_logits": torch.tensor([[[10.0]]], dtype=torch.float32),
"pred_boxes": torch.tensor([[[0.5, 0.5, 0.5, 0.5]]], dtype=torch.float32),
"pred_keypoints": torch.zeros((1, 1, 1, 8), dtype=torch.float32),
}
outputs["pred_keypoints"][0, 0, 0, 2] = 10.0
outputs["pred_keypoints"][0, 0, 0, 4] = 2.0
outputs["pred_keypoints"][0, 0, 0, 5] = 0.0
outputs["pred_keypoints"][0, 0, 0, 6] = 2.0
target_sizes = torch.tensor([[100, 200]], dtype=torch.int64)
results = postprocess(outputs, target_sizes)
original_score = torch.sigmoid(torch.tensor([10.0], dtype=torch.float32))
mean_trace = torch.tensor([2.0], dtype=torch.float32) * torch.exp(torch.tensor([-4.0], dtype=torch.float32))
fused_score = original_score * mean_trace.pow(-1.0)
expected_score = fused_score / (1.0 + fused_score)
assert fused_score.item() > 1.0
assert 0.0 < results[0]["scores"].item() < 1.0
torch.testing.assert_close(results[0]["scores"], expected_score, rtol=1e-4, atol=1e-6)
def test_postprocess_keypoints_trace_alpha_clamps_overflowing_fused_scores() -> None:
"""Trace fusion should stay finite and strictly below 1.0 when the raw fused score overflows."""
postprocess = PostProcess(num_select=1, num_keypoints_per_class=[1], trace_alpha=1.0)
outputs = {
"pred_logits": torch.tensor([[[0.0]]], dtype=torch.float32),
"pred_boxes": torch.tensor([[[0.5, 0.5, 0.5, 0.5]]], dtype=torch.float32),
"pred_keypoints": torch.zeros((1, 1, 1, 8), dtype=torch.float32),
}
outputs["pred_keypoints"][0, 0, 0, 2] = 10.0
outputs["pred_keypoints"][0, 0, 0, 4] = 50.0
outputs["pred_keypoints"][0, 0, 0, 5] = 0.0
outputs["pred_keypoints"][0, 0, 0, 6] = 50.0
target_sizes = torch.tensor([[100, 200]], dtype=torch.int64)
results = postprocess(outputs, target_sizes)
score = results[0]["scores"]
expected_score = torch.nextafter(torch.ones_like(score), torch.zeros_like(score))
assert torch.isfinite(score).all()
assert 0.0 < score.item() < 1.0
torch.testing.assert_close(score, expected_score, rtol=0.0, atol=0.0)
def test_postprocess_keypoints_trace_alpha_uses_log_space_for_extreme_trace() -> None:
"""Trace fusion should stay finite for extreme covariance terms."""
postprocess = PostProcess(num_select=1, num_keypoints_per_class=[1])
outputs = {
"pred_logits": torch.tensor([[[0.0]]], dtype=torch.float32),
"pred_boxes": torch.tensor([[[0.5, 0.5, 0.5, 0.5]]], dtype=torch.float32),
"pred_keypoints": torch.zeros((1, 1, 1, 8), dtype=torch.float32),
}
outputs["pred_keypoints"][0, 0, 0, 2] = 10.0
outputs["pred_keypoints"][0, 0, 0, 4] = -50.0
outputs["pred_keypoints"][0, 0, 0, 5] = 0.0
outputs["pred_keypoints"][0, 0, 0, 6] = 0.0
target_sizes = torch.tensor([[100, 200]], dtype=torch.int64)
results = postprocess(outputs, target_sizes)
expected_score = torch.tensor([0.5], dtype=torch.float32) * torch.exp(torch.tensor([-20.0], dtype=torch.float32))
torch.testing.assert_close(results[0]["scores"], expected_score, rtol=1e-4, atol=1e-12)
def test_postprocess_validate_outputs_raises_when_masks_and_keypoints_both_present() -> None:
"""PostProcess should raise ValueError when both pred_masks and pred_keypoints are present."""
postprocess = PostProcess(num_select=10)
outputs = {
"pred_logits": torch.zeros((1, 2, 2)),
"pred_boxes": torch.zeros((1, 2, 4)),
"pred_masks": torch.zeros((1, 2, 4, 4)),
"pred_keypoints": torch.zeros((1, 2, 17, 8)),
}
target_sizes = torch.tensor([[100, 200]], dtype=torch.int64)
with pytest.raises(ValueError, match="cannot be used together"):
postprocess(outputs, target_sizes)
+175
View File
@@ -0,0 +1,175 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for the ``_safe_torch_load`` helper in ``rfdetr.util.io``.
Covers the three-stage safe-load strategy: strict weights_only, safe-globals fallback, and opt-in pickle fallback.
"""
from __future__ import annotations
import argparse
from pathlib import Path
from types import SimpleNamespace
import pytest
import torch
from rfdetr.util.io import _safe_torch_load
# ---------------------------------------------------------------------------
# Fixtures / helpers
# ---------------------------------------------------------------------------
def _write_tensor_only_checkpoint(path: Path) -> None:
"""Save a checkpoint containing only tensors and plain dicts to *path*."""
ckpt = {"model": {"weight": torch.tensor([1.0, 2.0]), "bias": torch.tensor([0.0])}}
torch.save(ckpt, path)
def _write_namespace_checkpoint(path: Path) -> None:
"""Save a checkpoint with an ``argparse.Namespace`` args value to *path*.
Legacy RF-DETR engine.py checkpoints embed a Namespace; strict ``weights_only=True`` (without safe globals) would
reject these.
"""
ckpt = {
"model": {"weight": torch.tensor([1.0])},
"args": argparse.Namespace(pretrain_weights="rf-detr-small.pth", num_classes=80),
}
torch.save(ckpt, path)
def _write_simple_namespace_checkpoint(path: Path) -> None:
"""Save a checkpoint with a ``types.SimpleNamespace`` to *path*."""
ckpt = {
"model": {"weight": torch.tensor([1.0])},
"args": SimpleNamespace(pretrain_weights="rf-detr-small.pth"),
}
torch.save(ckpt, path)
class _ArbitraryObject:
"""Module-level object that torch.save can pickle but weights_only=True rejects.
Must be defined at module scope so pickle can resolve its fully-qualified name during serialisation (local/nested
classes cannot be pickled by torch.save).
"""
value = 42
def _write_arbitrary_pickle_checkpoint(path: Path) -> None:
"""Save a checkpoint that embeds an arbitrary class (requires pickle)."""
ckpt = {"model": {"weight": torch.tensor([1.0])}, "extra": _ArbitraryObject()}
torch.save(ckpt, path)
# ---------------------------------------------------------------------------
# Safe path (weights_only=True)
# ---------------------------------------------------------------------------
class TestSafeTorchLoadSafePath:
"""Tensor-only checkpoints load without trust=True."""
def test_tensor_only_checkpoint_loads(self, tmp_path: Path) -> None:
"""Pure-tensor checkpoint succeeds on the first safe-load attempt."""
ckpt_path = tmp_path / "ckpt.pth"
_write_tensor_only_checkpoint(ckpt_path)
result = _safe_torch_load(ckpt_path)
assert "model" in result
assert torch.allclose(result["model"]["weight"], torch.tensor([1.0, 2.0]))
def test_accepts_pathlib_path(self, tmp_path: Path) -> None:
"""Helper accepts a :class:`pathlib.Path` argument without error."""
ckpt_path = tmp_path / "ckpt.pth"
_write_tensor_only_checkpoint(ckpt_path)
result = _safe_torch_load(ckpt_path)
assert "model" in result
def test_accepts_string_path(self, tmp_path: Path) -> None:
"""Helper accepts a :class:`str` path argument without error."""
ckpt_path = tmp_path / "ckpt.pth"
_write_tensor_only_checkpoint(ckpt_path)
result = _safe_torch_load(str(ckpt_path))
assert "model" in result
# ---------------------------------------------------------------------------
# Safe-globals fallback (legacy Namespace checkpoints)
# ---------------------------------------------------------------------------
class TestSafeTorchLoadSafeGlobals:
"""Checkpoints with argparse.Namespace / SimpleNamespace load without trust=True."""
def test_argparse_namespace_loads_without_trust(self, tmp_path: Path) -> None:
"""argparse.Namespace checkpoint succeeds via the safe-globals retry."""
ckpt_path = tmp_path / "ckpt.pth"
_write_namespace_checkpoint(ckpt_path)
result = _safe_torch_load(ckpt_path)
assert isinstance(result["args"], argparse.Namespace)
assert result["args"].num_classes == 80
def test_simple_namespace_loads_without_trust(self, tmp_path: Path) -> None:
"""SimpleNamespace checkpoint succeeds via the safe-globals retry."""
ckpt_path = tmp_path / "ckpt.pth"
_write_simple_namespace_checkpoint(ckpt_path)
result = _safe_torch_load(ckpt_path)
assert isinstance(result["args"], SimpleNamespace)
# ---------------------------------------------------------------------------
# Arbitrary pickle — trust=False must raise, trust=True must succeed
# ---------------------------------------------------------------------------
class TestSafeTorchLoadTrustGate:
"""Arbitrary-pickle checkpoints require explicit trust=True."""
def test_arbitrary_pickle_raises_without_trust(self, tmp_path: Path) -> None:
"""Checkpoint with unknown Python object raises RuntimeError when trust=False."""
ckpt_path = tmp_path / "ckpt.pth"
_write_arbitrary_pickle_checkpoint(ckpt_path)
with pytest.raises(RuntimeError, match="trust_checkpoint=True"):
_safe_torch_load(ckpt_path, trust=False)
def test_arbitrary_pickle_raises_by_default(self, tmp_path: Path) -> None:
"""Checkpoint with unknown Python object raises RuntimeError when trust omitted (default=False)."""
ckpt_path = tmp_path / "ckpt.pth"
_write_arbitrary_pickle_checkpoint(ckpt_path)
with pytest.raises(RuntimeError, match="trust_checkpoint=True"):
_safe_torch_load(ckpt_path)
def test_arbitrary_pickle_succeeds_with_trust(self, tmp_path: Path) -> None:
"""Checkpoint with unknown Python object loads when trust=True."""
ckpt_path = tmp_path / "ckpt.pth"
_write_arbitrary_pickle_checkpoint(ckpt_path)
result = _safe_torch_load(ckpt_path, trust=True)
assert "model" in result
def test_trust_true_emits_warning(self, tmp_path: Path) -> None:
"""Trust=True triggers a UserWarning about unsafe loading."""
ckpt_path = tmp_path / "ckpt.pth"
_write_arbitrary_pickle_checkpoint(ckpt_path)
with pytest.warns(UserWarning, match="weights_only=False"):
_safe_torch_load(ckpt_path, trust=True)
+501
View File
@@ -0,0 +1,501 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for transformer utilities, MS deformable attention core, and MSDeformAttn module."""
import io
import numpy as np
import pytest
import torch
from rfdetr.models.ops.functions import ms_deform_attn_core_pytorch
from rfdetr.models.ops.modules.ms_deform_attn import MSDeformAttn
from rfdetr.models.transformer import gen_encoder_output_proposals, gen_sineembed_for_position
@pytest.fixture(autouse=True)
def _reset_random_seeds() -> None:
"""Ensure reproducible random state for every test."""
torch.manual_seed(42)
torch.cuda.manual_seed_all(42)
_MSDeformInputs = tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, list[tuple[int, int]]]
def _build_ms_deform_inputs(
bsz: int = 1,
n_heads: int = 2,
head_dim: int = 4,
len_q: int = 3,
npts: int = 1,
levels: list[tuple[int, int]] | None = None,
) -> _MSDeformInputs:
"""Build minimal valid inputs for ms_deform_attn_core_pytorch.
Args:
bsz: Batch size.
n_heads: Number of attention heads.
head_dim: Dimension per head.
len_q: Number of query elements.
npts: Number of sampling points per level.
levels: List of (H, W) int pairs; defaults to [(4, 4), (2, 2)].
Returns:
Tuple of (value, spatial_shapes_tensor, sampling_locations,
attention_weights, spatial_shapes_hw).
"""
if levels is None:
levels = [(4, 4), (2, 2)]
nlvl = len(levels)
total_hw = sum(ht * wd for ht, wd in levels)
spatial_shapes_tensor = torch.tensor(levels, dtype=torch.long)
value = torch.randn(bsz, n_heads, head_dim, total_hw)
# sampling_locations: (bsz, len_q, n_heads, nlvl, npts, 2) in [0, 1]
sampling_locations = torch.rand(bsz, len_q, n_heads, nlvl, npts, 2)
# attention_weights: (bsz, len_q, n_heads, nlvl * npts)
attention_weights = torch.softmax(torch.randn(bsz, len_q, n_heads, nlvl * npts), dim=-1)
return value, spatial_shapes_tensor, sampling_locations, attention_weights, levels
def test_gen_encoder_output_proposals_passes_ij_indexing_to_meshgrid(monkeypatch) -> None:
"""`gen_encoder_output_proposals` 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)
memory = torch.randn(1, 4, 8)
spatial_shapes = torch.tensor([[2, 2]], dtype=torch.long)
output_memory, output_proposals = gen_encoder_output_proposals(
memory,
spatial_shapes=spatial_shapes,
)
assert call_count == 1
def test_gen_sineembed_for_position_keeps_box_dimensions_in_sin_cos_order() -> None:
"""4D box positional embeddings must use the pretrained sin/cos order for all dimensions."""
pos_tensor = torch.tensor([[[0.125, 0.25, 0.5, 0.75]]], dtype=torch.float32)
dim = 4
scale = 2 * torch.pi
dim_t = torch.arange(dim, dtype=pos_tensor.dtype)
dim_t = 10000 ** (2 * (dim_t // 2) / dim)
expected_parts = []
for coord_idx in (1, 0, 2, 3):
coord = pos_tensor[:, :, coord_idx] * scale
encoded = coord[:, :, None] / dim_t
expected_parts.append(torch.stack((encoded[:, :, 0::2].sin(), encoded[:, :, 1::2].cos()), dim=3).flatten(2))
expected = torch.cat(expected_parts, dim=2)
actual = gen_sineembed_for_position(pos_tensor, dim=dim)
torch.testing.assert_close(actual, expected, rtol=1e-4, atol=1e-6)
def test_gen_encoder_output_proposals_rejects_non_square_ij_indexing(monkeypatch) -> None:
"""Wrong meshgrid indexing (xy vs ij) produces different proposals for non-square spatial shapes."""
original_meshgrid = torch.meshgrid
def _meshgrid_wrong_indexing(*args, **kwargs):
kwargs["indexing"] = "xy"
return original_meshgrid(*args, **kwargs)
# Use non-square spatial shapes so that ij vs xy indexing produces observably different outputs.
memory = torch.randn(1, 8, 8)
spatial_shapes = torch.tensor([[2, 4]], dtype=torch.long)
correct_memory, correct_proposals = gen_encoder_output_proposals(memory, spatial_shapes=spatial_shapes)
monkeypatch.setattr(torch, "meshgrid", _meshgrid_wrong_indexing)
wrong_memory, wrong_proposals = gen_encoder_output_proposals(memory, spatial_shapes=spatial_shapes)
assert not torch.allclose(correct_proposals, wrong_proposals), (
"xy indexing must produce different proposals than ij indexing for non-square spatial shapes"
)
def test_gen_encoder_output_proposals_accepts_int_tuple_spatial_shapes() -> None:
"""`gen_encoder_output_proposals` must accept `spatial_shapes` as a tensor of int pairs."""
batch = 2
ht, wd = 4, 4
memory = torch.randn(batch, ht * wd, 8)
spatial_shapes = torch.tensor([[ht, wd]], dtype=torch.long)
output_memory, output_proposals = gen_encoder_output_proposals(memory, spatial_shapes=spatial_shapes)
assert output_memory.shape == memory.shape
assert output_proposals.shape == (batch, ht * wd, 4)
def test_gen_encoder_output_proposals_accepts_python_int_pair_spatial_shapes() -> None:
"""`gen_encoder_output_proposals` must accept `spatial_shapes` as `list[tuple[int, int]]` with no padding mask.
Regression: `Transformer.forward` passes Python int pairs derived from `src.shape`, so the
export-driven call path uses `list[tuple[int, int]]` rather than a tensor.
"""
batch, ht, wd, dim = 2, 4, 4, 8
memory = torch.randn(batch, ht * wd, dim)
spatial_shapes = [(ht, wd)] # Python int pairs, as produced by Transformer.forward()
output_memory, output_proposals = gen_encoder_output_proposals(
memory,
memory_padding_mask=None,
spatial_shapes=spatial_shapes,
)
assert output_memory.shape == memory.shape
assert output_proposals.shape == (batch, ht * wd, 4)
class TestMSDeformAttnCorePytorch:
"""Tests for ms_deform_attn_core_pytorch with Python int pair spatial shapes.
Regression suite for torch.export.export compatibility: iterating over a spatial_shapes tensor yields FakeTensor
scalars during FakeTensor tracing, which cannot be used as Python int split/view sizes. The function now accepts an
optional ``value_spatial_shapes_hw`` list of Python int pairs that bypasses tensor iteration.
"""
@pytest.fixture
def make_inputs(self) -> _MSDeformInputs:
"""Default two-level inputs: levels=[(4, 4), (2, 2)]."""
return _build_ms_deform_inputs()
@pytest.fixture
def single_level_inputs(self) -> _MSDeformInputs:
"""Single-level inputs: levels=[(8, 8)]."""
return _build_ms_deform_inputs(levels=[(8, 8)])
def test_with_tensor_spatial_shapes(self, make_inputs: _MSDeformInputs) -> None:
"""Baseline: passing only the tensor spatial_shapes still works."""
value, spatial_shapes_tensor, sampling_locations, attention_weights, _ = make_inputs
output = ms_deform_attn_core_pytorch(value, spatial_shapes_tensor, sampling_locations, attention_weights)
bsz, n_heads, head_dim, _ = value.shape
len_q = sampling_locations.shape[1]
assert output.shape == (bsz, len_q, n_heads * head_dim)
def test_with_python_int_pair_spatial_shapes(self, make_inputs: _MSDeformInputs) -> None:
"""Regression: value_spatial_shapes_hw list of Python int pairs must be accepted.
This is the torch.export.export-compatible code path: tensor scalar values (from iterating over a FakeTensor)
cannot be used as split/view sizes, so the caller passes explicit Python int pairs via value_spatial_shapes_hw
instead.
"""
value, spatial_shapes_tensor, sampling_locations, attention_weights, levels = make_inputs
output = ms_deform_attn_core_pytorch(
value,
spatial_shapes_tensor,
sampling_locations,
attention_weights,
value_spatial_shapes_hw=levels,
)
bsz, n_heads, head_dim, _ = value.shape
len_q = sampling_locations.shape[1]
assert output.shape == (bsz, len_q, n_heads * head_dim)
def test_tensor_and_hw_paths_produce_identical_outputs(self, make_inputs: _MSDeformInputs) -> None:
"""Python int pair path and tensor iteration path must produce the same result."""
value, spatial_shapes_tensor, sampling_locations, attention_weights, levels = make_inputs
out_tensor_path = ms_deform_attn_core_pytorch(
value, spatial_shapes_tensor, sampling_locations, attention_weights
)
out_hw_path = ms_deform_attn_core_pytorch(
value,
spatial_shapes_tensor,
sampling_locations,
attention_weights,
value_spatial_shapes_hw=levels,
)
torch.testing.assert_close(out_tensor_path, out_hw_path)
def test_single_level(self, single_level_inputs: _MSDeformInputs) -> None:
"""Single-level case with Python int pair path must not crash."""
value, spatial_shapes_tensor, sampling_locations, attention_weights, levels = single_level_inputs
output = ms_deform_attn_core_pytorch(
value,
spatial_shapes_tensor,
sampling_locations,
attention_weights,
value_spatial_shapes_hw=levels,
)
assert output.shape[0] == 1
class TestMSDeformAttnModule:
"""Tests for MSDeformAttn.forward covering the export-compatibility changes.
Validates the module-level parameter threading and export-mode assert guard introduced in the torch.export.export
compatibility fix.
"""
_d_model = 32
_n_heads = 4
_n_levels = 2
_n_points = 1
_hw_pairs: list[tuple[int, int]] = [(4, 4), (2, 2)]
def _make_module_inputs(
self,
) -> tuple[
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
list[tuple[int, int]],
]:
"""Build minimal valid inputs for MSDeformAttn.forward.
Returns:
Tuple of (query, reference_points, input_flatten,
input_spatial_shapes, input_level_start_index, hw_pairs).
"""
hw_pairs = self._hw_pairs
total_len = sum(ht * wd for ht, wd in hw_pairs)
bsz, len_q = 1, 3
query = torch.randn(bsz, len_q, self._d_model)
reference_points = torch.rand(bsz, len_q, self._n_levels, 2)
input_flatten = torch.randn(bsz, total_len, self._d_model)
input_spatial_shapes = torch.tensor(hw_pairs, dtype=torch.long)
# Cumulative start index per level: [0, H0*W0]
starts = [sum(ht * wd for ht, wd in hw_pairs[:idx]) for idx in range(self._n_levels)]
input_level_start_index = torch.tensor(starts, dtype=torch.long)
return query, reference_points, input_flatten, input_spatial_shapes, input_level_start_index, hw_pairs
def test_forward_without_hw_param_backward_compat(self) -> None:
"""MSDeformAttn.forward without hw param produces correct output shape."""
module = MSDeformAttn(
d_model=self._d_model, n_levels=self._n_levels, n_heads=self._n_heads, n_points=self._n_points
)
query, ref_pts, input_flatten, spatial_shapes, level_start_index, _ = self._make_module_inputs()
output = module(query, ref_pts, input_flatten, spatial_shapes, level_start_index)
bsz, len_q, _ = query.shape
assert output.shape == (bsz, len_q, self._d_model)
def test_forward_with_hw_param_produces_correct_shape(self) -> None:
"""MSDeformAttn.forward with input_spatial_shapes_hw produces correct output shape."""
module = MSDeformAttn(
d_model=self._d_model, n_levels=self._n_levels, n_heads=self._n_heads, n_points=self._n_points
)
query, ref_pts, input_flatten, spatial_shapes, level_start_index, hw_pairs = self._make_module_inputs()
output = module(
query, ref_pts, input_flatten, spatial_shapes, level_start_index, input_spatial_shapes_hw=hw_pairs
)
bsz, len_q, _ = query.shape
assert output.shape == (bsz, len_q, self._d_model)
def test_export_mode_forward_with_hw_param(self) -> None:
"""MSDeformAttn.forward in export mode with hw param must not raise."""
module = MSDeformAttn(
d_model=self._d_model, n_levels=self._n_levels, n_heads=self._n_heads, n_points=self._n_points
)
module.export()
query, ref_pts, input_flatten, spatial_shapes, level_start_index, hw_pairs = self._make_module_inputs()
output = module(
query, ref_pts, input_flatten, spatial_shapes, level_start_index, input_spatial_shapes_hw=hw_pairs
)
bsz, len_q, _ = query.shape
assert output.shape == (bsz, len_q, self._d_model)
def test_export_flag_set_after_export_call(self) -> None:
"""Calling .export() must set _export=True, enabling the torch._assert guard path."""
module = MSDeformAttn(
d_model=self._d_model, n_levels=self._n_levels, n_heads=self._n_heads, n_points=self._n_points
)
assert not module._export
module.export()
assert module._export
class TestGenEncoderOutputProposalsDynamicBatch:
"""Regression tests for dynamic batch support in gen_encoder_output_proposals.
Ensures that the ONNX-symbolic refactoring (PR #950 / issue #949) does not bake a fixed batch dimension into
proposals and that output shapes are correct for varying batch sizes.
"""
@pytest.mark.parametrize("batch_size", [1, 2, 4, 8])
def test_output_shape_invariant_across_batch_sizes(self, batch_size: int) -> None:
"""Output shapes must scale correctly with batch size, with no baked constants.
Args:
batch_size: Number of images in the batch.
"""
ht, wd, dim = 4, 4, 8
memory = torch.randn(batch_size, ht * wd, dim)
spatial_shapes = [(ht, wd)]
output_memory, output_proposals = gen_encoder_output_proposals(
memory, memory_padding_mask=None, spatial_shapes=spatial_shapes
)
assert output_memory.shape == (batch_size, ht * wd, dim)
assert output_proposals.shape == (batch_size, ht * wd, 4)
def test_proposals_semantically_equivalent_across_batch_sizes(self) -> None:
"""Proposals for batch=1 and batch=4 must be identical per image.
Regression: if batch_size were baked as a constant, repeating the same image
N times would produce different proposals for each copy.
"""
ht, wd, dim = 4, 4, 8
memory_single = torch.randn(1, ht * wd, dim)
memory_multi = memory_single.expand(4, -1, -1).contiguous()
spatial_shapes = [(ht, wd)]
_, proposals_single = gen_encoder_output_proposals(
memory_single, memory_padding_mask=None, spatial_shapes=spatial_shapes
)
_, proposals_multi = gen_encoder_output_proposals(
memory_multi, memory_padding_mask=None, spatial_shapes=spatial_shapes
)
torch.testing.assert_close(proposals_single.expand(4, -1, -1), proposals_multi)
@pytest.mark.parametrize("batch_size", [1, 4])
def test_output_shape_invariant_with_padding_mask(self, batch_size: int) -> None:
"""Output shapes must be correct when memory_padding_mask is provided with varying batch sizes.
Regression for PR #950 / issue #949: the masked branch used .reshape(-1, h, w, 1) to infer the batch dimension
dynamically; this test verifies the branch handles varying batch sizes without error.
Args:
batch_size: Number of images in the batch.
"""
ht, wd, dim = 4, 4, 8
total_hw = ht * wd
memory = torch.randn(batch_size, total_hw, dim)
# Mask shape: (batch, sum_hw) — True means padding (invalid position)
memory_padding_mask = torch.zeros(batch_size, total_hw, dtype=torch.bool)
spatial_shapes = [(ht, wd)]
output_memory, output_proposals = gen_encoder_output_proposals(
memory, memory_padding_mask=memory_padding_mask, spatial_shapes=spatial_shapes
)
assert output_memory.shape == (batch_size, total_hw, dim)
assert output_proposals.shape == (batch_size, total_hw, 4)
@pytest.mark.parametrize("batch_size", [1, 4, 8])
def test_onnx_export_with_dynamic_batch_axis(self, batch_size: int) -> None:
"""ONNX export with dynamic batch axis must run inference for batch sizes other than the trace batch.
Regression for issue #949: exporting with a fixed trace batch baked `Reshape([8,...])` as a constant ONNX node,
causing TRT engines to fail at inference for any batch != 8. Skipped when onnx or onnxruntime is not installed.
"""
pytest.importorskip("onnx")
onnxruntime = pytest.importorskip("onnxruntime")
ht, wd, dim = 4, 4, 8
spatial_shapes_list = [(ht, wd)]
class _ProposalModule(torch.nn.Module):
"""Thin wrapper to export gen_encoder_output_proposals via torch.onnx."""
def forward(self, memory: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
"""Forward pass delegating to gen_encoder_output_proposals."""
return gen_encoder_output_proposals(
memory, memory_padding_mask=None, spatial_shapes=spatial_shapes_list
)
module = _ProposalModule()
trace_memory = torch.randn(2, ht * wd, dim)
buf = io.BytesIO()
torch.onnx.export(
module,
(trace_memory,),
buf,
input_names=["memory"],
output_names=["output_memory", "output_proposals"],
dynamic_axes={"memory": {0: "batch"}},
opset_version=17,
)
buf.seek(0)
onnx_bytes = buf.read()
session = onnxruntime.InferenceSession(onnx_bytes, providers=["CPUExecutionProvider"])
memory_np = np.random.randn(batch_size, ht * wd, dim).astype(np.float32)
out_memory, out_proposals = session.run(None, {"memory": memory_np})
assert out_memory.shape == (batch_size, ht * wd, dim), f"wrong memory shape for batch={batch_size}"
assert out_proposals.shape == (batch_size, ht * wd, 4), f"wrong proposals shape for batch={batch_size}"
def test_ms_deform_attn_core_pytorch_export_compatible() -> None:
"""torch.export.export must succeed on a module using ms_deform_attn_core_pytorch with hw param.
Regression test for the FakeTensor tracing failure: iterating over spatial_shapes and using the scalar elements as
split/view sizes fails during torch.export.export because FakeTensor data is not allocated. Passing
value_spatial_shapes_hw (concrete Python ints from a module attribute) bypasses the tensor iteration entirely.
"""
levels: list[tuple[int, int]] = [(4, 4), (2, 2)]
bsz, n_heads, head_dim = 1, 2, 4
total_hw = sum(ht * wd for ht, wd in levels)
len_q, nlvl, npts = 3, len(levels), 1
class _MinimalDeformAttn(torch.nn.Module):
"""Minimal wrapper to test torch.export.export on the hw-param code path."""
def __init__(self, hw: list[tuple[int, int]]) -> None:
super().__init__()
self.hw = hw
def forward(
self,
value: torch.Tensor,
spatial_shapes: torch.Tensor,
sampling_locations: torch.Tensor,
attention_weights: torch.Tensor,
) -> torch.Tensor:
"""Forward using concrete Python int pairs for export compatibility."""
return ms_deform_attn_core_pytorch(
value,
spatial_shapes,
sampling_locations,
attention_weights,
value_spatial_shapes_hw=self.hw,
)
value = torch.randn(bsz, n_heads, head_dim, total_hw)
spatial_shapes = torch.tensor(levels, dtype=torch.long)
sampling_locations = torch.rand(bsz, len_q, n_heads, nlvl, npts, 2)
attention_weights = torch.softmax(torch.randn(bsz, len_q, n_heads, nlvl * npts), dim=-1)
module = _MinimalDeformAttn(hw=levels)
exported = torch.export.export(module, args=(value, spatial_shapes, sampling_locations, attention_weights))
assert exported is not None
+250
View File
@@ -0,0 +1,250 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Regression tests for GroupPose-oriented transformer streams."""
from types import SimpleNamespace
import torch
from torch import nn
from rfdetr.models.transformer import Transformer, TransformerDecoder, TransformerDecoderLayer, build_transformer
def _build_transformer_inputs(
batch_size: int = 2,
hidden_dim: int = 16,
num_levels: int = 2,
) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor], torch.Tensor, torch.Tensor]:
"""Build minimal synthetic multi-scale inputs for `Transformer.forward`.
Args:
batch_size: Mini-batch size.
hidden_dim: Transformer and input channel size.
num_levels: Number of feature pyramid levels.
Returns:
srcs, masks, pos_embeds, refpoint_embed, query_feat.
"""
spatial_shapes = [(4, 4), (2, 2)]
srcs = [
torch.randn(batch_size, hidden_dim, spatial_shapes[idx][0], spatial_shapes[idx][1]) for idx in range(num_levels)
]
masks = [torch.zeros(batch_size, h, w, dtype=torch.bool) for h, w in spatial_shapes[:num_levels]]
pos_embeds = [
torch.randn(batch_size, hidden_dim, spatial_shapes[idx][0], spatial_shapes[idx][1]) for idx in range(num_levels)
]
refpoint_embed = torch.rand(6, 4)
query_feat = torch.randn(6, hidden_dim)
return srcs, masks, pos_embeds, refpoint_embed, query_feat
def test_transformer_keypoint_disabled_matches_default_contract() -> None:
"""Transformer without GroupPose should keep the 4-item return contract."""
srcs, masks, pos_embeds, refpoint_embed, query_feat = _build_transformer_inputs()
transformer = Transformer(
d_model=16,
num_queries=6,
num_decoder_layers=1,
sa_nhead=4,
ca_nhead=4,
num_feature_levels=2,
dec_n_points=1,
return_intermediate_dec=True,
lite_refpoint_refine=True,
use_grouppose_keypoints=False,
)
outputs = transformer(srcs, masks, pos_embeds, refpoint_embed, query_feat, cross_attn_srcs=None)
assert len(outputs) == 4, f"Expected 4 outputs, got {len(outputs)}"
hs, references, memory_ts, boxes_ts = outputs
assert hs is not None and references is not None
assert memory_ts is None and boxes_ts is None
def test_transformer_keypoint_enabled_shapes() -> None:
"""GroupPose path should emit keypoint hidden states and encoder keypoint slots."""
srcs, masks, pos_embeds, refpoint_embed, query_feat = _build_transformer_inputs()
transformer = Transformer(
d_model=16,
num_queries=6,
num_decoder_layers=1,
sa_nhead=4,
ca_nhead=4,
num_feature_levels=2,
dec_n_points=1,
return_intermediate_dec=True,
lite_refpoint_refine=True,
two_stage=True,
use_grouppose_keypoints=True,
num_keypoints_per_class=[17],
)
transformer.enc_out_class_embed = nn.ModuleList([nn.Linear(16, 2)])
transformer.enc_out_bbox_embed = nn.ModuleList([nn.Linear(16, 4)])
outputs = transformer(srcs, masks, pos_embeds, refpoint_embed, query_feat, cross_attn_srcs=None)
assert len(outputs) == 7, f"Expected 7 outputs, got {len(outputs)}"
hs, references, memory_ts, boxes_ts, keypoint_hs, enc_kp_predictions, keypoint_memory_ts = outputs
assert isinstance(hs, torch.Tensor)
assert hs.shape[-1] == 16
assert references.shape[-1] == 4
assert memory_ts is not None and boxes_ts is not None
assert keypoint_hs is not None
assert keypoint_hs.shape[3] == 17
assert enc_kp_predictions is not None
assert enc_kp_predictions.shape[-1] == 16
assert keypoint_memory_ts is not None
def test_build_transformer_defaults_inter_instance_keypoint_attention_to_config_default() -> None:
"""Older args objects without `inter_instance_kp_attn` should keep the preview topology default."""
args = SimpleNamespace(
hidden_dim=16,
sa_nheads=4,
ca_nheads=4,
num_queries=6,
dropout=0.0,
dim_feedforward=32,
dec_layers=1,
group_detr=1,
num_feature_levels=2,
dec_n_points=1,
lite_refpoint_refine=True,
decoder_norm="LN",
bbox_reparam=False,
use_grouppose_keypoints=True,
num_keypoints_per_class=[17],
)
transformer = build_transformer(args)
decoder_layer = transformer.decoder.layers[0]
assert isinstance(decoder_layer, TransformerDecoderLayer)
assert decoder_layer.enable_keypoint_processing
assert not decoder_layer.inter_instance_kp_attn
def test_keypoint_class_mask_person_only() -> None:
"""Person-only schema `[17]` should build a keypoint class mask with only self-class tokens."""
layer = TransformerDecoderLayer(
d_model=16,
sa_nhead=4,
ca_nhead=4,
dim_feedforward=32,
num_feature_levels=2,
enable_keypoint_processing=True,
grouppose_keypoint_dim_downscale=1,
keypoint_cross_attn=False,
inter_instance_kp_attn=False,
)
decoder = TransformerDecoder(
decoder_layer=layer,
num_layers=1,
return_intermediate=True,
d_model=16,
lite_refpoint_refine=True,
enable_keypoint_processing=True,
num_keypoints_per_class=[17],
grouppose_keypoint_dim_downscale=1,
)
assert decoder.keypoint_pos_embed is not None
assert decoder.keypoint_class_mask.shape == (18, 18)
assert decoder.keypoint_class_mask.dtype == torch.bool
assert not decoder.keypoint_class_mask.any()
def test_enc_keypoint_embed_eval_uses_only_head_zero() -> None:
"""Encoder keypoint path must use a single head (head 0) in eval mode.
Regression: ``group_detr = len(self.enc_out_keypoint_embed)`` without a
``self.training`` guard caused eval mode to split ``num_queries`` across all
group heads instead of routing every query through head 0. Fix:
``group_detr = len(...) if self.training else 1``.
Strategy: zero all head weights/biases; set head-0 last-layer bias to
``sentinel``. In eval mode every query routes through head 0, so
``kp_pred[..., 2:]`` (the pure-delta dims unaffected by ref_xy/wh) must all
equal ``sentinel``. In training mode only the first 1/group_detr queries go
through head 0 (the rest equal 0.0).
"""
group_detr = 3
num_queries = 6 # divisible by group_detr
hidden_dim = 16
batch_size = 1
sentinel = 50.0
srcs, masks, pos_embeds, _, _ = _build_transformer_inputs(batch_size=batch_size, hidden_dim=hidden_dim)
refpoint_embed = torch.rand(num_queries, 4)
query_feat = torch.randn(num_queries, hidden_dim)
transformer = Transformer(
d_model=hidden_dim,
num_queries=num_queries,
num_decoder_layers=1,
sa_nhead=4,
ca_nhead=4,
num_feature_levels=2,
dec_n_points=1,
return_intermediate_dec=True,
lite_refpoint_refine=True,
two_stage=True,
group_detr=group_detr,
use_grouppose_keypoints=True,
num_keypoints_per_class=[2],
)
transformer.enc_out_class_embed = nn.ModuleList([nn.Linear(hidden_dim, 2) for _ in range(group_detr)])
transformer.enc_out_bbox_embed = nn.ModuleList([nn.Linear(hidden_dim, 4) for _ in range(group_detr)])
# Zero all keypoint head weights and biases; give head 0 a distinctive output.
with torch.no_grad():
for _, head in enumerate(transformer.enc_out_keypoint_embed):
for layer in head.layers:
layer.weight.zero_()
layer.bias.zero_()
transformer.enc_out_keypoint_embed[0].layers[-1].bias.fill_(sentinel)
transformer.eval()
with torch.no_grad():
outputs = transformer(srcs, masks, pos_embeds, refpoint_embed, query_feat, cross_attn_srcs=None)
_, _, _, _, _, enc_kp_predictions, _ = outputs
assert enc_kp_predictions is not None, "enc_kp_predictions should not be None in keypoint mode"
# kp_pred = [kp_xy(2 dims), kp_delta[2:]]; dims 2: are pure MLP output unaffected by ref_xy/wh.
kp_beyond_xy = enc_kp_predictions[..., 2:]
assert (kp_beyond_xy == sentinel).all(), (
f"Eval mode must route all {num_queries} queries through head 0 (bias={sentinel}). "
f"Got min={kp_beyond_xy.min().item():.2f}, max={kp_beyond_xy.max().item():.2f}. "
"Bug: group_detr not guarded by self.training in enc_out_keypoint_embed loop."
)
def test_cross_attn_srcs_none_backward_compat() -> None:
"""`cross_attn_srcs=None` must remain equivalent to passing the primary feature stream."""
srcs, masks, pos_embeds, refpoint_embed, query_feat = _build_transformer_inputs()
transformer = Transformer(
d_model=16,
num_queries=6,
num_decoder_layers=1,
sa_nhead=4,
ca_nhead=4,
num_feature_levels=2,
dec_n_points=1,
return_intermediate_dec=True,
lite_refpoint_refine=True,
use_grouppose_keypoints=False,
)
outputs_default = transformer(srcs, masks, pos_embeds, refpoint_embed, query_feat, cross_attn_srcs=None)
outputs_explicit = transformer(srcs, masks, pos_embeds, refpoint_embed, query_feat, cross_attn_srcs=srcs)
assert len(outputs_default) == len(outputs_explicit) == 4
for default_part, explicit_part in zip(outputs_default, outputs_explicit):
if default_part is None:
assert explicit_part is None
else:
torch.testing.assert_close(default_part, explicit_part)
@@ -0,0 +1,409 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""ONNX-export regression tests for the shared Transformer's spatial_shapes path.
These guard the fix that builds ``spatial_shapes`` from symbolic feature-map shapes (``Shape`` -> ``Concat``) instead of
``torch.empty`` + in-place index assignment. The latter (added in #871 to keep the trace symbolic for dynamic-batch
export) emitted a ``ScatterND`` that fed a shape tensor, which TensorRT rejects ("IScatterLayer cannot be used to
compute a shape tensor"). The constant-baking ``torch.as_tensor`` alternative avoids the ScatterND but regresses the
symbolic trace back to a baked constant.
The Transformer is shared by detection, segmentation and keypoint models, so a single low-level export here covers the
spatial_shapes path for all of them.
"""
import inspect
import io
import numpy as np
import pytest
import torch
from torch import nn
onnx = pytest.importorskip("onnx", reason="onnx not installed; skip ONNX export tests")
from rfdetr.models.transformer import Transformer # noqa: E402
# CI guard: torch._shape_as_tensor is a private ATen API used on the live forward path in
# Transformer.forward(). If a future PyTorch upgrade removes it, this assertion fails
# loudly in CI before users hit an AttributeError at inference time.
assert hasattr(torch, "_shape_as_tensor"), (
"torch._shape_as_tensor not found — update spatial_shapes construction in "
"Transformer.forward() for the new PyTorch API."
)
# dynamo kwarg was added in PyTorch 2.1; our minimum is >=2.2.0, so it is always present.
# Check via inspect so that a future signature removal surfaces here rather than as a
# confusing TypeError inside torch.onnx.export.
_DYNAMO_KWARG: dict[str, bool] = (
{"dynamo": False} if "dynamo" in inspect.signature(torch.onnx.export).parameters else {}
)
class _TransformerExportWrapper(nn.Module):
"""Wrap Transformer.forward with flat tensor args for 2-level ONNX export.
``torch.onnx.export`` (TorchScript/non-dynamo path) cannot trace Python list arguments; this wrapper flattens the
list args of ``Transformer.forward`` into positional tensor arguments.
"""
def __init__(self, transformer: Transformer) -> None:
super().__init__()
self.transformer = transformer
def forward(
self,
s0: torch.Tensor,
s1: torch.Tensor,
p0: torch.Tensor,
p1: torch.Tensor,
m0: torch.Tensor,
m1: torch.Tensor,
refpoint_embed: torch.Tensor,
query_feat: torch.Tensor,
) -> torch.Tensor:
"""Run Transformer with two feature levels and return the first decoder output.
Args:
s0: First-level feature map of shape ``(B, C, H0, W0)``.
s1: Second-level feature map of shape ``(B, C, H1, W1)``.
p0: Positional embeddings for level 0, same shape as ``s0``.
p1: Positional embeddings for level 1, same shape as ``s1``.
m0: Boolean padding mask for level 0 of shape ``(B, H0, W0)``.
m1: Boolean padding mask for level 1 of shape ``(B, H1, W1)``.
refpoint_embed: Reference point embeddings of shape ``(num_queries, 4)``.
query_feat: Query feature embeddings of shape ``(num_queries, C)``.
Returns:
First intermediate decoder output tensor.
"""
outputs = self.transformer([s0, s1], [m0, m1], [p0, p1], refpoint_embed, query_feat, cross_attn_srcs=None)
return outputs[0]
class _TransformerExportWrapper1Level(nn.Module):
"""Wrap Transformer.forward with flat tensor args for 1-level ONNX export.
Production RF-DETR models (Base, Small, Nano, Medium, Large) use a single feature level
(``projector_scale=["P4"]``). This wrapper exercises that path.
"""
def __init__(self, transformer: Transformer) -> None:
super().__init__()
self.transformer = transformer
def forward(
self,
s0: torch.Tensor,
p0: torch.Tensor,
m0: torch.Tensor,
refpoint_embed: torch.Tensor,
query_feat: torch.Tensor,
) -> torch.Tensor:
"""Run Transformer with one feature level and return the first decoder output.
Args:
s0: Feature map of shape ``(B, C, H0, W0)``.
p0: Positional embeddings for level 0, same shape as ``s0``.
m0: Boolean padding mask for level 0 of shape ``(B, H0, W0)``.
refpoint_embed: Reference point embeddings of shape ``(num_queries, 4)``.
query_feat: Query feature embeddings of shape ``(num_queries, C)``.
Returns:
First intermediate decoder output tensor.
"""
outputs = self.transformer([s0], [m0], [p0], refpoint_embed, query_feat, cross_attn_srcs=None)
return outputs[0]
# ---------------------------------------------------------------------------
# Session-scoped fixtures — build and export once per test session
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def transformer_wrapper_2lvl() -> _TransformerExportWrapper:
"""Build a 2-level Transformer and wrap it for ONNX export.
Returns:
Eval-mode ``_TransformerExportWrapper`` with ``d_model=16``.
"""
transformer = Transformer(
d_model=16,
num_queries=6,
num_decoder_layers=1,
sa_nhead=4,
ca_nhead=4,
num_feature_levels=2,
dec_n_points=1,
return_intermediate_dec=True,
lite_refpoint_refine=True,
use_grouppose_keypoints=False,
)
return _TransformerExportWrapper(transformer.eval()).eval()
@pytest.fixture(scope="session")
def transformer_wrapper_1lvl() -> _TransformerExportWrapper1Level:
"""Build a 1-level Transformer and wrap it for ONNX export.
Returns:
Eval-mode ``_TransformerExportWrapper1Level`` with ``d_model=16``.
"""
transformer = Transformer(
d_model=16,
num_queries=6,
num_decoder_layers=1,
sa_nhead=4,
ca_nhead=4,
num_feature_levels=1,
dec_n_points=1,
return_intermediate_dec=True,
lite_refpoint_refine=True,
use_grouppose_keypoints=False,
)
return _TransformerExportWrapper1Level(transformer.eval()).eval()
@pytest.fixture(scope="session")
def example_inputs_2lvl() -> tuple[torch.Tensor, ...]:
"""Return fixed 2-level example input tensors for ``_TransformerExportWrapper``.
Level 0: spatial size 4×4. Level 1: spatial size 2×2. Batch size 1, ``d_model=16``.
Returns:
8-tuple ``(s0, s1, p0, p1, m0, m1, refpoint_embed, query_feat)``.
"""
return (
torch.randn(1, 16, 4, 4),
torch.randn(1, 16, 2, 2),
torch.randn(1, 16, 4, 4),
torch.randn(1, 16, 2, 2),
torch.zeros(1, 4, 4, dtype=torch.bool),
torch.zeros(1, 2, 2, dtype=torch.bool),
torch.rand(6, 4),
torch.randn(6, 16),
)
@pytest.fixture(scope="session")
def example_inputs_1lvl() -> tuple[torch.Tensor, ...]:
"""Return fixed 1-level example input tensors for ``_TransformerExportWrapper1Level``.
Level 0: spatial size 4×4. Batch size 1, ``d_model=16``.
Returns:
5-tuple ``(s0, p0, m0, refpoint_embed, query_feat)``.
"""
return (
torch.randn(1, 16, 4, 4),
torch.randn(1, 16, 4, 4),
torch.zeros(1, 4, 4, dtype=torch.bool),
torch.rand(6, 4),
torch.randn(6, 16),
)
@pytest.fixture(scope="session")
def exported_static_onnx(
tmp_path_factory: pytest.TempPathFactory,
transformer_wrapper_2lvl: _TransformerExportWrapper,
example_inputs_2lvl: tuple[torch.Tensor, ...],
) -> "onnx.ModelProto":
"""Export the 2-level Transformer to ONNX (static batch) and return the loaded proto.
Returns:
Loaded ``onnx.ModelProto`` for structural graph assertions.
"""
out = tmp_path_factory.mktemp("onnx_static") / "transformer.onnx"
torch.onnx.export(
transformer_wrapper_2lvl,
example_inputs_2lvl,
str(out),
input_names=["s0", "s1", "p0", "p1", "m0", "m1", "refpoint_embed", "query_feat"],
output_names=["hs"],
opset_version=17,
**_DYNAMO_KWARG,
)
return onnx.load(str(out))
@pytest.fixture(scope="session")
def exported_dynamic_onnx_bytes(
transformer_wrapper_2lvl: _TransformerExportWrapper,
example_inputs_2lvl: tuple[torch.Tensor, ...],
) -> bytes:
"""Export the 2-level Transformer with dynamic batch axis and return ONNX bytes.
Returns:
Serialized ONNX model bytes for onnxruntime inference with variable batch sizes.
"""
buf = io.BytesIO()
torch.onnx.export(
transformer_wrapper_2lvl,
example_inputs_2lvl,
buf,
input_names=["s0", "s1", "p0", "p1", "m0", "m1", "refpoint_embed", "query_feat"],
output_names=["hs"],
dynamic_axes={
"s0": {0: "batch"},
"s1": {0: "batch"},
"p0": {0: "batch"},
"p1": {0: "batch"},
"m0": {0: "batch"},
"m1": {0: "batch"},
},
opset_version=17,
**_DYNAMO_KWARG,
)
return buf.getvalue()
@pytest.fixture(scope="session")
def exported_1lvl_onnx(
tmp_path_factory: pytest.TempPathFactory,
transformer_wrapper_1lvl: _TransformerExportWrapper1Level,
example_inputs_1lvl: tuple[torch.Tensor, ...],
) -> "onnx.ModelProto":
"""Export the 1-level Transformer to ONNX and return the loaded proto.
Returns:
Loaded ``onnx.ModelProto`` for structural graph assertions.
"""
out = tmp_path_factory.mktemp("onnx_1lvl") / "transformer_1lvl.onnx"
torch.onnx.export(
transformer_wrapper_1lvl,
example_inputs_1lvl,
str(out),
input_names=["s0", "p0", "m0", "refpoint_embed", "query_feat"],
output_names=["hs"],
opset_version=17,
**_DYNAMO_KWARG,
)
return onnx.load(str(out))
# ---------------------------------------------------------------------------
# Tests: structural ONNX graph assertions (2-level static export)
# ---------------------------------------------------------------------------
def test_spatial_shapes_export_has_no_scatternd(exported_static_onnx: "onnx.ModelProto") -> None:
"""The exported Transformer must not contain a ScatterND (TRT shape-tensor killer)."""
op_types = [n.op_type for n in exported_static_onnx.graph.node]
assert "ScatterND" not in op_types, (
"ScatterND reintroduced in Transformer export — spatial_shapes is no longer "
"built from symbolic Shape ops; this breaks TensorRT engine building."
)
def test_spatial_shapes_export_is_shape_derived(exported_static_onnx: "onnx.ModelProto") -> None:
"""Sanity-check that the exported 2-level Transformer graph contains Shape ops.
Note: ``spatial_shapes`` itself traces as a ``Constant`` node in TorchScript ONNX export —
the tracer records concrete H,W values at trace time, not ``Shape`` ops. The ``Shape`` ops
present here originate from other model computations (e.g. batch-size extraction). The true
regression guards are ``test_spatial_shapes_export_has_no_scatternd`` (no ScatterND) and
``test_spatial_shapes_dynamic_batch_inference`` (runtime correctness at variable batch size).
"""
op_types = [n.op_type for n in exported_static_onnx.graph.node]
assert "Shape" in op_types, "No Shape op in 2-level Transformer ONNX graph — unexpected graph structure change."
# ---------------------------------------------------------------------------
# Tests: dynamic-batch export (2-level)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("batch_size", [pytest.param(1, id="batch1"), pytest.param(2, id="batch2")])
def test_spatial_shapes_dynamic_batch_inference(
exported_dynamic_onnx_bytes: bytes,
batch_size: int,
) -> None:
"""Dynamic-batch Transformer ONNX must run onnxruntime inference at batch != trace batch.
Regression guard: a baked batch constant in ``spatial_shapes`` or any upstream tensor
would cause shape mismatches at runtime for any batch size other than the trace batch (1).
"""
onnxruntime = pytest.importorskip("onnxruntime", reason="onnxruntime not installed")
session = onnxruntime.InferenceSession(exported_dynamic_onnx_bytes, providers=["CPUExecutionProvider"])
# The TorchScript tracer may constant-fold positional embeddings (p0, p1) into the
# graph; query the actual session inputs rather than assuming all 8 are present.
actual_names = {inp.name for inp in session.get_inputs()}
candidate_feeds = {
"s0": np.random.randn(batch_size, 16, 4, 4).astype(np.float32),
"s1": np.random.randn(batch_size, 16, 2, 2).astype(np.float32),
"p0": np.random.randn(batch_size, 16, 4, 4).astype(np.float32),
"p1": np.random.randn(batch_size, 16, 2, 2).astype(np.float32),
"m0": np.zeros((batch_size, 4, 4), dtype=bool),
"m1": np.zeros((batch_size, 2, 2), dtype=bool),
"refpoint_embed": np.random.rand(6, 4).astype(np.float32),
"query_feat": np.random.randn(6, 16).astype(np.float32),
}
feeds = {k: v for k, v in candidate_feeds.items() if k in actual_names}
(hs,) = session.run(None, feeds)
assert hs.shape[1] == batch_size, f"Expected batch dim=={batch_size} at index 1, got shape {hs.shape}"
# ---------------------------------------------------------------------------
# Tests: single-level export (production models use 1 feature level)
# ---------------------------------------------------------------------------
def test_spatial_shapes_single_level_export_has_no_scatternd(
exported_1lvl_onnx: "onnx.ModelProto",
) -> None:
"""Single-level Transformer ONNX export must not contain ScatterND."""
op_types = [n.op_type for n in exported_1lvl_onnx.graph.node]
assert "ScatterND" not in op_types, (
"ScatterND present in single-level Transformer export — torch.stack on a "
"one-element list must still produce a Shape-derived result."
)
def test_spatial_shapes_single_level_export_is_shape_derived(
exported_1lvl_onnx: "onnx.ModelProto",
) -> None:
"""Sanity-check that the exported 1-level Transformer graph contains Shape ops.
Note: ``spatial_shapes`` itself traces as a ``Constant`` node in TorchScript ONNX export —
the tracer records the concrete H,W value at trace time. The ``Shape`` ops present here
originate from other model computations. The true regression guards are
``test_spatial_shapes_single_level_export_has_no_scatternd`` and the dynamic-batch
inference test.
"""
op_types = [n.op_type for n in exported_1lvl_onnx.graph.node]
assert "Shape" in op_types, "No Shape op in 1-level Transformer ONNX graph — unexpected graph structure change."
# ---------------------------------------------------------------------------
# Tests: level_start_index numerical correctness
# ---------------------------------------------------------------------------
def test_level_start_index_correctness_two_levels() -> None:
"""spatial_shapes construction must extract correct (H, W) for non-square feature maps.
Uses H0=8, W0=6 and H1=4, W1=3 — non-square and H≠W so a transposed [2:4] slice would produce wrong values. Verifies
that the ``torch.stack(_shape_as_tensor)`` formula in ``Transformer.forward()`` yields
``spatial_shapes=[[8,6],[4,3]]`` and ``level_start_index=[0, 48]`` (cumulative H*W: 8*6=48, then 48+4*3=60 but index
stops at level boundaries → [0, 48]).
"""
s0 = torch.randn(1, 16, 8, 6)
s1 = torch.randn(1, 16, 4, 3)
srcs = [s0, s1]
spatial_shapes = torch.stack([torch._shape_as_tensor(src)[2:4] for src in srcs]).to(
device=s0.device, dtype=torch.long
)
level_start_index = torch.cat((spatial_shapes.new_zeros((1,)), spatial_shapes.prod(1).cumsum(0)[:-1]))
assert torch.equal(spatial_shapes, torch.tensor([[8, 6], [4, 3]], dtype=torch.long)), (
f"spatial_shapes mismatch: got {spatial_shapes.tolist()}"
)
assert torch.equal(level_start_index, torch.tensor([0, 48], dtype=torch.long)), (
f"level_start_index expected [0, 48], got {level_start_index.tolist()}"
)
+152
View File
@@ -0,0 +1,152 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Unit tests for :func:`rfdetr.detr._validate_shape_dims` and :func:`rfdetr.detr._resolve_patch_size`.
Tests call each helper directly so each validation path has a single focused test without the export/predict scaffolding
overhead.
"""
from types import SimpleNamespace
import pytest
from rfdetr.detr import _resolve_patch_size, _validate_shape_dims
class TestValidateShapeDimsHappyPath:
"""_validate_shape_dims returns normalised (height, width) for valid inputs."""
def test_exact_plain_ints(self) -> None:
"""Plain int dims divisible by block_size are returned unchanged."""
assert _validate_shape_dims((56, 112), 14, 14, 1) == (56, 112)
def test_returns_plain_int_tuple(self) -> None:
"""Return type is always a tuple of plain Python int."""
h, w = _validate_shape_dims((56, 56), 14, 14, 1)
assert type(h) is int and type(w) is int
def test_numpy_int_accepted(self) -> None:
"""numpy.int64 dims are accepted via operator.index and normalised."""
import numpy as np
h, w = _validate_shape_dims((np.int64(56), np.int64(112)), 14, 14, 1)
assert (h, w) == (56, 112)
assert type(h) is int and type(w) is int
def test_non_square_shape(self) -> None:
"""Non-square (H != W) shapes are returned correctly."""
assert _validate_shape_dims((112, 224), 14, 14, 1) == (112, 224)
def test_block_size_from_num_windows(self) -> None:
"""block_size = patch_size * num_windows; both dims divisible by it."""
# patch_size=16, num_windows=2 → block_size=32
assert _validate_shape_dims((64, 128), 32, 16, 2) == (64, 128)
class TestValidateShapeDimsArityErrors:
"""_validate_shape_dims raises ValueError for non-two-element shapes."""
def test_one_element_raises(self) -> None:
"""Single-element tuple must raise ValueError."""
with pytest.raises(ValueError, match="shape must be a sequence"):
_validate_shape_dims((56,), 14, 14, 1)
def test_three_element_raises(self) -> None:
"""Three-element tuple must raise ValueError."""
with pytest.raises(ValueError, match="shape must be a sequence"):
_validate_shape_dims((56, 56, 3), 14, 14, 1)
def test_scalar_raises(self) -> None:
"""Bare scalar (not a sequence) must raise ValueError."""
with pytest.raises(ValueError, match="shape must be a sequence"):
_validate_shape_dims(56, 14, 14, 1) # type: ignore[arg-type]
class TestValidateShapeDimsInvalidDim:
"""_validate_shape_dims rejects bool, float, and non-positive dimension values."""
@pytest.mark.parametrize("shape,match", [((True, 56), "height"), ((56, False), "width")])
def test_bool_dim_raises(self, shape: tuple, match: str) -> None:
"""Bool dims must raise ValueError even though bool is an int subtype."""
with pytest.raises(ValueError, match=f"{match} must be an integer"):
_validate_shape_dims(shape, 14, 14, 1) # type: ignore[arg-type]
@pytest.mark.parametrize("shape", [(56.0, 56.0), (56.0, 56), (56, 56.0)])
def test_float_dim_raises(self, shape: tuple) -> None:
"""Float dims must raise ValueError (operator.index rejects them)."""
with pytest.raises(ValueError, match="must be an integer"):
_validate_shape_dims(shape, 14, 14, 1)
@pytest.mark.parametrize("shape", [(0, 56), (56, 0), (-14, 56), (56, -14)])
def test_non_positive_dim_raises(self, shape: tuple[int, int]) -> None:
"""Zero or negative dims must raise ValueError."""
with pytest.raises(ValueError, match="positive integers"):
_validate_shape_dims(shape, 14, 14, 1)
class TestValidateShapeDimsDivisibilityCheck:
"""_validate_shape_dims enforces divisibility by block_size."""
@pytest.mark.parametrize(
"shape, block_size",
[
pytest.param((55, 56), 14, id="height_not_divisible"),
pytest.param((56, 55), 14, id="width_not_divisible"),
pytest.param((48, 64), 32, id="height_not_divisible_large_block"),
],
)
def test_indivisible_shape_raises(self, shape: tuple[int, int], block_size: int) -> None:
"""Shapes not divisible by block_size must raise ValueError."""
with pytest.raises(ValueError, match=f"divisible by {block_size}"):
_validate_shape_dims(shape, block_size, 14, 1)
def test_error_message_includes_patch_size_and_num_windows(self) -> None:
"""Error message must name patch_size and num_windows for debuggability."""
with pytest.raises(ValueError, match="patch_size=16") as exc_info:
_validate_shape_dims((48, 64), 32, 16, 2)
assert "num_windows=2" in str(exc_info.value)
class TestResolvePatchSize:
"""_resolve_patch_size resolves and validates patch_size for export()/predict()."""
def _cfg(self, patch_size: int) -> SimpleNamespace:
"""Return a minimal model_config stub with the given patch_size."""
return SimpleNamespace(patch_size=patch_size)
def test_none_reads_from_model_config(self) -> None:
"""patch_size=None resolves to model_config.patch_size."""
assert _resolve_patch_size(None, self._cfg(16), "export") == 16
def test_none_falls_back_to_14_when_config_missing(self) -> None:
"""patch_size=None falls back to 14 when model_config has no patch_size."""
assert _resolve_patch_size(None, SimpleNamespace(), "export") == 14
def test_explicit_matching_config_accepted(self) -> None:
"""Providing patch_size equal to model_config.patch_size succeeds."""
assert _resolve_patch_size(14, self._cfg(14), "export") == 14
def test_explicit_mismatch_raises(self) -> None:
"""Providing patch_size != model_config.patch_size must raise ValueError."""
with pytest.raises(ValueError, match="does not match"):
_resolve_patch_size(16, self._cfg(14), "export")
def test_mismatch_error_includes_caller_name(self) -> None:
"""Mismatch error message includes the caller name for context."""
with pytest.raises(ValueError, match="predict"):
_resolve_patch_size(16, self._cfg(14), "predict")
@pytest.mark.parametrize("bad", [0, -1, True, False])
def test_invalid_explicit_patch_size_raises(self, bad: int) -> None:
"""Non-positive-int patch_size must raise ValueError before the mismatch check."""
cfg = SimpleNamespace(patch_size=bad)
with pytest.raises(ValueError, match="patch_size must be a positive integer"):
_resolve_patch_size(bad, cfg, "export")
def test_invalid_config_patch_size_raises(self) -> None:
"""Bad patch_size in model_config (when caller passes None) must raise ValueError."""
with pytest.raises(ValueError, match="patch_size must be a positive integer"):
_resolve_patch_size(None, SimpleNamespace(patch_size=0), "export")
File diff suppressed because it is too large Load Diff