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

502 lines
20 KiB
Python

# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for 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