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]
# ------------------------------------------------------------------------
+884
View File
@@ -0,0 +1,884 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for model export functionality.
Use cases covered:
- Segmentation outputs must be present in both train/eval modes to avoid export crashes.
- Export should not change the original model's training state.
- CLI export path (deploy.export.main) must include 'masks' in output_names for
segmentation models, call make_infer_image with the correct individual args, and call export_onnx with args.output_dir
as the first argument.
"""
import importlib.util
import inspect
import types
import warnings
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Literal
from unittest.mock import MagicMock, patch
import pytest
import torch
from torch.jit import TracerWarning
from rfdetr import RFDETRSegNano
from rfdetr import detr as _detr_module
from rfdetr.export import main as _cli_export_module
_IS_ONNX_INSTALLED = importlib.util.find_spec("onnx") is not None
@contextmanager
def ignore_tracer_warnings() -> Iterator[None]:
"""Suppress torch.jit.TracerWarning during export tests to reduce log spam."""
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=TracerWarning)
yield
class _DummyCoreModel:
"""Minimal torch.nn.Module stub shared across export tests.
Avoids real forward passes; returns synthetic detection (and optionally segmentation) outputs matching the shapes
expected by RFDETR.export().
"""
def __init__(self, *, segmentation_head: bool = False) -> None:
self._segmentation_head = segmentation_head
def to(self, *_args, **_kwargs):
return self
def eval(self):
return self
def cpu(self):
return self
def __call__(self, *_args, **_kwargs):
out = {"pred_boxes": torch.zeros(1, 1, 4), "pred_logits": torch.zeros(1, 1, 2)}
if self._segmentation_head:
out["pred_masks"] = torch.zeros(1, 1, 2, 2)
return out
def test_export_onnx_uses_legacy_exporter_when_dynamo_flag_exists(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""`export_onnx` should pass `dynamo=False` when supported by torch.onnx.export."""
captured_kwargs: dict = {}
class _ToyModel(torch.nn.Module):
def forward(self, x: torch.Tensor) -> torch.Tensor:
return x
def _fake_onnx_export(*args, **kwargs) -> None:
captured_kwargs.update(kwargs)
monkeypatch.setattr(_cli_export_module.torch.onnx, "export", _fake_onnx_export)
_cli_export_module.export_onnx(
output_dir=str(tmp_path),
model=_ToyModel(),
input_names=["images"],
input_tensors=torch.randn(1, 3, 8, 8),
output_names=["dets"],
dynamic_axes={},
verbose=False,
)
has_dynamo_arg = "dynamo" in inspect.signature(torch.onnx.export).parameters
assert ("dynamo" in captured_kwargs) == has_dynamo_arg
if has_dynamo_arg:
assert captured_kwargs["dynamo"] is False
@pytest.mark.gpu
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required for export test")
@pytest.mark.skipif(not _IS_ONNX_INSTALLED, reason="onnx not installed, run: pip install rfdetr[onnx]")
def test_segmentation_model_export_no_crash(tmp_path: Path) -> None:
"""Integration test: exporting a segmentation model should not crash.
This exercises the full export path to ensure no AttributeError occurs.
"""
model = RFDETRSegNano()
# This should not crash with "AttributeError: 'dict' object has no attribute 'shape'"
with ignore_tracer_warnings():
model.export(output_dir=str(tmp_path), verbose=False)
# Verify export produced output files
onnx_files = list(tmp_path.glob("*.onnx"))
assert len(onnx_files) > 0, "Export should produce ONNX file(s)"
@pytest.mark.gpu
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required for export test")
@pytest.mark.skipif(not _IS_ONNX_INSTALLED, reason="onnx not installed, run: pip install rfdetr[onnx]")
def test_export_does_not_change_original_training_state(tmp_path: Path) -> None:
"""Verify that calling export() does not change the original model's train/eval state.
This ensures that export() puts a deepcopy of the model in eval mode without mutating the underlying training model
used by RF-DETR.
"""
model = RFDETRSegNano()
# Access the underlying torch module (model.model.model), as in other tests
torch_model = model.model.model.to("cuda")
# Ensure the original model is in training mode
torch_model.train()
assert torch_model.training is True, "Precondition: original model should start in training mode"
# Call export() on the high-level model; this should not change the original model's mode
with ignore_tracer_warnings():
model.export(output_dir=str(tmp_path))
# After export, the original underlying model should still be in training mode
assert torch_model.training is True, "export() should not change the original model's training state"
@pytest.mark.parametrize(
"dynamic_batch, segmentation_head",
[
pytest.param(True, False, id="detection_dynamic"),
pytest.param(True, True, id="segmentation_dynamic"),
pytest.param(False, False, id="detection_static"),
],
)
def test_rfdetr_export_dynamic_batch_forwards_dynamic_axes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
dynamic_batch: bool,
segmentation_head: bool,
) -> None:
"""`RFDETR.export(..., dynamic_batch=True)` must pass a non-None `dynamic_axes` dict to `export_onnx`;
`dynamic_batch=False` must pass `None`."""
model = types.SimpleNamespace(
model=types.SimpleNamespace(
model=_DummyCoreModel(segmentation_head=segmentation_head), device="cpu", resolution=14
),
model_config=types.SimpleNamespace(
segmentation_head=segmentation_head,
use_grouppose_keypoints=False,
num_channels=3,
),
size=None,
)
captured: dict = {}
def _fake_make_infer_image(*_args, **_kwargs):
return torch.zeros(1, 3, 14, 14)
def _fake_export_onnx(*_args, dynamic_axes=None, **_kw):
captured["dynamic_axes"] = dynamic_axes
return str(tmp_path / "inference_model.onnx")
monkeypatch.setattr("rfdetr.export.main.make_infer_image", _fake_make_infer_image)
monkeypatch.setattr("rfdetr.export.main.export_onnx", _fake_export_onnx)
monkeypatch.setattr("rfdetr.detr.deepcopy", lambda x: x)
_detr_module.RFDETR.export(model, output_dir=str(tmp_path), dynamic_batch=dynamic_batch, shape=(14, 14))
dynamic_axes = captured.get("dynamic_axes")
if not dynamic_batch:
assert dynamic_axes is None, f"expected None for static export, got {dynamic_axes!r}"
return
assert isinstance(dynamic_axes, dict), f"expected dict, got {dynamic_axes!r}"
for name, axes in dynamic_axes.items():
assert axes == {0: "batch"}, f"axis spec for {name!r} should be {{0: 'batch'}}, got {axes!r}"
expected_names = {"input", "dets", "labels", "masks"} if segmentation_head else {"input", "dets", "labels"}
assert set(dynamic_axes.keys()) == expected_names, f"expected keys {expected_names}, got {set(dynamic_axes.keys())}"
@pytest.mark.gpu
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
@pytest.mark.parametrize("mode", [pytest.param("train", id="train_mode"), pytest.param("eval", id="eval_mode")])
def test_segmentation_outputs_present_in_train_and_eval(mode: Literal["train", "eval"]) -> None:
"""Use case: segmentation outputs are present in both train and eval modes."""
model = RFDETRSegNano()
# Access the underlying torch module (model.model.model)
torch_model = model.model.model.to("cuda")
# Use resolution compatible with model's patch size (312 for seg-nano)
resolution = model.model.resolution
dummy_input = torch.randn(1, 3, resolution, resolution, device="cuda")
if mode == "train":
torch_model.train()
else:
torch_model.eval()
with torch.no_grad():
output = torch_model(dummy_input)
assert "pred_boxes" in output
assert "pred_logits" in output
assert "pred_masks" in output
# --------------------------------------------------------------------------
# Tests for the CLI export path: rfdetr.export.main.main()
# --------------------------------------------------------------------------
class TestCliExportMain:
"""Unit tests for deploy.export.main() (CLI export path).
Three bugs were present before the fix:
1. output_names omitted 'masks' for segmentation models.
2. make_infer_image received the whole args Namespace instead of individual fields.
3. export_onnx received model/args in the wrong positions (output_dir was missing).
"""
@pytest.fixture
def output_dir(self, tmp_path: Path) -> str:
return str(tmp_path)
@staticmethod
def _make_args(
*,
backbone_only: bool = False,
segmentation_head: bool = False,
output_dir: str,
infer_dir: str | None = None,
shape: tuple[int, int] = (640, 640),
batch_size: int = 1,
verbose: bool = False,
opset_version: int = 17,
tensorrt: bool = False,
dynamic_batch: bool = False,
) -> types.SimpleNamespace:
return types.SimpleNamespace(
device="cpu",
seed=42,
layer_norm=False,
resume=None,
backbone_only=backbone_only,
segmentation_head=segmentation_head,
output_dir=output_dir,
infer_dir=infer_dir,
shape=shape,
batch_size=batch_size,
verbose=verbose,
opset_version=opset_version,
tensorrt=tensorrt,
dynamic_batch=dynamic_batch,
)
@staticmethod
def _run(args: types.SimpleNamespace) -> tuple[dict, dict]:
"""Run deploy.export.main(args) with all heavy dependencies mocked.
Stubs out build_model, make_infer_image, and export_onnx, and injects mock onnx/onnxsim modules so the export
module can be imported even when those optional packages are not installed.
Returns (make_infer_image_captured, export_onnx_captured).
"""
make_infer_image_captured: dict = {}
export_onnx_captured: dict = {}
mock_model = MagicMock()
# parameters() must return an iterable of real objects so sum(p.numel()) works
mock_model.parameters.return_value = []
mock_model.backbone.parameters.return_value = []
mock_model.backbone.__getitem__.return_value.projector.parameters.return_value = []
mock_model.backbone.__getitem__.return_value.encoder.parameters.return_value = []
mock_model.transformer.parameters.return_value = []
mock_model.to.return_value = mock_model
mock_model.cpu.return_value = mock_model
mock_model.eval.return_value = mock_model
if args.backbone_only:
mock_model.return_value = torch.zeros(1, 512, 20, 20)
elif args.segmentation_head:
mock_model.return_value = {
"pred_boxes": torch.zeros(1, 100, 4),
"pred_logits": torch.zeros(1, 100, 90),
"pred_masks": torch.zeros(1, 100, 27, 27),
}
else:
mock_model.return_value = {
"pred_boxes": torch.zeros(1, 300, 4),
"pred_logits": torch.zeros(1, 300, 90),
}
mock_tensor = MagicMock()
mock_tensor.to.return_value = mock_tensor
mock_tensor.cpu.return_value = mock_tensor
def fake_make_infer_image(*pos_args, **kw_args):
make_infer_image_captured["positional"] = pos_args
make_infer_image_captured["keyword"] = kw_args
return mock_tensor
def fake_export_onnx(output_dir, model, input_names, input_tensors, output_names, dynamic_axes, **kwargs):
export_onnx_captured["output_dir"] = output_dir
export_onnx_captured["model"] = model
export_onnx_captured["output_names"] = output_names
export_onnx_captured["dynamic_axes"] = dynamic_axes
export_onnx_captured["kwargs"] = kwargs
return str(args.output_dir) + "/inference_model.onnx"
with (
patch.object(_cli_export_module, "build_model", return_value=(mock_model, MagicMock(), MagicMock())),
patch.object(_cli_export_module, "make_infer_image", side_effect=fake_make_infer_image),
patch.object(_cli_export_module, "export_onnx", side_effect=fake_export_onnx),
patch.object(_cli_export_module, "get_rank", return_value=0),
):
_cli_export_module.main(args)
return make_infer_image_captured, export_onnx_captured
@pytest.mark.parametrize(
"segmentation_head, backbone_only, expected_output_names",
[
pytest.param(True, False, ["dets", "labels", "masks"], id="segmentation"),
pytest.param(False, False, ["dets", "labels"], id="detection"),
pytest.param(False, True, ["features"], id="backbone_only"),
],
)
def test_output_names(
self,
output_dir: str,
segmentation_head: bool,
backbone_only: bool,
expected_output_names: list[str],
) -> None:
"""export_onnx must receive the correct output_names for every model type.
Before the fix, deploy/export.py line 253 used:
output_names = ['features'] if args.backbone_only else ['dets', 'labels']
which always omitted 'masks' for segmentation models.
"""
args = self._make_args(
backbone_only=backbone_only,
segmentation_head=segmentation_head,
output_dir=output_dir,
)
_, export_onnx_captured = self._run(args)
actual = export_onnx_captured.get("output_names")
assert actual == expected_output_names, f"expected output_names={expected_output_names}, got {actual!r}"
def test_make_infer_image_receives_individual_fields(self, output_dir: str) -> None:
"""make_infer_image must be called with (infer_dir, shape, batch_size, device), not with the whole args
Namespace.
Before the fix, deploy/export.py line 251 used:
input_tensors = make_infer_image(args, device)
"""
shape = (640, 640)
batch_size = 2
infer_dir = None
args = self._make_args(
output_dir=output_dir,
infer_dir=infer_dir,
shape=shape,
batch_size=batch_size,
)
make_infer_image_captured, _ = self._run(args)
pos = make_infer_image_captured.get("positional", ())
assert pos[:3] == (infer_dir, shape, batch_size), f"expected (infer_dir, shape, batch_size), got {pos[:3]!r}"
def test_export_onnx_receives_output_dir_and_kwargs(self, output_dir: str) -> None:
"""export_onnx must be called as export_onnx(output_dir, model, ...) with backbone_only, verbose, and
opset_version forwarded as keyword args.
Before the fix, deploy/export.py line 294 used:
export_onnx(model, args, input_names, input_tensors, output_names, dynamic_axes)
which swapped output_dir/model and dropped all keyword args.
"""
args = self._make_args(
output_dir=output_dir,
verbose=True,
opset_version=11,
)
_, export_onnx_captured = self._run(args)
assert "output_dir" in export_onnx_captured, "export_onnx was not called"
assert export_onnx_captured["output_dir"] == output_dir, (
f"expected output_dir={output_dir!r}, got {export_onnx_captured['output_dir']!r}"
)
kwargs = export_onnx_captured.get("kwargs", {})
assert kwargs.get("verbose") == args.verbose, (
f"expected verbose={args.verbose!r}, got {kwargs.get('verbose')!r}"
)
assert kwargs.get("opset_version") == args.opset_version, (
f"expected opset_version={args.opset_version!r}, got {kwargs.get('opset_version')!r}"
)
assert "backbone_only" in kwargs, "backbone_only kwarg missing from export_onnx call"
@pytest.mark.parametrize(
"dynamic_batch, segmentation_head, backbone_only",
[
pytest.param(True, False, False, id="detection_dynamic"),
pytest.param(True, True, False, id="segmentation_dynamic"),
pytest.param(True, False, True, id="backbone_only_dynamic"),
pytest.param(False, False, False, id="detection_static"),
],
)
def test_dynamic_batch_forwards_dynamic_axes(
self,
output_dir: str,
dynamic_batch: bool,
segmentation_head: bool,
backbone_only: bool,
) -> None:
"""CLI --dynamic_batch=True must pass {name: {0: 'batch'}} for every I/O name.
When dynamic_batch=False, dynamic_axes must be None (static export).
"""
args = self._make_args(
output_dir=output_dir,
dynamic_batch=dynamic_batch,
segmentation_head=segmentation_head,
backbone_only=backbone_only,
)
_, captured = self._run(args)
dynamic_axes = captured.get("dynamic_axes")
if not dynamic_batch:
assert dynamic_axes is None, f"expected None for static export, got {dynamic_axes!r}"
return
assert isinstance(dynamic_axes, dict), f"expected dict, got {dynamic_axes!r}"
for name, axes in dynamic_axes.items():
assert axes == {0: "batch"}, f"axis spec for {name!r} should be {{0: 'batch'}}, got {axes!r}"
# Every input/output name must have an entry
if backbone_only:
expected_names = {"input", "features"}
elif segmentation_head:
expected_names = {"input", "dets", "labels", "masks"}
else:
expected_names = {"input", "dets", "labels"}
assert set(dynamic_axes.keys()) == expected_names, (
f"expected keys {expected_names}, got {set(dynamic_axes.keys())}"
)
@pytest.mark.parametrize(
"device",
[
pytest.param("cpu", id="cpu"),
pytest.param("cuda", id="cuda"),
],
)
def test_model_moved_to_correct_device(self, output_dir: str, device: str, monkeypatch) -> None:
"""model.to() and input_tensors.to() must use args.device, not a hard-coded 'cuda'.
Before the fix, export/main.py line 145 called model.eval().to('cuda') unconditionally, which crashed when
CUDA_VISIBLE_DEVICES was blank (CPU export).
"""
import torch
to_calls = []
mock_model = MagicMock()
mock_model.parameters.return_value = []
mock_model.backbone.parameters.return_value = []
mock_model.backbone.__getitem__.return_value.projector.parameters.return_value = []
mock_model.backbone.__getitem__.return_value.encoder.parameters.return_value = []
mock_model.transformer.parameters.return_value = []
# Capture .to() calls
def _record_to(dev):
to_calls.append(str(dev))
return mock_model
mock_model.to.side_effect = _record_to
mock_model.cpu.return_value = mock_model
mock_model.eval.return_value = mock_model
mock_model.return_value = {"pred_boxes": torch.zeros(1, 300, 4), "pred_logits": torch.zeros(1, 300, 90)}
mock_tensor = MagicMock()
tensor_to_calls = []
def _tensor_to(dev):
tensor_to_calls.append(str(dev))
return mock_tensor
mock_tensor.to.side_effect = _tensor_to
mock_tensor.cpu.return_value = mock_tensor
# When testing "cuda" path, pretend CUDA is not available so the
# fallback-to-cpu branch fires and we can verify the warning without
# needing a GPU in CI.
monkeypatch.setattr(torch, "cuda", MagicMock(is_available=MagicMock(return_value=False)))
args = self._make_args(output_dir=output_dir)
args.device = device
with (
patch.object(_cli_export_module, "build_model", return_value=(mock_model, MagicMock(), MagicMock())),
patch.object(_cli_export_module, "make_infer_image", return_value=mock_tensor),
patch.object(_cli_export_module, "export_onnx", return_value=str(output_dir) + "/m.onnx"),
patch.object(_cli_export_module, "get_rank", return_value=0),
):
_cli_export_module.main(args)
# The model must NOT have been moved to a hard-coded "cuda" device when
# device="cpu" — verify the fallback CPU path was taken.
assert "cuda" not in to_calls, f"model was moved to 'cuda' unexpectedly: {to_calls}"
class TestExportPatchSize:
"""RFDETR.export() patch_size validation and shape-divisibility tests."""
@staticmethod
def _scaffold(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, patch_size: int, num_windows: int
) -> types.SimpleNamespace:
"""Build a minimal RFDETR-like namespace with controllable patch_size/num_windows."""
model = types.SimpleNamespace(
model=types.SimpleNamespace(
model=_DummyCoreModel(),
device="cpu",
resolution=patch_size * num_windows * 2, # always valid
),
model_config=types.SimpleNamespace(
segmentation_head=False,
use_grouppose_keypoints=False,
patch_size=patch_size,
num_windows=num_windows,
num_channels=3,
),
size=None,
)
def _fake_make_infer_image(*_a, **_kw):
return torch.zeros(1, 3, 8, 8)
def _fake_export_onnx(*_a, **_kw):
return str(tmp_path / "inference_model.onnx")
monkeypatch.setattr("rfdetr.export.main.make_infer_image", _fake_make_infer_image)
monkeypatch.setattr("rfdetr.export.main.export_onnx", _fake_export_onnx)
monkeypatch.setattr("rfdetr.detr.deepcopy", lambda x: x)
return model
def test_export_patch_size_mismatch_raises(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""export(patch_size=X) must raise ValueError when X != model_config.patch_size."""
model = self._scaffold(monkeypatch, tmp_path, patch_size=14, num_windows=4)
with pytest.raises(ValueError, match="patch_size"):
_detr_module.RFDETR.export(model, output_dir=str(tmp_path), patch_size=16)
@pytest.mark.parametrize("bad_patch_size", [0, -1])
def test_export_invalid_patch_size_raises(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, bad_patch_size: int
) -> None:
"""Export() must raise ValueError when patch_size is not a positive integer."""
model = self._scaffold(monkeypatch, tmp_path, patch_size=14, num_windows=4)
# Keep model_config.patch_size consistent with the patch_size argument for this test
model.model_config.patch_size = bad_patch_size
with pytest.raises(ValueError, match="patch_size must be a positive integer"):
_detr_module.RFDETR.export(model, output_dir=str(tmp_path), patch_size=bad_patch_size)
def test_export_shape_must_be_divisible_by_block_size(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Export() must reject shapes not divisible by patch_size * num_windows."""
# patch_size=16, num_windows=2 → block_size=32; shape (48, 64): 48 % 32 != 0
model = self._scaffold(monkeypatch, tmp_path, patch_size=16, num_windows=2)
with pytest.raises(ValueError, match="divisible by 32"):
_detr_module.RFDETR.export(model, output_dir=str(tmp_path), shape=(48, 64))
@pytest.mark.parametrize(
"bad_shape",
[
pytest.param((-64, 64), id="negative_height"),
pytest.param((64, -64), id="negative_width"),
pytest.param((0, 64), id="zero_height"),
pytest.param((64, 0), id="zero_width"),
],
)
def test_export_negative_or_zero_shape_raises(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, bad_shape: tuple[int, int]
) -> None:
"""Export() must reject non-positive shape dimensions (Python -N % M == 0 wraps silently)."""
model = self._scaffold(monkeypatch, tmp_path, patch_size=16, num_windows=2)
with pytest.raises(ValueError, match="positive integers"):
_detr_module.RFDETR.export(model, output_dir=str(tmp_path), shape=bad_shape)
def test_export_shape_valid_for_block_size(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Export() accepts shape divisible by patch_size * num_windows without error."""
# patch_size=16, num_windows=2 → block_size=32; shape (64, 64) is valid
model = self._scaffold(monkeypatch, tmp_path, patch_size=16, num_windows=2)
# Should not raise
_detr_module.RFDETR.export(model, output_dir=str(tmp_path), shape=(64, 64))
@pytest.mark.parametrize("bad_patch_size", [True, False])
def test_export_bool_patch_size_raises(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, bad_patch_size: bool
) -> None:
"""Export() must reject bool values for patch_size (isinstance(True, int) is True)."""
model = self._scaffold(monkeypatch, tmp_path, patch_size=14, num_windows=1)
with pytest.raises(ValueError, match="patch_size must be a positive integer"):
_detr_module.RFDETR.export(model, output_dir=str(tmp_path), patch_size=bad_patch_size)
def test_export_explicit_patch_size_matching_config_succeeds(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""export(patch_size=X) must succeed when X matches model_config.patch_size."""
model = self._scaffold(monkeypatch, tmp_path, patch_size=14, num_windows=4)
# patch_size=14 matches model_config.patch_size=14; block_size=56; resolution=112 (56*2)
_detr_module.RFDETR.export(model, output_dir=str(tmp_path), patch_size=14)
@pytest.mark.parametrize(
"bad_shape",
[
pytest.param((14.0, 14.0), id="float_dims"),
pytest.param((14,), id="wrong_arity_one_element"),
pytest.param((14, 14, 3), id="wrong_arity_three_elements"),
pytest.param((True, 14), id="bool_height"),
pytest.param((14, False), id="bool_width"),
],
)
def test_export_invalid_shape_type_raises(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, bad_shape: tuple
) -> None:
"""Export() must raise ValueError for float, bool, or wrong-arity shape tuples."""
model = self._scaffold(monkeypatch, tmp_path, patch_size=14, num_windows=1)
with pytest.raises(ValueError, match="shape"):
_detr_module.RFDETR.export(model, output_dir=str(tmp_path), shape=bad_shape)
@pytest.mark.parametrize("bad_num_windows", [0, -1, True])
def test_export_invalid_num_windows_raises(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, bad_num_windows: int
) -> None:
"""Export() must raise ValueError when model_config.num_windows is not a positive integer."""
model = self._scaffold(monkeypatch, tmp_path, patch_size=14, num_windows=1)
model.model_config.num_windows = bad_num_windows
with pytest.raises(ValueError, match="num_windows must be a positive integer"):
_detr_module.RFDETR.export(model, output_dir=str(tmp_path))
def test_export_default_resolution_not_divisible_by_block_size_raises(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Export() with shape=None must raise ValueError when model.resolution % block_size != 0."""
# patch_size=14, num_windows=3 → block_size=42; scaffold sets resolution=84 (42*2) which is valid
# Override resolution to 50 (not divisible by 42) to trigger the check
model = self._scaffold(monkeypatch, tmp_path, patch_size=14, num_windows=3)
model.model.resolution = 50
with pytest.raises(ValueError, match="default resolution"):
_detr_module.RFDETR.export(model, output_dir=str(tmp_path))
def test_make_infer_image_produces_correct_rectangular_shape() -> None:
"""make_infer_image must produce a (B, C, H, W) tensor for non-square shapes.
Regression test for the square-resize bug where ``Resize((shape[0], shape[0]))`` was used instead of
``Resize((shape[0], shape[1]))``, causing the output width to silently equal the height.
"""
from rfdetr.export.main import make_infer_image
h, w, b = 112, 224, 2
tensor = make_infer_image(infer_dir=None, shape=(h, w), batch_size=b, device="cpu")
assert tensor.shape == (b, 3, h, w), f"Expected shape ({b}, 3, {h}, {w}), got {tensor.shape}"
# ---------------------------------------------------------------------------
# ONNX export variant naming
# ---------------------------------------------------------------------------
class TestExportOnnxVariantNaming:
"""Verify that export_onnx uses variant_name in the output filename."""
def test_variant_name_in_filename(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""When variant_name is provided, the ONNX file is named after the variant."""
captured: dict = {}
def _fake_onnx_export(*args, **kwargs) -> None:
captured["output_file"] = args[2] # 3rd positional arg is output_file
monkeypatch.setattr(_cli_export_module.torch.onnx, "export", _fake_onnx_export)
_cli_export_module.export_onnx(
output_dir=str(tmp_path),
model=torch.nn.Identity(),
input_names=["input"],
input_tensors=torch.randn(1, 3, 8, 8),
output_names=["dets"],
dynamic_axes=None,
verbose=False,
variant_name="rfdetr-medium",
)
assert captured["output_file"].endswith("rfdetr-medium.onnx")
def test_variant_name_with_backbone(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""backbone_only + variant_name produces '{variant}-backbone.onnx'."""
captured: dict = {}
def _fake_onnx_export(*args, **kwargs) -> None:
captured["output_file"] = args[2]
monkeypatch.setattr(_cli_export_module.torch.onnx, "export", _fake_onnx_export)
_cli_export_module.export_onnx(
output_dir=str(tmp_path),
model=torch.nn.Identity(),
input_names=["input"],
input_tensors=torch.randn(1, 3, 8, 8),
output_names=["features"],
dynamic_axes=None,
backbone_only=True,
verbose=False,
variant_name="rfdetr-nano",
)
assert captured["output_file"].endswith("rfdetr-nano-backbone.onnx")
def test_default_name_without_variant(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Without variant_name, falls back to 'inference_model.onnx'."""
captured: dict = {}
def _fake_onnx_export(*args, **kwargs) -> None:
captured["output_file"] = args[2]
monkeypatch.setattr(_cli_export_module.torch.onnx, "export", _fake_onnx_export)
_cli_export_module.export_onnx(
output_dir=str(tmp_path),
model=torch.nn.Identity(),
input_names=["input"],
input_tensors=torch.randn(1, 3, 8, 8),
output_names=["dets"],
dynamic_axes=None,
verbose=False,
)
assert captured["output_file"].endswith("inference_model.onnx")
def test_default_backbone_name_without_variant(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Without variant_name + backbone_only, falls back to 'backbone_model.onnx'."""
captured: dict = {}
def _fake_onnx_export(*args, **kwargs) -> None:
captured["output_file"] = args[2]
monkeypatch.setattr(_cli_export_module.torch.onnx, "export", _fake_onnx_export)
_cli_export_module.export_onnx(
output_dir=str(tmp_path),
model=torch.nn.Identity(),
input_names=["input"],
input_tensors=torch.randn(1, 3, 8, 8),
output_names=["features"],
dynamic_axes=None,
backbone_only=True,
verbose=False,
)
assert captured["output_file"].endswith("backbone_model.onnx")
def test_rfdetr_export_passes_variant_name(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""RFDETR.export() passes self.size as variant_name to export_onnx."""
captured: dict = {}
model = types.SimpleNamespace(
model=types.SimpleNamespace(model=_DummyCoreModel(), device="cpu", resolution=14),
model_config=types.SimpleNamespace(
segmentation_head=False,
use_grouppose_keypoints=False,
num_channels=3,
),
size="rfdetr-medium",
)
def _fake_make_infer_image(*_args, **_kwargs):
return torch.zeros(1, 3, 14, 14)
def _fake_export_onnx(*_args, variant_name=None, **_kw):
captured["variant_name"] = variant_name
return str(tmp_path / "rfdetr-medium.onnx")
monkeypatch.setattr("rfdetr.export.main.make_infer_image", _fake_make_infer_image)
monkeypatch.setattr("rfdetr.export.main.export_onnx", _fake_export_onnx)
monkeypatch.setattr("rfdetr.detr.deepcopy", lambda x: x)
_detr_module.RFDETR.export(model, output_dir=str(tmp_path), shape=(14, 14))
assert captured["variant_name"] == "rfdetr-medium"
def test_rfdetr_export_passes_none_when_size_not_set(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Base RFDETR (size=None) passes None as variant_name."""
captured: dict = {}
model = types.SimpleNamespace(
model=types.SimpleNamespace(model=_DummyCoreModel(), device="cpu", resolution=14),
model_config=types.SimpleNamespace(
segmentation_head=False,
use_grouppose_keypoints=False,
num_channels=3,
),
size=None,
)
def _fake_make_infer_image(*_args, **_kwargs):
return torch.zeros(1, 3, 14, 14)
def _fake_export_onnx(*_args, variant_name=None, **_kw):
captured["variant_name"] = variant_name
return str(tmp_path / "inference_model.onnx")
monkeypatch.setattr("rfdetr.export.main.make_infer_image", _fake_make_infer_image)
monkeypatch.setattr("rfdetr.export.main.export_onnx", _fake_export_onnx)
monkeypatch.setattr("rfdetr.detr.deepcopy", lambda x: x)
_detr_module.RFDETR.export(model, output_dir=str(tmp_path), shape=(14, 14))
assert captured["variant_name"] is None
@pytest.mark.parametrize(
"variant_name, expected_suffix",
[
pytest.param("", "inference_model.onnx", id="empty_string_falls_back_to_default"),
pytest.param("foo/bar", "bar.onnx", id="path_separator_stripped_to_basename"),
pytest.param("/tmp/x", "x.onnx", id="absolute_path_stripped_to_basename"),
],
)
def test_variant_name_sanitization(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
variant_name: str,
expected_suffix: str,
) -> None:
"""variant_name edge cases: empty string falls back to default; path separators are stripped."""
captured: dict = {}
def _fake_onnx_export(*args, **kwargs) -> None:
captured["output_file"] = args[2]
monkeypatch.setattr(_cli_export_module.torch.onnx, "export", _fake_onnx_export)
_cli_export_module.export_onnx(
output_dir=str(tmp_path),
model=torch.nn.Identity(),
input_names=["input"],
input_tensors=torch.randn(1, 3, 8, 8),
output_names=["dets"],
dynamic_axes=None,
verbose=False,
variant_name=variant_name or None,
)
assert captured["output_file"].endswith(expected_suffix)
+92
View File
@@ -0,0 +1,92 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for ``RFDETR.export_for_roboflow``.
``export_for_roboflow`` is the extracted, network-free core of ``deploy_to_roboflow``: it writes ``weights.pt`` (a dict
with ``"model"`` and ``"args"`` keys) and ``class_names.txt`` into a target directory. A lightweight stub stands in for
``self.model`` so the file-writing contract is exercised without building a real model or downloading weights.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import torch
from rfdetr.detr import RFDETR
def _make_stub_model(class_names: list[str]) -> RFDETR:
"""Build an RFDETR instance whose model/state are stubbed for export_for_roboflow.
``RFDETR.__init__`` is bypassed; only the attributes ``export_for_roboflow`` reads are populated.
"""
instance = RFDETR.__new__(RFDETR)
args = SimpleNamespace(resolution=560)
inner_module = SimpleNamespace(state_dict=lambda: {"weight": torch.zeros(2, 2)})
instance.model = SimpleNamespace(model=inner_module, args=args, class_names=class_names)
return instance
class TestExportForRoboflow:
"""export_for_roboflow writes a deploy-ready bundle into a directory."""
def test_writes_weights_pt_with_model_and_args(self, tmp_path: Path) -> None:
"""weights.pt is a dict with 'model' and 'args', and args carries resolution."""
model = _make_stub_model(["cat", "dog"])
model.export_for_roboflow(str(tmp_path))
bundle = torch.load(tmp_path / "weights.pt", map_location="cpu", weights_only=False)
assert set(bundle) == {"model", "args"}
assert "weight" in bundle["model"]
assert bundle["args"].resolution == 560
def test_writes_class_names_txt(self, tmp_path: Path) -> None:
"""class_names.txt lists one class name per line."""
model = _make_stub_model(["cat", "dog"])
model.export_for_roboflow(str(tmp_path))
assert (tmp_path / "class_names.txt").read_text(encoding="utf-8") == "cat\ndog"
def test_embeds_class_names_in_args(self, tmp_path: Path) -> None:
"""class_names are embedded in the saved args namespace when absent."""
model = _make_stub_model(["cat", "dog"])
model.export_for_roboflow(str(tmp_path))
bundle = torch.load(tmp_path / "weights.pt", map_location="cpu", weights_only=False)
assert bundle["args"].class_names == ["cat", "dog"]
def test_does_not_overwrite_existing_args_class_names(self, tmp_path: Path) -> None:
"""args.class_names already set on the model is preserved in the saved bundle."""
model = _make_stub_model(["cat", "dog"])
model.model.args.class_names = ["pre_existing"]
model.export_for_roboflow(str(tmp_path))
bundle = torch.load(tmp_path / "weights.pt", map_location="cpu", weights_only=False)
assert bundle["args"].class_names == ["pre_existing"]
def test_empty_class_names_writes_empty_file(self, tmp_path: Path) -> None:
"""Empty class_names list produces an empty class_names.txt (no trailing newline)."""
model = _make_stub_model([])
model.export_for_roboflow(str(tmp_path))
assert (tmp_path / "class_names.txt").read_text(encoding="utf-8") == ""
def test_creates_output_dir_when_missing(self, tmp_path: Path) -> None:
"""output_dir is created if it does not already exist."""
model = _make_stub_model(["cat", "dog"])
target = tmp_path / "nested" / "bundle"
model.export_for_roboflow(str(target))
assert (target / "weights.pt").exists()
assert (target / "class_names.txt").exists()
+160
View File
@@ -0,0 +1,160 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for the ``notes`` parameter in :func:`~rfdetr.export._onnx.exporter.export_onnx`."""
import json
from pathlib import Path
import pytest
import torch
import torch.nn as nn
onnx = pytest.importorskip("onnx", reason="onnx not installed; skip ONNX notes tests")
from rfdetr.export._onnx.exporter import export_onnx # noqa: E402
class _TinyModel(nn.Module):
"""Minimal model that can be exported to ONNX."""
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Run a trivial identity-like forward pass.
Args:
x: Input tensor.
Returns:
Input tensor unchanged.
"""
return x
def _export_tiny_model(tmp_path: Path, notes: object = None) -> str:
"""Export a tiny model to ONNX and return the output file path.
Args:
tmp_path: Temporary directory provided by pytest.
notes: Optional notes to embed in the ONNX file.
Returns:
Path to the exported ONNX file.
"""
model = _TinyModel().eval()
input_tensor = torch.randn(1, 3, 32, 32)
return export_onnx(
output_dir=str(tmp_path),
model=model,
input_names=["input"],
input_tensors=input_tensor,
output_names=["output"],
dynamic_axes=None,
verbose=False,
notes=notes,
)
class TestExportOnnxNotes:
"""Verify ``notes`` metadata round-trips through the ONNX export."""
@pytest.mark.parametrize(
"notes, expected_value",
[
pytest.param("simple string", "simple string", id="string"),
pytest.param(
{"date": "2026-01-01", "labeller": "Alice"},
'{"date": "2026-01-01", "labeller": "Alice"}',
id="dict",
),
pytest.param(["class_a", "class_b"], '["class_a", "class_b"]', id="list"),
pytest.param(42, "42", id="int"),
],
)
def test_notes_embedded_in_onnx_metadata(self, tmp_path: Path, notes: object, expected_value: str) -> None:
"""Notes are stored as the 'notes' metadata_props entry in the ONNX model."""
output_file = _export_tiny_model(tmp_path, notes=notes)
model = onnx.load(output_file)
meta = {prop.key: prop.value for prop in model.metadata_props}
assert "rfdetr_notes" in meta
assert meta["rfdetr_notes"] == expected_value
def test_string_notes_stored_verbatim_without_json_wrapping(self, tmp_path: Path) -> None:
"""Plain string notes must be stored as-is, not double-encoded as JSON."""
notes = "my run description"
output_file = _export_tiny_model(tmp_path, notes=notes)
model = onnx.load(output_file)
meta = {prop.key: prop.value for prop in model.metadata_props}
assert meta["rfdetr_notes"] == "my run description"
def test_dict_notes_round_trip_via_json(self, tmp_path: Path) -> None:
"""Dict notes deserialise back to the original dict via json.loads."""
notes = {"project": "ceramics", "batch": 7}
output_file = _export_tiny_model(tmp_path, notes=notes)
model = onnx.load(output_file)
meta = {prop.key: prop.value for prop in model.metadata_props}
assert json.loads(meta["rfdetr_notes"]) == notes
def test_no_notes_metadata_when_notes_is_none(self, tmp_path: Path) -> None:
"""When notes=None (default), no 'rfdetr_notes' metadata entry is written."""
output_file = _export_tiny_model(tmp_path, notes=None)
model = onnx.load(output_file)
meta = {prop.key: prop.value for prop in model.metadata_props}
assert "rfdetr_notes" not in meta
@pytest.mark.parametrize(
"notes",
[
pytest.param("", id="empty_string"),
pytest.param({}, id="empty_dict"),
pytest.param([], id="empty_list"),
pytest.param(0, id="zero"),
pytest.param(False, id="false"),
],
)
def test_falsy_notes_still_embedded(self, tmp_path: Path, notes: object) -> None:
"""Falsy but non-None notes are embedded; guard is 'is not None', not truthiness."""
output_file = _export_tiny_model(tmp_path, notes=notes)
model = onnx.load(output_file)
meta = {prop.key: prop.value for prop in model.metadata_props}
assert "rfdetr_notes" in meta
def test_unicode_notes_stored_verbatim(self, tmp_path: Path) -> None:
"""Unicode string notes survive the ONNX metadata round-trip unchanged."""
notes = "Reviewer: Łukasz · 2026-Q2 · ✅"
output_file = _export_tiny_model(tmp_path, notes=notes)
model = onnx.load(output_file)
meta = {prop.key: prop.value for prop in model.metadata_props}
assert meta["rfdetr_notes"] == notes
def test_nan_notes_raises_value_error(self, tmp_path: Path) -> None:
"""Non-finite float notes raise ValueError (allow_nan=False)."""
with pytest.raises(ValueError):
_export_tiny_model(tmp_path, notes=float("nan"))
def test_notes_is_keyword_only(self, tmp_path: Path) -> None:
"""Notes must be passed as a keyword argument; positional use raises TypeError."""
model = _TinyModel().eval()
input_tensor = torch.randn(1, 3, 32, 32)
with pytest.raises(TypeError):
export_onnx( # type: ignore[call-arg]
str(tmp_path),
model,
["input"],
input_tensor,
["output"],
None,
False,
False,
17,
None,
"positional_notes_value",
)
+229
View File
@@ -0,0 +1,229 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for TensorRT export helpers."""
import argparse
import subprocess
import pytest
from rfdetr.export import _tensorrt as tensorrt_export
def test_run_command_shell_dry_run_handles_missing_cuda_visible_devices(monkeypatch) -> None:
"""Dry-run logging should not crash when CUDA_VISIBLE_DEVICES is unset."""
monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising=False)
logged_messages = []
monkeypatch.setattr(tensorrt_export.logger, "info", logged_messages.append)
result = tensorrt_export.run_command_shell(["trtexec", "--help"], dry_run=True)
assert result.returncode == 0
assert any("CUDA_VISIBLE_DEVICES=" in message for message in logged_messages)
def test_run_command_shell_uses_list_not_string(monkeypatch) -> None:
"""subprocess.run must be called with a list (shell=False) to prevent injection."""
captured = {}
def _fake_run(command, shell, capture_output, text, check):
captured["command"] = command
captured["shell"] = shell
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
monkeypatch.setattr(tensorrt_export.subprocess, "run", _fake_run)
tensorrt_export.run_command_shell(["trtexec", "--onnx=/some/model.onnx"], dry_run=False)
assert isinstance(captured["command"], list), "command must be a list, not a string"
assert captured["shell"] is False, "shell=False is required to prevent injection"
def test_run_command_shell_dry_run_does_not_invoke_subprocess(monkeypatch) -> None:
"""Dry-run must return early without calling subprocess.run."""
was_called = []
def _should_not_run(*args, **kwargs):
was_called.append(True)
return subprocess.CompletedProcess([], 0)
monkeypatch.setattr(tensorrt_export.subprocess, "run", _should_not_run)
monkeypatch.setattr(tensorrt_export.logger, "info", lambda _: None)
result = tensorrt_export.run_command_shell(["trtexec", "--help"], dry_run=True)
assert not was_called, "subprocess.run must not be called during dry_run"
assert result.returncode == 0
def test_trtexec_returns_engine_path(monkeypatch) -> None:
"""Trtexec() must return the .engine path, not None."""
captured_argv = []
def _fake_run(command, **kwargs):
captured_argv.extend(command)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
monkeypatch.setattr(tensorrt_export.subprocess, "run", _fake_run)
monkeypatch.setattr(tensorrt_export, "parse_trtexec_output", lambda _: {})
args = argparse.Namespace(profile=False, verbose=False, dry_run=False)
result = tensorrt_export.trtexec("/tmp/model.onnx", args)
assert result == "/tmp/model.engine"
def test_trtexec_dry_run_returns_engine_path(monkeypatch) -> None:
"""Trtexec() with dry_run=True must still return the engine path."""
monkeypatch.setattr(tensorrt_export.logger, "info", lambda _: None)
monkeypatch.setattr(tensorrt_export, "parse_trtexec_output", lambda _: {})
args = argparse.Namespace(profile=False, verbose=False, dry_run=True)
result = tensorrt_export.trtexec("/tmp/model.onnx", args)
assert result == "/tmp/model.engine"
@pytest.mark.parametrize(
("onnx_path", "expected_engine"),
[
pytest.param("/output/rfdetr.onnx", "/output/rfdetr.engine", id="plain-path"),
pytest.param("/path with spaces/model.onnx", "/path with spaces/model.engine", id="path-with-spaces"),
pytest.param("/model;rm -rf /.onnx", "/model;rm -rf /.engine", id="shell-metachar"),
],
)
def test_trtexec_argv_contains_no_shell_string(monkeypatch, onnx_path: str, expected_engine: str) -> None:
"""Trtexec builds an argv list; no shell string concatenation of user paths."""
captured = {}
def _fake_run(command, shell, **kwargs):
captured["command"] = command
captured["shell"] = shell
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
monkeypatch.setattr(tensorrt_export.subprocess, "run", _fake_run)
monkeypatch.setattr(tensorrt_export, "parse_trtexec_output", lambda _: {})
args = argparse.Namespace(profile=False, verbose=False, dry_run=False)
result = tensorrt_export.trtexec(onnx_path, args)
assert result == expected_engine
assert isinstance(captured["command"], list), "argv must be a list"
assert captured["shell"] is False, "shell=False required"
# Verify the ONNX path appears as a standalone argument element (not shell-expanded)
assert any(onnx_path in arg for arg in captured["command"])
# ---------------------------------------------------------------------------
# parse_trtexec_output (#1)
# ---------------------------------------------------------------------------
_FULL_TRTEXEC_STDOUT = """\
[I] GPU Compute Time: min = 1.23 ms, max = 4.56 ms, mean = 2.34 ms, median = 2.10 ms
[I] Host to Device Transfer Time: min = 0.10 ms, max = 0.20 ms, mean = 0.15 ms
[I] Device to Host Transfer Time: min = 0.05 ms, max = 0.08 ms, mean = 0.06 ms
[I] Latency: min = 1.40 ms, max = 4.80 ms, mean = 2.55 ms
[I] Throughput: 391.22 qps
"""
_PARTIAL_TRTEXEC_STDOUT = """\
[I] GPU Compute Time: min = 1.00 ms, max = 2.00 ms, mean = 1.50 ms, median = 1.45 ms
[I] Throughput: 100.00 qps
"""
@pytest.mark.parametrize(
("output_text", "expected"),
[
pytest.param(
_FULL_TRTEXEC_STDOUT,
{
"compute_min_ms": 1.23,
"compute_max_ms": 4.56,
"compute_mean_ms": 2.34,
"compute_median_ms": 2.10,
"h2d_min_ms": 0.10,
"h2d_max_ms": 0.20,
"h2d_mean_ms": 0.15,
"d2h_min_ms": 0.05,
"d2h_max_ms": 0.08,
"d2h_mean_ms": 0.06,
"latency_min_ms": 1.40,
"latency_max_ms": 4.80,
"latency_mean_ms": 2.55,
"throughput_qps": 391.22,
},
id="all-5-patterns",
),
pytest.param(
"",
{},
id="empty-stdout",
),
pytest.param(
_PARTIAL_TRTEXEC_STDOUT,
{
"compute_min_ms": 1.00,
"compute_max_ms": 2.00,
"compute_mean_ms": 1.50,
"compute_median_ms": 1.45,
"throughput_qps": 100.00,
},
id="partial-stdout",
),
],
)
def test_parse_trtexec_output(output_text: str, expected: dict) -> None:
"""parse_trtexec_output extracts timing statistics from trtexec stdout."""
result = tensorrt_export.parse_trtexec_output(output_text)
assert result == pytest.approx(expected, abs=1e-6)
# ---------------------------------------------------------------------------
# CalledProcessError logging path (#15)
# ---------------------------------------------------------------------------
def test_run_command_shell_called_process_error_is_reraised(monkeypatch) -> None:
"""CalledProcessError from subprocess.run is re-raised after logging."""
error_messages = []
def _fake_run(command, **kwargs):
raise subprocess.CalledProcessError(returncode=1, cmd=["trtexec"], stderr="engine build failed")
monkeypatch.setattr(tensorrt_export.subprocess, "run", _fake_run)
monkeypatch.setattr(tensorrt_export.logger, "error", error_messages.append)
with pytest.raises(subprocess.CalledProcessError):
tensorrt_export.run_command_shell(["trtexec", "--onnx=/tmp/model.onnx"], dry_run=False)
assert error_messages, "logger.error must be called when CalledProcessError is raised"
# ---------------------------------------------------------------------------
# profile=True argv path (#17)
# ---------------------------------------------------------------------------
def test_trtexec_profile_true_wraps_with_nsys(monkeypatch) -> None:
"""Profile=True wraps trtexec with 'nsys profile …' and the output flag is present."""
captured_argv: list[str] = []
def _fake_run(command, **kwargs):
captured_argv.extend(command)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
monkeypatch.setattr(tensorrt_export.subprocess, "run", _fake_run)
monkeypatch.setattr(tensorrt_export, "parse_trtexec_output", lambda _: {})
monkeypatch.setattr(tensorrt_export.logger, "info", lambda _: None)
args = argparse.Namespace(profile=True, verbose=False, dry_run=False)
tensorrt_export.trtexec("/tmp/model.onnx", args)
assert captured_argv[0] == "nsys", "profile=True must wrap with nsys as argv[0]"
argv_str = " ".join(captured_argv)
assert "--output=" in argv_str, "nsys profile must include --output= flag"
File diff suppressed because it is too large Load Diff
+784
View File
@@ -0,0 +1,784 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for TFLite inference helpers.
Covers:
* ``_create_interpreter()`` — interpreter loading with tflite_runtime / tensorflow fallback
* ``_run_inference()`` — image preprocessing, invocation, and detection decoding
* ``_decode_masks()`` — segmentation mask upsampling and thresholding
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest import mock
import numpy as np
import pytest
import supervision as sv
from PIL import Image as PILImage
from rfdetr.export._tflite.inference import (
_bilinear_resize_half_pixel,
_create_interpreter,
_decode_masks,
_preprocess_image,
_run_inference,
)
# ---------------------------------------------------------------------------
# Shared helpers / factories
# ---------------------------------------------------------------------------
_INPUT_SHAPE = [1, 224, 224, 3]
_DET_OUTPUT = {"shape": [1, 10, 4], "name": "serving_default_dets:0", "index": 1}
_LABEL_OUTPUT = {"shape": [1, 10, 82], "name": "serving_default_labels:0", "index": 2}
def _make_boxes() -> np.ndarray:
"""Return (1, 10, 4) array of normalised cxcywh boxes all centred at 0.5."""
return np.array([[[0.5, 0.5, 0.1, 0.1]] * 10], dtype=np.float32)
def _make_logits(high_conf_idx: int | None = 0) -> np.ndarray:
"""Return (1, 10, 82) logits with one high-confidence entry when requested.
Background fill is -10.0 so sigmoid scores are near zero (~0.0001) for all entries except the explicitly boosted one
(logit=+10.0, sigmoid≈0.9999). This ensures the helper works correctly under per-class sigmoid scoring.
"""
logits = np.full((1, 10, 82), -10.0, dtype=np.float32)
if high_conf_idx is not None:
logits[0, high_conf_idx, 0] = 10.0
return logits
def _make_interp(
input_shape: list[int] | None = None,
out_dets: list[dict] | None = None,
boxes: np.ndarray | None = None,
logits: np.ndarray | None = None,
) -> mock.MagicMock:
"""Build a mock TFLite interpreter with configurable I/O details."""
if input_shape is None:
input_shape = _INPUT_SHAPE
out_dets = out_dets if out_dets is not None else [_DET_OUTPUT, _LABEL_OUTPUT]
if boxes is None:
boxes = _make_boxes()
if logits is None:
logits = _make_logits()
def _get_tensor(index: int) -> np.ndarray:
if index == _DET_OUTPUT["index"]:
return boxes
if index == _LABEL_OUTPUT["index"]:
return logits
raise ValueError(f"Unknown tensor index: {index}")
interp = mock.MagicMock()
interp.get_input_details.return_value = [{"shape": input_shape, "index": 0, "dtype": np.float32}]
interp.get_output_details.return_value = out_dets
interp.get_tensor.side_effect = _get_tensor
return interp
def _save_rgb_image(path: Path, size: tuple[int, int] = (64, 64)) -> None:
"""Write a small solid-colour RGB JPEG to *path*."""
PILImage.new("RGB", size, color=(100, 150, 200)).save(path)
def _save_grayscale_image(path: Path, size: tuple[int, int] = (64, 64)) -> None:
"""Write a small solid-colour grayscale PNG to *path*."""
PILImage.new("L", size, color=128).save(path)
# ---------------------------------------------------------------------------
# TestCreateInterpreter
# ---------------------------------------------------------------------------
# Shared masking entries for mock.patch.dict(sys.modules, ...) that force
# ``_create_interpreter`` to skip the ai_edge_litert backend probe.
_AI_EDGE_LITERT_MASK: dict[str, None] = {
"ai_edge_litert": None,
"ai_edge_litert.interpreter": None,
}
class TestCreateInterpreter:
"""Tests for ``_create_interpreter()``."""
@pytest.fixture()
def _mock_tflite_runtime(self):
"""Inject a fake tflite_runtime.interpreter into sys.modules and mask ai_edge_litert.
``_create_interpreter`` probes backends in priority order: ``ai_edge_litert`` first, then ``tflite_runtime``,
then ``tensorflow``. Masking ``ai_edge_litert`` and ``ai_edge_litert.interpreter`` to ``None`` forces the import
loop to fall through to the ``tflite_runtime`` path so tests exercise that branch regardless of what is
installed.
Python's import machinery resolves ``import tflite_runtime.interpreter`` by looking up
``sys.modules["tflite_runtime.interpreter"]`` directly. We also set the ``interpreter`` attribute on the parent
package mock so attribute-path resolution is consistent regardless of Python version.
"""
interp_instance = mock.MagicMock()
interp_instance.get_input_details.return_value = [{"shape": [1, 640, 640, 3], "dtype": np.float32}]
interp_instance.get_output_details.return_value = [
{"shape": [1, 300, 4], "name": "dets"},
{"shape": [1, 300, 81], "name": "labels"},
]
interp_cls = mock.MagicMock(return_value=interp_instance)
# Build the submodule with a real Interpreter attribute
import types
mod = types.ModuleType("tflite_runtime.interpreter")
mod.Interpreter = interp_cls # type: ignore[attr-defined]
# Build parent package that exposes mod as .interpreter
parent_mod = types.ModuleType("tflite_runtime")
parent_mod.interpreter = mod # type: ignore[attr-defined]
with mock.patch.dict(
sys.modules,
{
**_AI_EDGE_LITERT_MASK,
"tflite_runtime": parent_mod,
"tflite_runtime.interpreter": mod,
},
):
yield interp_cls, interp_instance
def test_uses_tflite_runtime_when_ai_edge_litert_absent(self, _mock_tflite_runtime) -> None:
"""tflite_runtime is used as backend when ai_edge_litert is masked from the environment."""
interp_cls, interp_instance = _mock_tflite_runtime
_create_interpreter("model.tflite")
interp_cls.assert_called_once_with(model_path="model.tflite")
def test_allocate_tensors_called(self, _mock_tflite_runtime) -> None:
"""allocate_tensors() is always called after construction."""
_, interp_instance = _mock_tflite_runtime
_create_interpreter("model.tflite")
interp_instance.allocate_tensors.assert_called_once()
def test_falls_back_to_tensorflow_when_tflite_runtime_missing(self) -> None:
"""tensorflow.lite.Interpreter is used when tflite_runtime is absent."""
interp_instance = mock.MagicMock()
interp_instance.get_input_details.return_value = [{"shape": [1, 640, 640, 3], "dtype": np.float32}]
interp_instance.get_output_details.return_value = [{"shape": [1, 300, 4], "name": "dets"}]
tf_interp_cls = mock.MagicMock(return_value=interp_instance)
tf_lite_mod = mock.MagicMock()
tf_lite_mod.Interpreter = tf_interp_cls
tf_mod = mock.MagicMock()
tf_mod.lite = tf_lite_mod
with mock.patch.dict(
sys.modules,
{
**_AI_EDGE_LITERT_MASK,
"tflite_runtime": None,
"tflite_runtime.interpreter": None,
"tensorflow": tf_mod,
"tensorflow.lite": tf_lite_mod,
},
):
_create_interpreter("model.tflite")
tf_interp_cls.assert_called_once_with(model_path="model.tflite")
interp_instance.allocate_tensors.assert_called_once()
def test_returns_interpreter(self, _mock_tflite_runtime) -> None:
"""Return value is the interpreter instance (not the class)."""
_, interp_instance = _mock_tflite_runtime
result = _create_interpreter("model.tflite")
assert result is interp_instance
def test_logs_input_and_output_shapes(self, _mock_tflite_runtime) -> None:
"""Logger.debug is called with 'Input' and 'Output' shape lines."""
with mock.patch("rfdetr.export._tflite.inference.logger") as mock_logger:
_create_interpreter("model.tflite")
debug_msgs = [call.args[0] for call in mock_logger.debug.call_args_list]
assert any("Input" in m for m in debug_msgs)
assert any("Output" in m for m in debug_msgs)
def test_accepts_path_object(self, _mock_tflite_runtime) -> None:
"""Path objects are converted to strings before passing to Interpreter."""
interp_cls, _ = _mock_tflite_runtime
_create_interpreter(Path("model.tflite"))
call_kwargs = interp_cls.call_args[1]
assert call_kwargs["model_path"] == "model.tflite"
assert isinstance(call_kwargs["model_path"], str)
@pytest.fixture()
def _mock_ai_edge_litert(self):
"""Inject a fake ai_edge_litert.interpreter into sys.modules and mask lower-priority backends.
Mirrors ``_mock_tflite_runtime`` for the first-priority backend so the ``ai_edge_litert.interpreter`` branch of
``_create_interpreter`` can be exercised independently of whether the real package is installed.
"""
interp_instance = mock.MagicMock()
interp_instance.get_input_details.return_value = [{"shape": [1, 640, 640, 3], "dtype": np.float32}]
interp_instance.get_output_details.return_value = [
{"shape": [1, 300, 4], "name": "dets"},
{"shape": [1, 300, 81], "name": "labels"},
]
interp_cls = mock.MagicMock(return_value=interp_instance)
import types
mod = types.ModuleType("ai_edge_litert.interpreter")
mod.Interpreter = interp_cls # type: ignore[attr-defined]
parent_mod = types.ModuleType("ai_edge_litert")
parent_mod.interpreter = mod # type: ignore[attr-defined]
with mock.patch.dict(
sys.modules,
{
"ai_edge_litert": parent_mod,
"ai_edge_litert.interpreter": mod,
"tflite_runtime": None,
"tflite_runtime.interpreter": None,
},
):
yield interp_cls, interp_instance
def test_uses_ai_edge_litert_when_available(self, _mock_ai_edge_litert) -> None:
"""ai_edge_litert is used as the first-priority backend when it is importable."""
interp_cls, _ = _mock_ai_edge_litert
_create_interpreter("model.tflite")
interp_cls.assert_called_once_with(model_path="model.tflite")
def test_ai_edge_litert_allocate_tensors_called(self, _mock_ai_edge_litert) -> None:
"""allocate_tensors() is called after construction via the ai_edge_litert backend."""
_, interp_instance = _mock_ai_edge_litert
_create_interpreter("model.tflite")
interp_instance.allocate_tensors.assert_called_once()
def test_ai_edge_litert_returns_interpreter(self, _mock_ai_edge_litert) -> None:
"""Return value is the ai_edge_litert interpreter instance."""
_, interp_instance = _mock_ai_edge_litert
result = _create_interpreter("model.tflite")
assert result is interp_instance
def test_raises_when_no_backend_available(self) -> None:
"""ImportError with a helpful install message is raised when all backends are absent."""
with mock.patch.dict(
sys.modules,
{
**_AI_EDGE_LITERT_MASK,
"tflite_runtime": None,
"tflite_runtime.interpreter": None,
"tensorflow": None,
"tensorflow.lite": None,
},
):
with pytest.raises(ImportError, match="TFLite inference requires"):
_create_interpreter("model.tflite")
# ---------------------------------------------------------------------------
# TestRunInference
# ---------------------------------------------------------------------------
class TestRunInference:
"""Tests for ``_run_inference()``."""
@pytest.fixture()
def rgb_image(self, tmp_path: Path) -> Path:
"""Write a small RGB JPEG to a temp file and return its path."""
p = tmp_path / "image.jpg"
_save_rgb_image(p)
return p
@pytest.fixture()
def grayscale_image(self, tmp_path: Path) -> Path:
"""Write a small grayscale PNG to a temp file and return its path."""
p = tmp_path / "gray.png"
_save_grayscale_image(p)
return p
def test_returns_detections_and_image(self, rgb_image: Path) -> None:
"""Return type is tuple[sv.Detections, PIL.Image.Image]."""
interp = _make_interp()
result = _run_inference(interp, rgb_image)
assert isinstance(result, tuple)
dets, img = result
assert isinstance(dets, sv.Detections)
assert isinstance(img, PILImage.Image)
def test_detections_above_threshold_kept(self, rgb_image: Path) -> None:
"""At least one detection is returned when one logit is high-confidence."""
interp = _make_interp(logits=_make_logits(high_conf_idx=0))
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert len(dets) >= 1
def test_detections_below_threshold_filtered(self, rgb_image: Path) -> None:
"""No detections survive when all logits are zero (uniform probs < 0.3)."""
interp = _make_interp(logits=_make_logits(high_conf_idx=None))
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert len(dets) == 0
def test_boxes_in_pixel_space(self, rgb_image: Path) -> None:
"""Xyxy coordinates are scaled to image pixel dimensions, not 01 range."""
img_size = (200, 100) # (width, height) for PIL
PILImage.new("RGB", img_size, color=(100, 150, 200)).save(rgb_image)
# One centred box: cx=0.5, cy=0.5, w=0.2, h=0.2
boxes = np.array([[[0.5, 0.5, 0.2, 0.2]] + [[0.0, 0.0, 0.0, 0.0]] * 9], dtype=np.float32)
logits = _make_logits(high_conf_idx=0)
interp = _make_interp(boxes=boxes, logits=logits)
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
# With cx=0.5*200=100, cy=0.5*100=50, bw=0.2*200=40, bh=0.2*100=20
# xyxy expected: [80, 40, 120, 60]
assert dets.xyxy[0, 0] > 1.0 # x1 in pixel coords, not 01
def test_set_tensor_called_with_correct_shape(self, rgb_image: Path) -> None:
"""set_tensor receives a tensor matching (1, H, W, C)."""
_, H, W, C = _INPUT_SHAPE # noqa: N806
interp = _make_interp()
_run_inference(interp, rgb_image)
call_args = interp.set_tensor.call_args
tensor_arg = call_args[0][1]
assert tensor_arg.shape == (1, H, W, C)
def test_invoke_called_exactly_once(self, rgb_image: Path) -> None:
"""interp.invoke() is called exactly once per inference call."""
interp = _make_interp()
_run_inference(interp, rgb_image)
interp.invoke.assert_called_once()
def test_grayscale_image_accepted(self, grayscale_image: Path) -> None:
"""Grayscale (L-mode) input with C=1 is accepted without error."""
input_shape = [1, 224, 224, 1]
det_out = {"shape": [1, 10, 4], "name": "serving_default_dets:0", "index": 1}
label_out = {"shape": [1, 10, 82], "name": "serving_default_labels:0", "index": 2}
interp = _make_interp(input_shape=input_shape, out_dets=[det_out, label_out])
dets, _ = _run_inference(interp, grayscale_image)
assert isinstance(dets, sv.Detections)
def test_output_lookup_by_name_robust_to_ordering(self, rgb_image: Path) -> None:
"""Swapping dets/labels order in get_output_details returns same detections."""
logits = _make_logits(high_conf_idx=0)
boxes = _make_boxes()
# Canonical order: dets first, labels second
interp_normal = _make_interp(boxes=boxes, logits=logits)
dets_normal, _ = _run_inference(interp_normal, rgb_image, threshold=0.3)
# Swapped order: labels first, dets second
det_out_swapped = {"shape": [1, 10, 4], "name": "serving_default_dets:0", "index": 1}
label_out_swapped = {"shape": [1, 10, 82], "name": "serving_default_labels:0", "index": 2}
interp_swapped = _make_interp(
out_dets=[label_out_swapped, det_out_swapped],
boxes=boxes,
logits=logits,
)
dets_swapped, _ = _run_inference(interp_swapped, rgb_image, threshold=0.3)
assert len(dets_normal) == len(dets_swapped)
def test_raises_for_non_float32_input_dtype(self, rgb_image: Path) -> None:
"""ValueError raised when model input dtype is not float32."""
interp = mock.MagicMock()
interp.get_input_details.return_value = [{"shape": _INPUT_SHAPE, "index": 0, "dtype": np.uint8}]
interp.get_output_details.return_value = [_DET_OUTPUT, _LABEL_OUTPUT]
with pytest.raises(ValueError, match="float32"):
_run_inference(interp, rgb_image)
# ---------------------------------------------------------------------------
# TestSigmoidScoring
# ---------------------------------------------------------------------------
class TestSigmoidScoring:
"""Tests for per-class sigmoid scoring introduced in _run_inference."""
@pytest.fixture()
def rgb_image(self, tmp_path: Path) -> Path:
"""Write a small RGB JPEG to a temp file and return its path."""
p = tmp_path / "image.jpg"
_save_rgb_image(p)
return p
def test_high_logit_yields_confidence_near_one(self, rgb_image: Path) -> None:
"""Logit of 10.0 produces sigmoid ≈ 0.9999; confidence[0] > 0.99."""
logits = _make_logits(high_conf_idx=0) # logits[0, 0, 0] = 10.0
interp = _make_interp(logits=logits)
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert dets.confidence[0] > 0.99
def test_low_logit_filtered_at_threshold(self, rgb_image: Path) -> None:
"""Logit of -10.0 produces sigmoid ≈ 0.0001; detection filtered at threshold=0.3."""
logits = np.full((1, 10, 82), -10.0, dtype=np.float32)
interp = _make_interp(logits=logits)
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert len(dets) == 0
def test_multiclass_class_id_is_argmax_of_logits(self, rgb_image: Path) -> None:
"""Argmax of sigmoid equals argmax of logits; query with [5,2,1,...] gets class_id==0."""
# Shape (1, 10, 82): first query has logits [5, 2, 1, 0, ...], rest are -100
logits = np.full((1, 10, 82), -100.0, dtype=np.float32)
logits[0, 0, 0] = 5.0
logits[0, 0, 1] = 2.0
logits[0, 0, 2] = 1.0
interp = _make_interp(logits=logits)
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
# argmax of sigmoid == argmax of logits because sigmoid is monotone increasing
assert dets.class_id[0] == 0
# ---------------------------------------------------------------------------
# TestShapeBasedOutputFallback
# ---------------------------------------------------------------------------
# Generic output detail dicts used across shape-based fallback tests.
# Indices mirror the canonical ones so _make_interp's _get_tensor dispatch works.
_GENERIC_DET_OUTPUT = {"shape": [1, 10, 4], "name": "Identity_0", "index": 1}
_GENERIC_LABEL_OUTPUT = {"shape": [1, 10, 82], "name": "Identity_1", "index": 2}
class TestShapeBasedOutputFallback:
"""Tests for the shape-based output matching fallback in _run_inference."""
@pytest.fixture()
def rgb_image(self, tmp_path: Path) -> Path:
"""Write a small RGB JPEG to a temp file and return its path."""
p = tmp_path / "image.jpg"
_save_rgb_image(p)
return p
def test_unambiguous_shapes_inferred_correctly(self, rgb_image: Path) -> None:
"""Generic names with shapes [1,10,4] and [1,10,82] resolve without error."""
interp = _make_interp(
out_dets=[_GENERIC_DET_OUTPUT, _GENERIC_LABEL_OUTPUT],
logits=_make_logits(high_conf_idx=0),
)
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert isinstance(dets, sv.Detections)
assert len(dets) >= 1
def test_ambiguous_shapes_two_outputs_positional_fallback(self, rgb_image: Path) -> None:
"""When both outputs have last-dim==4 (num_classes==3) and there are exactly 2, positional fallback is used."""
# num_classes=3 → logits shape last-dim==4; boxes last-dim==4 → ambiguous
# Positional order: index 0 = boxes (Identity_0, tensor index 1), index 1 = logits (Identity_1, tensor index 2)
ambiguous_dets = {"shape": [1, 10, 4], "name": "Identity_0", "index": 1}
ambiguous_labels = {"shape": [1, 10, 4], "name": "Identity_1", "index": 2}
# Build logits of shape (1, 10, 4) so last col is dropped → (10, 3) per-class
logits_ambiguous = np.full((1, 10, 4), -10.0, dtype=np.float32)
logits_ambiguous[0, 0, 0] = 10.0 # first query, first class → high confidence
interp = _make_interp(
out_dets=[ambiguous_dets, ambiguous_labels],
logits=logits_ambiguous,
)
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert isinstance(dets, sv.Detections)
assert len(dets) >= 1
def test_three_outputs_all_dim4_raises_value_error(self, rgb_image: Path) -> None:
"""3 outputs all with last-dim==4 and no name match raises ValueError with expected message."""
# Need a third tensor index; extend _get_tensor via a custom mock
third_output = {"shape": [1, 10, 4], "name": "Identity_2", "index": 3}
boxes = _make_boxes()
logits = _make_logits()
def _get_tensor(index: int) -> np.ndarray:
if index == 1:
return boxes
if index in (2, 3):
return logits
raise ValueError(f"Unknown tensor index: {index}")
interp = mock.MagicMock()
interp.get_input_details.return_value = [{"shape": _INPUT_SHAPE, "index": 0, "dtype": np.float32}]
interp.get_output_details.return_value = [
{"shape": [1, 10, 4], "name": "Identity_0", "index": 1},
{"shape": [1, 10, 4], "name": "Identity_1", "index": 2},
third_output,
]
interp.get_tensor.side_effect = _get_tensor
with pytest.raises(ValueError, match="Shape-based TFLite output matching failed"):
_run_inference(interp, rgb_image, threshold=0.3)
def test_three_outputs_with_rank4_masks_resolves_correctly(self, rgb_image: Path) -> None:
"""3-output segmentation export (boxes/logits/masks) with generic names resolves without error.
Ensures the shape fallback ignores the rank-4 masks tensor and correctly identifies boxes [1,Q,4] and logits
[1,Q,C+1] as rank-3 candidates.
"""
boxes = _make_boxes()
logits = _make_logits(high_conf_idx=0)
masks = np.zeros((1, 10, 28, 28), dtype=np.float32)
def _get_tensor(index: int) -> np.ndarray:
if index == 1:
return boxes
if index == 2:
return logits
if index == 3:
return masks
raise ValueError(f"Unknown tensor index: {index}")
interp = mock.MagicMock()
interp.get_input_details.return_value = [{"shape": _INPUT_SHAPE, "index": 0, "dtype": np.float32}]
interp.get_output_details.return_value = [
{"shape": [1, 10, 4], "name": "Identity_0", "index": 1},
{"shape": [1, 10, 82], "name": "Identity_1", "index": 2},
{"shape": [1, 10, 28, 28], "name": "Identity_2", "index": 3},
]
interp.get_tensor.side_effect = _get_tensor
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert isinstance(dets, sv.Detections)
assert len(dets) >= 1
# ---------------------------------------------------------------------------
# TestMaskDecoding
# ---------------------------------------------------------------------------
class TestMaskDecoding:
"""Tests for ``_decode_masks()`` and mask decoding in ``_run_inference()``."""
@pytest.fixture()
def rgb_image(self, tmp_path: Path) -> Path:
"""Write a small RGB JPEG to a temp file and return its path."""
p = tmp_path / "image.jpg"
_save_rgb_image(p)
return p
def test_decode_masks_shape_and_dtype(self) -> None:
"""Output shape is (K, height, width) from out_size=(width, height); dtype is bool."""
out = _decode_masks(np.zeros((3, 10, 10), dtype=np.float32), (40, 20))
assert out.shape == (3, 20, 40)
assert out.dtype == bool
def test_decode_masks_thresholds_at_zero(self) -> None:
"""Positive logits decode to True, negative logits to False."""
logits = np.stack(
[
np.full((8, 8), 5.0, dtype=np.float32),
np.full((8, 8), -5.0, dtype=np.float32),
]
)
out = _decode_masks(logits, (16, 16))
assert out[0].all()
assert not out[1].any()
def test_decode_masks_empty_input(self) -> None:
"""Zero masks in yields a (0, height, width) array, not an error."""
out = _decode_masks(np.zeros((0, 10, 10), dtype=np.float32), (32, 32))
assert out.shape == (0, 32, 32)
def test_run_inference_decodes_masks_for_seg_model(self, rgb_image: Path) -> None:
"""A 3-output segmentation export populates Detections.mask at image size."""
boxes = _make_boxes()
logits = _make_logits(high_conf_idx=0)
masks = np.full((1, 10, 28, 28), -10.0, dtype=np.float32)
masks[0, 0] = 10.0 # query 0 (the kept detection) gets an all-positive mask
def _get_tensor(index: int) -> np.ndarray:
return {1: boxes, 2: logits, 3: masks}[index]
interp = mock.MagicMock()
interp.get_input_details.return_value = [{"shape": _INPUT_SHAPE, "index": 0, "dtype": np.float32}]
interp.get_output_details.return_value = [
{"shape": [1, 10, 4], "name": "Identity_0", "index": 1},
{"shape": [1, 10, 82], "name": "Identity_1", "index": 2},
{"shape": [1, 10, 28, 28], "name": "Identity_2", "index": 3},
]
interp.get_tensor.side_effect = _get_tensor
dets, img = _run_inference(interp, rgb_image, threshold=0.3)
assert dets.mask is not None
assert dets.mask.shape == (len(dets), img.height, img.width)
assert dets.mask.dtype == bool
assert dets.mask[0].all() # query 0's all-positive logits decode to a full mask
def test_run_inference_no_mask_for_detection_model(self, rgb_image: Path) -> None:
"""A 2-output detection export leaves Detections.mask as None."""
interp = _make_interp(logits=_make_logits(high_conf_idx=0))
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert dets.mask is None
def test_run_inference_name_based_mask_detection(self, rgb_image: Path) -> None:
"""Output named 'masks:0' exercises the name-based path and sets Detections.mask."""
boxes = _make_boxes()
logits = _make_logits(high_conf_idx=0)
masks = np.full((1, 10, 28, 28), 10.0, dtype=np.float32)
def _get_tensor(index: int) -> np.ndarray:
return {1: boxes, 2: logits, 3: masks}[index]
interp = mock.MagicMock()
interp.get_input_details.return_value = [{"shape": _INPUT_SHAPE, "index": 0, "dtype": np.float32}]
interp.get_output_details.return_value = [
{"shape": [1, 10, 4], "name": "serving_default_dets:0", "index": 1},
{"shape": [1, 10, 82], "name": "serving_default_labels:0", "index": 2},
{"shape": [1, 10, 28, 28], "name": "serving_default_masks:0", "index": 3},
]
interp.get_tensor.side_effect = _get_tensor
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert dets.mask is not None
def test_run_inference_seg_model_no_detections_returns_none_mask(self, rgb_image: Path) -> None:
"""Seg model with all scores below threshold returns mask=None (keep.any() is False)."""
boxes = _make_boxes()
logits = _make_logits(high_conf_idx=None) # all scores near zero, below threshold
masks = np.full((1, 10, 28, 28), 10.0, dtype=np.float32)
def _get_tensor(index: int) -> np.ndarray:
return {1: boxes, 2: logits, 3: masks}[index]
interp = mock.MagicMock()
interp.get_input_details.return_value = [{"shape": _INPUT_SHAPE, "index": 0, "dtype": np.float32}]
interp.get_output_details.return_value = [
{"shape": [1, 10, 4], "name": "Identity_0", "index": 1},
{"shape": [1, 10, 82], "name": "Identity_1", "index": 2},
{"shape": [1, 10, 28, 28], "name": "Identity_2", "index": 3},
]
interp.get_tensor.side_effect = _get_tensor
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert len(dets) == 0
assert dets.mask is None
def test_decode_masks_raises_on_wrong_rank(self) -> None:
"""_decode_masks raises ValueError when input is not rank-3."""
with pytest.raises(ValueError, match="rank-3"):
_decode_masks(np.zeros((10, 28, 28, 1), dtype=np.float32), (56, 56))
def test_decode_masks_exact_zero_logit_decodes_to_false(self) -> None:
"""Logit exactly 0.0 is not > 0.0 and decodes to False (strict threshold)."""
zero_logits = np.zeros((1, 8, 8), dtype=np.float32)
out = _decode_masks(zero_logits, (16, 16))
assert not out.any()
def test_decode_masks_non_square_logit_input(self) -> None:
"""Non-square logit map (K, Hm, Wm) with Hm != Wm resizes to the correct output shape."""
logits = np.full((3, 7, 14), 5.0, dtype=np.float32)
out = _decode_masks(logits, (56, 28)) # out_size=(width=56, height=28)
assert out.shape == (3, 28, 56)
assert out.all() # all-positive logits → all True
def test_decode_masks_parity_positive_negative_regions(self) -> None:
"""Positive/negative logit regions map correctly after bilinear upsample + threshold.
Uses high-magnitude logits (±10) so no ambiguity near the boundary; verifies the core _decode_masks contract
matches the >0 PostProcess.forward equivalent.
"""
logits = np.full((1, 14, 14), -10.0, dtype=np.float32)
logits[0, :7, :] = 10.0 # top half strongly positive, bottom half strongly negative
out = _decode_masks(logits, (28, 28))
# Interior rows well away from the half-way boundary
assert out[0, 1:6, :].all() # top rows → all True
assert not out[0, 15:27, :].any() # bottom rows → all False
# ---------------------------------------------------------------------------
# TestBilinearResizeHalfPixel
# ---------------------------------------------------------------------------
class TestBilinearResizeHalfPixel:
"""Tests for ``_bilinear_resize_half_pixel()``."""
def test_output_shape(self) -> None:
"""Output shape is (K, out_h, out_w)."""
src = np.ones((3, 8, 8), dtype=np.float32)
out = _bilinear_resize_half_pixel(src, 16, 16)
assert out.shape == (3, 16, 16)
def test_output_dtype_is_float32(self) -> None:
"""Output dtype is float32 regardless of input magnitude."""
src = np.ones((1, 4, 4), dtype=np.float32)
out = _bilinear_resize_half_pixel(src, 8, 8)
assert out.dtype == np.float32
def test_identity_when_no_resize(self) -> None:
"""Output equals input when target dimensions match source dimensions."""
rng = np.random.default_rng(0)
src = rng.random((2, 8, 8)).astype(np.float32)
out = _bilinear_resize_half_pixel(src, 8, 8)
np.testing.assert_allclose(out, src, atol=1e-6)
@pytest.mark.parametrize(
("src_shape", "out_h", "out_w"),
[
pytest.param((1, 4, 4), 8, 8, id="upsample_square"),
pytest.param((3, 7, 5), 14, 10, id="upsample_nonsquare"),
pytest.param((2, 8, 8), 4, 4, id="downsample"),
pytest.param((1, 1, 1), 3, 3, id="degenerate_1x1"),
],
)
def test_parity_with_torch_interpolate(self, src_shape: tuple[int, int, int], out_h: int, out_w: int) -> None:
"""Output matches F.interpolate(mode='bilinear', align_corners=False) to within 1e-5."""
torch = pytest.importorskip("torch")
import torch.nn.functional as F # noqa: N812
rng = np.random.default_rng(42)
src = rng.random(src_shape).astype(np.float32)
result = _bilinear_resize_half_pixel(src, out_h, out_w)
t = torch.from_numpy(src).unsqueeze(0)
with torch.no_grad():
ref = F.interpolate(t, size=(out_h, out_w), mode="bilinear", align_corners=False)
ref_np = ref.squeeze(0).numpy()
np.testing.assert_allclose(result, ref_np, atol=1e-5)
# ---------------------------------------------------------------------------
# TestPreprocessImage
# ---------------------------------------------------------------------------
class TestPreprocessImage:
"""Tests for ``_preprocess_image()``."""
def test_output_shape_rgb(self) -> None:
"""RGB image returns float32 array of shape (1, H, W, 3)."""
pil_img = PILImage.new("RGB", (100, 80))
out = _preprocess_image(pil_img, (64, 64))
assert out.shape == (1, 64, 64, 3)
assert out.dtype == np.float32
def test_output_shape_grayscale(self) -> None:
"""Grayscale image with channels=1 returns float32 array of shape (1, H, W, 1)."""
pil_img = PILImage.new("L", (100, 80))
out = _preprocess_image(pil_img, (64, 64), channels=1)
assert out.shape == (1, 64, 64, 1)
assert out.dtype == np.float32
def test_output_values_are_normalized(self) -> None:
"""ImageNet normalization shifts black-pixel output below -1.0."""
pil_img = PILImage.new("RGB", (32, 32), color=(0, 0, 0))
out = _preprocess_image(pil_img, (32, 32))
# pixel 0 → 0.0 → (0.0 - 0.485) / 0.229 ≈ -2.12
assert out.min() < -1.0
def test_pil_fallback_when_torch_unavailable(self) -> None:
"""PIL path is used when torch is masked from sys.modules."""
pil_img = PILImage.new("RGB", (100, 80))
with mock.patch.dict(
sys.modules,
{
"torch": None,
"torchvision": None,
"torchvision.transforms": None,
"torchvision.transforms.functional": None,
},
):
out = _preprocess_image(pil_img, (64, 64))
assert out.shape == (1, 64, 64, 3)
assert out.dtype == np.float32
@@ -0,0 +1,261 @@
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Preprocessing parity tests: ``_run_inference`` must produce essentially the same input tensor as ``RFDETR.predict``
for the same source image, otherwise the TFLite-exported model is fed inputs the PyTorch graph never saw and detections
drift.
History: an earlier version of ``_run_inference`` called ``PIL.Image.resize`` without a filter argument, picking up
PIL's default (BICUBIC since Pillow 9.1.0). PyTorch's predict() path uses torchvision ``F.resize`` (BILINEAR). The
mismatch caused IoU drift up to 0.36 on detail-rich images and a 2-class-mismatch FP16 disaster on the ``dog`` test
image. This test exists to keep ``_preprocess_image`` locked to BILINEAR -- any regression that re-introduces BICUBIC or
otherwise shifts the resize filter will surface here.
"""
from __future__ import annotations
import sys
import numpy as np
import pytest
import torchvision.transforms.functional as F # noqa: N812
from PIL import Image as PILImage
from rfdetr.export._tflite.inference import _bilinear_resize_half_pixel, _preprocess_image
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD = [0.229, 0.224, 0.225]
# Bound for max abs diff in normalised space between the PyTorch and TFLite preprocessing pipelines.
# With torchvision available (which is always the case in this repo's CI -- it's a hard rfdetr
# dependency) _preprocess_image runs the same torchvision call PyTorch's predict() uses, so the
# tensors are bit-exact. The 0.05 bound is generous so the torch-free fallback path (which uses
# PIL.BILINEAR and shows ~0.016 max diff) also passes; the BICUBIC regression would push max diff
# to ~0.5, well above the bound.
MAX_ABS_DIFF_BOUND = 0.05
# When torchvision is available the inference path matches torchvision-resize byte-for-byte, so
# the diff is effectively zero modulo floating-point noise.
BIT_EXACT_BOUND = 1e-5
def _pytorch_preprocess(pil_img: PILImage.Image, hw: tuple[int, int]) -> np.ndarray:
"""Mirror of the PyTorch predict() preprocessing: to_tensor -> resize -> normalize."""
img = F.to_tensor(pil_img)
img = F.resize(img, list(hw))
img = F.normalize(img, IMAGENET_MEAN, IMAGENET_STD)
return img.unsqueeze(0).numpy()
def _tflite_preprocess_to_nchw(pil_img: PILImage.Image, hw: tuple[int, int]) -> np.ndarray:
"""Call ``_preprocess_image`` and convert NHWC -> NCHW for apples-to-apples comparison."""
nhwc = _preprocess_image(pil_img, hw, channels=3)
return nhwc.transpose(0, 3, 1, 2)
def _make_synthetic_rgb(seed: int, size: tuple[int, int]) -> PILImage.Image:
"""Deterministic synthetic RGB image with structure (not pure noise) so resize filtering matters."""
rng = np.random.default_rng(seed)
height, width = size
base = rng.integers(0, 256, size=(height // 8, width // 8, 3), dtype=np.uint8)
pil_small = PILImage.fromarray(base, mode="RGB")
return pil_small.resize((width, height), getattr(PILImage, "Resampling", PILImage).NEAREST)
class TestPreprocessingParity:
"""``_preprocess_image`` must match PyTorch's predict() preprocessing within MAX_ABS_DIFF_BOUND.
Three shapes cover the common downscale ratios produced by RFDETR exports:
- 1280x720 -> 384x384 (nano default): heavy downscale, the case that surfaced the BICUBIC bug
- 800x600 -> 384x384: moderate downscale, mixed aspect ratio
- 384x384 -> 384x384: identity resize -- only normalisation differs (rounding noise only)
"""
@pytest.mark.parametrize(
("src_size", "tgt_size", "seed"),
[
pytest.param((1280, 720), (384, 384), 0, id="1280x720_to_384x384"),
pytest.param((800, 600), (384, 384), 1, id="800x600_to_384x384"),
pytest.param((384, 384), (384, 384), 2, id="identity_384x384"),
],
)
def test_matches_pytorch_predict_preprocessing(
self, src_size: tuple[int, int], tgt_size: tuple[int, int], seed: int
) -> None:
pil = _make_synthetic_rgb(seed, (src_size[1], src_size[0])) # _make takes (H, W)
pt = _pytorch_preprocess(pil, tgt_size)
tf = _tflite_preprocess_to_nchw(pil, tgt_size)
assert pt.shape == tf.shape, f"shape mismatch: PT {pt.shape} vs TF {tf.shape}"
max_diff = float(np.abs(pt - tf).max())
# torchvision is a hard rfdetr dependency, so in this test environment _preprocess_image
# uses the torchvision path and matches PyTorch byte-for-byte. The torch-free fallback is
# exercised separately by test_torch_free_fallback_still_close.
assert max_diff < BIT_EXACT_BOUND, (
f"PyTorch vs TFLite preprocessing diverged: max|diff|={max_diff:.6f} exceeds "
f"{BIT_EXACT_BOUND}. With torchvision available, _preprocess_image should be using "
f"torchvision.transforms.functional.resize and the diff should be effectively zero. "
f"If this fires, check that the torch/torchvision import path inside _preprocess_image "
f"hasn't been broken."
)
def test_grayscale_channel_handling(self) -> None:
"""Grayscale (channels=1) path must produce shape (1, H, W, 1)."""
rng = np.random.default_rng(3)
height, width = 256, 256
pil = PILImage.fromarray(rng.integers(0, 256, size=(height, width), dtype=np.uint8), mode="L")
tf = _preprocess_image(pil, (128, 128), channels=1)
assert tf.shape == (1, 128, 128, 1), f"unexpected shape: {tf.shape}"
assert tf.dtype == np.float32
def test_returns_nhwc_float32(self) -> None:
"""``_preprocess_image`` returns NHWC float32 with a leading batch dim."""
pil = _make_synthetic_rgb(seed=7, size=(64, 64))
tf = _preprocess_image(pil, (32, 32), channels=3)
assert tf.shape == (1, 32, 32, 3)
assert tf.dtype == np.float32
def test_normalisation_uses_imagenet_stats(self) -> None:
"""A mid-gray (128) image should land near zero on all channels after normalisation."""
gray = np.full((64, 64, 3), 128, dtype=np.uint8)
pil = PILImage.fromarray(gray, mode="RGB")
tf = _preprocess_image(pil, (32, 32), channels=3)
# 128/255 ~= 0.502; expected normalised values per channel:
# (0.502 - 0.485) / 0.229 ~= 0.074
# (0.502 - 0.456) / 0.224 ~= 0.205
# (0.502 - 0.406) / 0.225 ~= 0.426
expected = np.array([(128 / 255.0 - IMAGENET_MEAN[c]) / IMAGENET_STD[c] for c in range(3)], dtype=np.float32)
per_channel_mean = tf[0].mean(axis=(0, 1))
np.testing.assert_allclose(per_channel_mean, expected, atol=1e-3)
def test_torch_free_fallback_still_close(self) -> None:
"""Simulate the torch-free environment by masking torch imports; assert the PIL fallback still stays within the
looser MAX_ABS_DIFF_BOUND.
This documents the gap users on edge deployments without torch installed will see (versus the bit-exact
torchvision path).
"""
from unittest import mock
pil = _make_synthetic_rgb(seed=11, size=(720, 1280))
tgt = (384, 384)
pt = _pytorch_preprocess(pil, tgt)
# Hide torch from _preprocess_image's lazy import, forcing the PIL fallback.
with mock.patch.dict(sys.modules, {"torch": None}):
tf = _tflite_preprocess_to_nchw(pil, tgt)
max_diff = float(np.abs(pt - tf).max())
assert max_diff < MAX_ABS_DIFF_BOUND, (
f"Torch-free PIL fallback diverged: max|diff|={max_diff:.4f} > {MAX_ABS_DIFF_BOUND}. "
"The fallback uses PIL.BILINEAR which should keep diff ~0.016; a regression to "
"BICUBIC would push it ~30x larger."
)
class TestPreprocessingFilterRegression:
"""Direct comparison of BILINEAR vs BICUBIC.
Asserts the current code stays on BILINEAR by showing BICUBIC would produce a much larger divergence.
"""
def test_bicubic_would_be_much_worse(self) -> None:
"""If a future change reverts to BICUBIC default, this confirms how much worse it gets."""
pil = _make_synthetic_rgb(seed=42, size=(720, 1280))
tgt = (384, 384)
pt = _pytorch_preprocess(pil, tgt)
tf_current = _tflite_preprocess_to_nchw(pil, tgt)
# Simulate the regression: PIL default (BICUBIC since 9.1.0).
mean = np.array(IMAGENET_MEAN, dtype=np.float32)
std = np.array(IMAGENET_STD, dtype=np.float32)
height, width = tgt
arr_bicubic = np.array(pil.convert("RGB").resize((width, height)), dtype=np.float32) / 255.0
tf_bicubic = ((arr_bicubic - mean) / std)[np.newaxis].transpose(0, 3, 1, 2)
max_diff_current = float(np.abs(pt - tf_current).max())
max_diff_bicubic = float(np.abs(pt - tf_bicubic).max())
# The BILINEAR fix must be at least 5x closer to PyTorch than the BICUBIC regression.
# In practice it's ~30x closer; the 5x floor is forgiving of pillow / numpy drift.
assert max_diff_current * 5 < max_diff_bicubic, (
f"_preprocess_image is too close to BICUBIC behaviour: "
f"current max|diff|={max_diff_current:.4f}, BICUBIC max|diff|={max_diff_bicubic:.4f}. "
f"Check that _PIL_BILINEAR is being passed to .resize()."
)
class TestBilinearResizeHalfPixelParity:
"""``_bilinear_resize_half_pixel`` is the torch-free fallback used by ``_decode_masks``.
It must match ``torch.nn.functional.interpolate(..., mode="bilinear", align_corners=False)``
-- the same call ``PostProcess.forward`` uses -- byte-for-byte modulo float noise. Sharp-edge
inputs are the worst case: even a sub-pixel shift in the half-pixel convention flips boundary
pixels and tanks mask IoU.
"""
@staticmethod
def _torch_interpolate(src: np.ndarray, out_hw: tuple[int, int]) -> np.ndarray:
"""Reference implementation: ``F.interpolate`` with ``align_corners=False``."""
import torch
import torch.nn.functional as TF # noqa: N812
with torch.no_grad():
t = torch.from_numpy(src.astype(np.float32)).unsqueeze(0)
out = TF.interpolate(t, size=out_hw, mode="bilinear", align_corners=False)
return out.squeeze(0).cpu().numpy()
@pytest.mark.parametrize(
("src_hw", "out_hw"),
[
pytest.param((28, 28), (384, 384), id="upsample_28_to_384"),
pytest.param((56, 56), (256, 256), id="upsample_56_to_256"),
pytest.param((100, 100), (100, 100), id="identity_100"),
pytest.param((100, 100), (50, 50), id="downsample_100_to_50"),
pytest.param((40, 60), (200, 400), id="non_square_upsample"),
],
)
def test_matches_torch_interpolate_on_random_logits(self, src_hw: tuple[int, int], out_hw: tuple[int, int]) -> None:
"""Random logits over a small batch must resize identically to ``F.interpolate``."""
rng = np.random.default_rng(0)
src = rng.standard_normal((3, *src_hw)).astype(np.float32) * 4.0
ours = _bilinear_resize_half_pixel(src, out_hw[0], out_hw[1])
ref = self._torch_interpolate(src, out_hw)
max_diff = float(np.abs(ours - ref).max())
# 1e-4 absorbs the float32 op-order noise that accumulates on large upsample ratios
# (mine: split bilinear sums in pure numpy; torch: fused kernel). Half-pixel-convention
# drift would push this several orders of magnitude higher.
assert max_diff < 1e-4, (
f"_bilinear_resize_half_pixel diverged from F.interpolate(align_corners=False): "
f"max|diff|={max_diff:.2e} on shape {src_hw} -> {out_hw}. "
"Half-pixel convention drift would surface here."
)
def test_sharp_edge_mask_matches_torch(self) -> None:
"""A mask with a sharp left/right boundary is the regression-prone case.
This is the shape ``_decode_masks`` actually consumes (logits with a zero-crossing). A half-pixel shift would
flip the boundary column and is exactly what the original PIL.BILINEAR path got wrong.
"""
src = np.full((1, 28, 28), -10.0, dtype=np.float32)
src[0, :, 14:] = 10.0 # sharp vertical edge at column 14
out_hw = (224, 224)
ours = _bilinear_resize_half_pixel(src, out_hw[0], out_hw[1])
ref = self._torch_interpolate(src, out_hw)
max_diff = float(np.abs(ours - ref).max())
assert max_diff < 1e-4, (
f"Sharp-edge resize diverged from F.interpolate: max|diff|={max_diff:.2e}. "
"This is the case that previously dropped mask IoU below 0.6 with PIL.BILINEAR."
)
# Also assert the thresholded output matches: this is what _decode_masks actually returns.
assert np.array_equal(ours > 0, ref > 0), (
"Boolean mask after thresholding diverged from F.interpolate. Even a single column of "
"flipped pixels would show up here -- the exact failure mode the original PR fixes."
)