chore: import upstream snapshot with attribution
CPU tests Workflow / Testing (ubuntu-latest, 3.12) (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.13) (push) Failing after 0s
Mypy Type Check / Type Check (push) Failing after 0s
Docs/Test WorkFlow / Test docs build (push) Failing after 1s
PR Conflict Labeler / labeling (push) Failing after 1s
Dependency resolution / Resolve [tflite] extra — Python 3.12 (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.10) (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.13) (push) Failing after 1s
CPU tests Workflow / build-pkg (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.10) (push) Failing after 0s
CPU tests Workflow / Testing (ubuntu-latest, 3.11) (push) Failing after 0s
Smoke Tests / try-all-models (macos-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (macos-latest, 3.13) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / testing-guardian (push) Has been cancelled
GPU tests Workflow / Testing (push) Has been cancelled
CPU tests Workflow / Testing (ubuntu-latest, 3.12) (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.13) (push) Failing after 0s
Mypy Type Check / Type Check (push) Failing after 0s
Docs/Test WorkFlow / Test docs build (push) Failing after 1s
PR Conflict Labeler / labeling (push) Failing after 1s
Dependency resolution / Resolve [tflite] extra — Python 3.12 (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.10) (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.13) (push) Failing after 1s
CPU tests Workflow / build-pkg (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.10) (push) Failing after 0s
CPU tests Workflow / Testing (ubuntu-latest, 3.11) (push) Failing after 0s
Smoke Tests / try-all-models (macos-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (macos-latest, 3.13) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / testing-guardian (push) Has been cancelled
GPU tests Workflow / Testing (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# ------------------------------------------------------------------------
|
||||
# RF-DETR
|
||||
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
||||
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
@@ -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 0–1 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 0–1
|
||||
|
||||
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."
|
||||
)
|
||||
Reference in New Issue
Block a user