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

1111 lines
42 KiB
Python

# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for the ONNX → TFLite export pipeline.
Tests cover:
* ``export_tflite()`` — the main conversion function (mocked ``onnx2tf``)
* ``_check_onnx2tf_available()`` — import-based availability check
* ``_numpy_allow_pickle()`` — NumPy monkey-patch context manager
* ``_patch_validation_download()`` — validation download redirect
* ``_get_onnx_input_info()`` — ONNX model input metadata reader
* ``_prepare_calibration_data()`` — calibration data preparation
* ``format="tflite"`` parameter wiring through ``RFDETR.export()``
"""
from __future__ import annotations
import sys
import types
from pathlib import Path
from typing import Any, Generator
from unittest import mock
import numpy as np
import pytest
from rfdetr.export._tflite import _IS_ONNX2TF_AVAILABLE
from rfdetr.export._tflite.converter import (
_DEFAULT_CALIB_SAMPLES,
_DEFAULT_DIR_CALIB_SAMPLES,
_IMAGE_EXTENSIONS,
_NUMPY_LOAD_PATCH_LOCK,
_VALID_QUANTIZATIONS,
_check_onnx2tf_available,
_get_onnx_input_info,
_load_calibration_images,
_numpy_allow_pickle,
_patch_validation_download,
_prepare_calibration_data,
export_tflite,
)
onnx2tf_available = pytest.mark.skipif(not _IS_ONNX2TF_AVAILABLE, reason="onnx2tf not installed")
# ---------------------------------------------------------------------------
# Helpers — fake onnx2tf module injected into sys.modules
# ---------------------------------------------------------------------------
class _FakeOnnx2tfModule:
"""Namespace that mimics ``onnx2tf`` for testing."""
def __init__(self) -> None:
self.convert = mock.MagicMock()
_ONNX2TF_KEYS = ("onnx2tf", "onnx2tf.onnx2tf", "onnx2tf.utils", "onnx2tf.utils.common_functions")
def _install_fake_onnx2tf() -> tuple[_FakeOnnx2tfModule, mock.MagicMock, dict[str, object]]:
"""Insert a fake ``onnx2tf`` package into ``sys.modules``.
Saves any pre-existing real modules under the same keys so they can be restored by ``_remove_fake_onnx2tf``
(Copilot: do not silently clobber real modules that a prior test may have imported).
Returns:
Tuple of (fake_module, convert_mock, saved_originals).
"""
# Snapshot originals before overwriting (None means the key was absent).
saved: dict[str, object] = {k: sys.modules.get(k) for k in _ONNX2TF_KEYS}
fake = _FakeOnnx2tfModule()
pkg = types.ModuleType("onnx2tf")
pkg.convert = fake.convert # type: ignore[attr-defined]
pkg.__version__ = "2.4.0" # type: ignore[attr-defined]
# onnx2tf.onnx2tf — force-imported by export_tflite() before patching
inner_mod = types.ModuleType("onnx2tf.onnx2tf")
inner_mod.download_test_image_data = mock.MagicMock( # type: ignore[attr-defined]
return_value=np.zeros((20, 128, 128, 3), dtype=np.float32),
)
# onnx2tf.utils and onnx2tf.utils.common_functions
utils_mod = types.ModuleType("onnx2tf.utils")
cf_mod = types.ModuleType("onnx2tf.utils.common_functions")
cf_mod.download_test_image_data = mock.MagicMock( # type: ignore[attr-defined]
return_value=np.zeros((20, 128, 128, 3), dtype=np.float32),
)
# Wire up module hierarchy
pkg.onnx2tf = inner_mod # type: ignore[attr-defined]
pkg.utils = utils_mod # type: ignore[attr-defined]
utils_mod.common_functions = cf_mod # type: ignore[attr-defined]
sys.modules["onnx2tf"] = pkg
sys.modules["onnx2tf.onnx2tf"] = inner_mod
sys.modules["onnx2tf.utils"] = utils_mod
sys.modules["onnx2tf.utils.common_functions"] = cf_mod
return fake, fake.convert, saved
def _remove_fake_onnx2tf(saved: dict[str, object] | None = None) -> None:
"""Remove fake ``onnx2tf`` entries from ``sys.modules`` and restore originals.
Args:
saved: Snapshot returned by ``_install_fake_onnx2tf``. If a key was
present before installation its original value is restored; if it was absent it is deleted. When *saved* is
``None`` all ``onnx2tf*`` keys are simply deleted (legacy behaviour).
"""
if saved is not None:
for key in _ONNX2TF_KEYS:
original = saved.get(key)
if original is None:
sys.modules.pop(key, None)
else:
sys.modules[key] = original # type: ignore[assignment]
else:
for key in list(sys.modules):
if key == "onnx2tf" or key.startswith("onnx2tf."):
del sys.modules[key]
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def fake_onnx2tf():
"""Provide a fake ``onnx2tf`` that records *convert()* calls.
Also patches ``_replace_gridsample_for_tflite`` to return the input path unchanged so tests that supply stub ONNX
bytes do not depend on ``onnx.load`` tolerating those bytes.
"""
fake, convert_mock, saved = _install_fake_onnx2tf()
with mock.patch(
"rfdetr.export._tflite.converter._replace_gridsample_for_tflite",
side_effect=lambda path, _dir: path,
):
yield fake, convert_mock
_remove_fake_onnx2tf(saved)
@pytest.fixture()
def onnx_model(tmp_path: Path) -> Path:
"""Create a dummy ``.onnx`` file."""
p = tmp_path / "model.onnx"
p.write_bytes(b"\x08\x06") # minimal bytes
return p
@pytest.fixture()
def mock_prepare_calib(tmp_path: Path) -> Generator:
"""Mock ``_prepare_calibration_data`` so dummy ONNX files work.
``export_tflite`` calls ``_prepare_calibration_data`` which calls ``_get_onnx_input_info`` (requiring a real ONNX
file). Since the ``onnx_model`` fixture writes only stub bytes, this mock prevents the ONNX parse from being
attempted.
"""
dummy_npy = tmp_path / "_rfdetr_calib_data.npy"
np.save(str(dummy_npy), np.zeros((1, 64, 64, 3), dtype=np.float32))
with mock.patch(
"rfdetr.export._tflite.converter._prepare_calibration_data",
return_value=dummy_npy,
) as m:
yield m
@pytest.fixture()
def tflite_output(tmp_path: Path, onnx_model: Path) -> Path:
"""Create expected TFLite output file so export_tflite finds it."""
out = tmp_path / "output"
out.mkdir()
(out / f"{onnx_model.stem}_float32.tflite").write_bytes(b"tflite")
return out
# ---------------------------------------------------------------------------
# TestExportTfliteConverter
# ---------------------------------------------------------------------------
@onnx2tf_available
class TestExportTfliteConverter:
"""Tests for ``export_tflite()``."""
def test_missing_onnx_raises_file_not_found(self, tmp_path: Path, fake_onnx2tf: Any) -> None:
with pytest.raises(FileNotFoundError, match="ONNX model not found"):
export_tflite(tmp_path / "nope.onnx", tmp_path / "out")
def test_invalid_quantization_raises_value_error(self, onnx_model: Path, tmp_path: Path, fake_onnx2tf: Any) -> None:
with pytest.raises(ValueError, match="Unsupported quantization"):
export_tflite(onnx_model, tmp_path / "out", quantization="q4")
@pytest.mark.parametrize(
"static_mode",
[
pytest.param("int8_static", id="int8_static"),
pytest.param("full_int8", id="full_int8"),
pytest.param("integer_quant", id="integer_quant"),
],
)
def test_static_int8_raises(self, onnx_model: Path, tmp_path: Path, fake_onnx2tf: Any, static_mode: str) -> None:
"""A static / full-integer INT8 request must raise a ValueError.
Static INT8 is intentionally unsupported; only dynamic-range 'int8' is offered.
"""
with pytest.raises(ValueError, match="[Ss]tatic / full-integer INT8 is not supported"):
export_tflite(onnx_model, tmp_path / "out", quantization=static_mode)
def test_default_quantization_calls_convert(
self,
onnx_model: Path,
tflite_output: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
_, convert_mock = fake_onnx2tf
result = export_tflite(onnx_model, tflite_output)
convert_mock.assert_called_once()
kwargs = convert_mock.call_args.kwargs
assert kwargs["input_onnx_file_path"] == str(onnx_model)
assert kwargs["output_folder_path"] == str(tflite_output)
assert kwargs["output_signaturedefs"] is True
assert kwargs["non_verbose"] is True
assert "output_integer_quantized_tflite" not in kwargs
assert result == tflite_output / "model_float32.tflite"
def test_custom_input_not_passed_to_convert(
self,
onnx_model: Path,
tflite_output: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
"""custom_input_op_name_np_data_path must NOT be passed to convert().
The onnx2tf custom_input code path triggers a tf.tile rank mismatch with DINOv2-style backbones when N > 1. We
rely on patching download_test_image_data() instead.
"""
_, convert_mock = fake_onnx2tf
export_tflite(onnx_model, tflite_output)
kwargs = convert_mock.call_args.kwargs
assert "custom_input_op_name_np_data_path" not in kwargs
def test_output_signaturedefs_always_enabled(
self,
onnx_model: Path,
tflite_output: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
"""output_signaturedefs must always be True.
Segmentation models produce ONNX node names with leading "/" characters that violate the TF saved_model naming
pattern. Enabling signature defs bypasses this restriction.
"""
_, convert_mock = fake_onnx2tf
export_tflite(onnx_model, tflite_output)
kwargs = convert_mock.call_args.kwargs
assert kwargs["output_signaturedefs"] is True
def test_tflite_backend_forced_to_tf_converter(
self,
onnx_model: Path,
tflite_output: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
"""tflite_backend must always be 'tf_converter' to avoid the TFLite TopK_V2 kernel check.
onnx2tf 2.x defaults to flatbuffer_direct, which trips a "k > internal dimension" error at AllocateTensors()
time on RF-DETR's encoder TopK node. tf_converter is forced unconditionally.
"""
_, convert_mock = fake_onnx2tf
export_tflite(onnx_model, tflite_output)
assert convert_mock.call_args.kwargs["tflite_backend"] == "tf_converter"
def test_replace_to_pseudo_operators_contains_erf_and_gelu(
self,
onnx_model: Path,
tflite_output: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
"""replace_to_pseudo_operators must include Erf and GeLU.
Without this, AllocateTensors() fails with "FlexErf failed to prepare" because the TFLite runtime lacks native
Erf / GeLU kernels.
"""
_, convert_mock = fake_onnx2tf
export_tflite(onnx_model, tflite_output)
pseudo_ops = convert_mock.call_args.kwargs.get("replace_to_pseudo_operators", [])
assert "Erf" in pseudo_ops
assert "GeLU" in pseudo_ops
def test_fp32_quantization_no_int8_flag(
self,
onnx_model: Path,
tflite_output: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
_, convert_mock = fake_onnx2tf
export_tflite(onnx_model, tflite_output, quantization="fp32")
assert "output_integer_quantized_tflite" not in convert_mock.call_args.kwargs
def test_fp16_quantization_no_int8_flag(
self,
onnx_model: Path,
tflite_output: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
_, convert_mock = fake_onnx2tf
export_tflite(onnx_model, tflite_output, quantization="fp16")
assert "output_integer_quantized_tflite" not in convert_mock.call_args.kwargs
def test_int8_quantization_produces_dynamic_range(
self,
onnx_model: Path,
tflite_output: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
"""Int8 export derives a dynamic-range model and avoids onnx2tf's -oiqt path.
onnx2tf's ``output_integer_quantized_tflite`` (-oiqt) only yields static quantization, which RF-DETR's
transformer activations do not survive. The converter instead builds dynamic-range INT8 from the SavedModel via
``_quantize_dynamic_range``, so the onnx2tf call must NOT carry the ``output_integer_quantized_tflite`` flag.
"""
_, convert_mock = fake_onnx2tf
dyn_path = tflite_output / "model_dynamic_range_quant.tflite"
with mock.patch(
"rfdetr.export._tflite.converter._quantize_dynamic_range",
return_value=dyn_path,
) as quant_mock:
result = export_tflite(onnx_model, tflite_output, quantization="int8")
assert "output_integer_quantized_tflite" not in convert_mock.call_args.kwargs
quant_mock.assert_called_once()
assert result == dyn_path
def test_verbosity_forwarded(
self,
onnx_model: Path,
tflite_output: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
_, convert_mock = fake_onnx2tf
export_tflite(onnx_model, tflite_output, verbosity="debug")
assert convert_mock.call_args.kwargs["verbosity"] == "debug"
def test_convert_failure_raises_runtime_error(
self,
onnx_model: Path,
tmp_path: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
_, convert_mock = fake_onnx2tf
convert_mock.side_effect = RuntimeError("boom")
with pytest.raises(RuntimeError, match="onnx2tf conversion failed"):
export_tflite(onnx_model, tmp_path / "out")
def test_fallback_when_primary_tflite_missing(
self,
onnx_model: Path,
tmp_path: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
"""Fallback returns a stem-scoped file when the primary *_float32.tflite is absent."""
out = tmp_path / "out"
out.mkdir()
# Scoped fallback: must match {stem}_*.tflite (stem == "model" here).
(out / "model_float16.tflite").write_bytes(b"fb")
result = export_tflite(onnx_model, out)
assert result.name == "model_float16.tflite"
def test_fallback_does_not_return_unrelated_tflite(
self,
onnx_model: Path,
tmp_path: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
"""Stale artifacts from a different export are never returned as fallback."""
out = tmp_path / "out"
out.mkdir()
# Unrelated file — does NOT match model_*.tflite.
(out / "other_model.tflite").write_bytes(b"stale")
with pytest.raises(RuntimeError, match="no .tflite file matching"):
export_tflite(onnx_model, out)
def test_no_tflite_output_raises_runtime_error(
self,
onnx_model: Path,
tmp_path: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
"""Empty output directory raises RuntimeError after conversion."""
out = tmp_path / "empty_out"
out.mkdir()
with pytest.raises(RuntimeError, match="no .tflite file matching"):
export_tflite(onnx_model, out)
def test_returns_path_object(
self,
onnx_model: Path,
tflite_output: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
result = export_tflite(onnx_model, tflite_output)
assert isinstance(result, Path)
def test_calibration_data_forwarded_to_prepare(
self,
onnx_model: Path,
tflite_output: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
"""Verify that calibration_data is passed to _prepare_calibration_data."""
calib_path = "/some/calib.npy"
export_tflite(onnx_model, tflite_output, calibration_data=calib_path)
call_args = mock_prepare_calib.call_args
assert call_args[0][1] == calib_path # second positional arg
def test_max_images_forwarded_to_prepare(
self,
onnx_model: Path,
tflite_output: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
"""Verify that max_images is passed to _prepare_calibration_data."""
export_tflite(onnx_model, tflite_output, max_images=42)
call_kwargs = mock_prepare_calib.call_args
assert call_kwargs.kwargs.get("max_images") == 42
def test_max_images_default_is_100(
self,
onnx_model: Path,
tflite_output: Path,
fake_onnx2tf: Any,
mock_prepare_calib: Any,
) -> None:
"""Verify that max_images defaults to 100 when not specified."""
export_tflite(onnx_model, tflite_output)
call_kwargs = mock_prepare_calib.call_args
assert call_kwargs.kwargs.get("max_images") == 100
def test_valid_quantizations_set(self) -> None:
assert _VALID_QUANTIZATIONS == {None, "fp32", "fp16", "int8"}
# ---------------------------------------------------------------------------
# TestExportFormatParameter
# ---------------------------------------------------------------------------
class TestExportFormatParameter:
"""Tests for ``format="tflite"`` wiring through ``RFDETR.export()``."""
@pytest.fixture(autouse=True)
def _patch_export_deps(self, tmp_path: Path) -> None:
"""Mock the heavy export dependencies so ``RFDETR.export()`` is fast."""
self._tmp_path = tmp_path
onnx_out = tmp_path / "inference_model.onnx"
onnx_out.write_bytes(b"onnx")
import contextlib
self._mock_stack = contextlib.ExitStack()
# Mock export_onnx to return a fake ONNX file path
self._mock_export_onnx = self._mock_stack.enter_context(
mock.patch("rfdetr.export.main.export_onnx", return_value=str(onnx_out))
)
# Mock make_infer_image to return a small tensor
import torch
self._mock_stack.enter_context(
mock.patch(
"rfdetr.export.main.make_infer_image",
return_value=torch.zeros(1, 3, 560, 560),
)
)
# Mock export_tflite
self._mock_export_tflite = self._mock_stack.enter_context(
mock.patch(
"rfdetr.export._tflite.converter.export_tflite",
return_value=tmp_path / "inference_model_float32.tflite",
)
)
yield
self._mock_stack.close()
@staticmethod
def _make_rfdetr() -> Any:
"""Create a minimal RFDETR instance with mocked internals."""
from rfdetr.detr import RFDETR
obj = RFDETR.__new__(RFDETR)
obj.model = mock.MagicMock()
obj.model.resolution = 560
obj.model.device = "cpu"
obj.model.model.to.return_value = obj.model.model
obj.model_config = mock.MagicMock()
obj.model_config.segmentation_head = False
obj.model_config.patch_size = 14
obj.model_config.num_windows = 1
return obj
def test_tflite_format_calls_export_tflite(self) -> None:
obj = self._make_rfdetr()
obj.export(format="tflite", output_dir=str(self._tmp_path / "out"))
self._mock_export_tflite.assert_called_once()
def test_onnx_format_does_not_call_export_tflite(self) -> None:
obj = self._make_rfdetr()
obj.export(format="onnx", output_dir=str(self._tmp_path / "out"))
self._mock_export_tflite.assert_not_called()
def test_quantization_forwarded(self) -> None:
obj = self._make_rfdetr()
obj.export(
format="tflite",
output_dir=str(self._tmp_path / "out"),
quantization="int8",
)
call_kwargs = self._mock_export_tflite.call_args
assert call_kwargs[1].get("quantization") == "int8" or call_kwargs.kwargs.get("quantization") == "int8"
@pytest.mark.parametrize(
"quant",
[
pytest.param(None, id="none"),
pytest.param("fp32", id="fp32"),
pytest.param("fp16", id="fp16"),
pytest.param("int8", id="int8"),
],
)
def test_all_quantization_modes_accepted(self, quant: str | None) -> None:
obj = self._make_rfdetr()
obj.export(
format="tflite",
output_dir=str(self._tmp_path / "out"),
quantization=quant,
)
self._mock_export_tflite.assert_called_once()
def test_unsupported_format_raises(self) -> None:
obj = self._make_rfdetr()
with pytest.raises(ValueError, match="[Uu]nsupported.*format"):
obj.export(format="banana", output_dir=str(self._tmp_path / "out"))
def test_calibration_data_forwarded(self) -> None:
"""Verify calibration_data kwarg reaches export_tflite."""
obj = self._make_rfdetr()
calib = "/my/calib.npy"
obj.export(
format="tflite",
output_dir=str(self._tmp_path / "out"),
calibration_data=calib,
)
call_kwargs = self._mock_export_tflite.call_args
assert call_kwargs[1].get("calibration_data") == calib or call_kwargs.kwargs.get("calibration_data") == calib
def test_max_images_forwarded(self) -> None:
"""Verify max_images kwarg reaches export_tflite."""
obj = self._make_rfdetr()
obj.export(
format="tflite",
output_dir=str(self._tmp_path / "out"),
max_images=50,
)
call_kwargs = self._mock_export_tflite.call_args
assert call_kwargs[1].get("max_images") == 50 or call_kwargs.kwargs.get("max_images") == 50
def test_max_images_default_is_100(self) -> None:
"""Verify max_images defaults to 100 when not specified."""
obj = self._make_rfdetr()
obj.export(
format="tflite",
output_dir=str(self._tmp_path / "out"),
)
call_kwargs = self._mock_export_tflite.call_args
assert call_kwargs[1].get("max_images") == 100 or call_kwargs.kwargs.get("max_images") == 100
# ---------------------------------------------------------------------------
# TestNumpyAllowPickle
# ---------------------------------------------------------------------------
class TestNumpyAllowPickle:
"""Tests for ``_numpy_allow_pickle()`` context manager."""
def test_patches_np_load(self) -> None:
original = np.load
with _numpy_allow_pickle():
assert np.load is not original
assert np.load is original
def test_sets_allow_pickle_default(self) -> None:
calls: list[dict[str, Any]] = []
original = np.load
def _spy(*args: Any, **kwargs: Any) -> Any:
calls.append(kwargs.copy())
raise ValueError("stop")
np.load = _spy # type: ignore[assignment]
try:
with _numpy_allow_pickle():
with pytest.raises(ValueError, match="stop"):
np.load("dummy.npy")
assert calls[0].get("allow_pickle") is True
finally:
np.load = original # type: ignore[assignment]
def test_restores_on_exception(self) -> None:
"""Patch is restored even when an exception propagates out of the context."""
original = np.load
try:
with _numpy_allow_pickle():
raise RuntimeError("boom")
except RuntimeError:
pass
assert np.load is original
def test_concurrent_access_lock_not_reentrant(self) -> None:
"""Lock prevents a second context from patching np.load while the first is still active."""
import threading
entered = threading.Event()
released = threading.Event()
def _hold_context() -> None:
with _numpy_allow_pickle():
entered.set()
released.wait(timeout=2.0)
holder = threading.Thread(target=_hold_context)
holder.start()
entered.wait(timeout=2.0)
acquired = _NUMPY_LOAD_PATCH_LOCK.acquire(blocking=False)
released.set()
holder.join(timeout=2.0)
assert not acquired, "Lock must remain held while first context is active"
# ---------------------------------------------------------------------------
# TestPatchValidationDownload
# ---------------------------------------------------------------------------
class TestPatchValidationDownload:
"""Tests for ``_patch_validation_download()`` context manager."""
def test_patches_download_in_common_functions(self, tmp_path: Path, fake_onnx2tf: Any) -> None:
"""The patch replaces download_test_image_data in common_functions."""
data = np.random.rand(5, 128, 128, 3).astype(np.float32)
npy_path = tmp_path / "calib.npy"
np.save(str(npy_path), data)
cf_mod = sys.modules["onnx2tf.utils.common_functions"]
original_fn = cf_mod.download_test_image_data
with _patch_validation_download(str(npy_path)):
assert cf_mod.download_test_image_data is not original_fn
result = cf_mod.download_test_image_data()
np.testing.assert_array_equal(result, data)
# Restored after exit
assert cf_mod.download_test_image_data is original_fn
def test_patches_download_in_onnx2tf_module(self, tmp_path: Path, fake_onnx2tf: Any) -> None:
"""The patch also covers onnx2tf.onnx2tf if it has the function."""
data = np.random.rand(3, 64, 64, 3).astype(np.float32)
npy_path = tmp_path / "calib.npy"
np.save(str(npy_path), data)
# Create onnx2tf.onnx2tf submodule with download_test_image_data
onnx2tf_inner = types.ModuleType("onnx2tf.onnx2tf")
original_fn = mock.MagicMock()
onnx2tf_inner.download_test_image_data = original_fn # type: ignore[attr-defined]
sys.modules["onnx2tf.onnx2tf"] = onnx2tf_inner
try:
with _patch_validation_download(str(npy_path)):
assert onnx2tf_inner.download_test_image_data is not original_fn
result = onnx2tf_inner.download_test_image_data()
np.testing.assert_array_equal(result, data)
assert onnx2tf_inner.download_test_image_data is original_fn
finally:
sys.modules.pop("onnx2tf.onnx2tf", None)
def test_restores_on_exception(self, tmp_path: Path, fake_onnx2tf: Any) -> None:
"""Functions are restored even when an exception occurs."""
npy_path = tmp_path / "calib.npy"
np.save(str(npy_path), np.zeros((1, 8, 8, 3), dtype=np.float32))
cf_mod = sys.modules["onnx2tf.utils.common_functions"]
original_fn = cf_mod.download_test_image_data
try:
with _patch_validation_download(str(npy_path)):
raise RuntimeError("boom")
except RuntimeError:
pass
assert cf_mod.download_test_image_data is original_fn
def test_skips_missing_modules(self, tmp_path: Path) -> None:
"""Does not raise when onnx2tf modules are not in sys.modules."""
npy_path = tmp_path / "calib.npy"
np.save(str(npy_path), np.zeros((1, 8, 8, 3), dtype=np.float32))
# Ensure onnx2tf is NOT in sys.modules
keys = [k for k in sys.modules if k == "onnx2tf" or k.startswith("onnx2tf.")]
saved = {k: sys.modules.pop(k) for k in keys}
try:
with _patch_validation_download(str(npy_path)):
pass # should not raise
finally:
sys.modules.update(saved)
class TestGetOnnxInputInfo:
"""Tests for ``_get_onnx_input_info()``."""
def test_reads_input_name_and_shape(self, tmp_path: Path) -> None:
"""Build a minimal ONNX model and verify we read back its metadata."""
onnx = pytest.importorskip("onnx", reason="onnx not installed")
TensorProto, helper = onnx.TensorProto, onnx.helper # noqa: N806
inp = helper.make_tensor_value_info("images", TensorProto.FLOAT, [1, 3, 560, 560])
out = helper.make_tensor_value_info("output", TensorProto.FLOAT, [1, 100, 4])
node = helper.make_node("Identity", inputs=["images"], outputs=["output"])
graph = helper.make_graph([node], "test", [inp], [out])
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
onnx_path = tmp_path / "test_model.onnx"
onnx.save(model, str(onnx_path))
name, dims = _get_onnx_input_info(onnx_path)
assert name == "images"
assert dims == [1, 3, 560, 560]
def test_different_input_shape(self, tmp_path: Path) -> None:
"""Verify non-square resolution reads correctly."""
onnx = pytest.importorskip("onnx", reason="onnx not installed")
TensorProto, helper = onnx.TensorProto, onnx.helper # noqa: N806
inp = helper.make_tensor_value_info("input", TensorProto.FLOAT, [1, 3, 448, 640])
out = helper.make_tensor_value_info("output", TensorProto.FLOAT, [1, 10, 4])
node = helper.make_node("Identity", inputs=["input"], outputs=["output"])
graph = helper.make_graph([node], "test", [inp], [out])
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
onnx_path = tmp_path / "test_model.onnx"
onnx.save(model, str(onnx_path))
name, dims = _get_onnx_input_info(onnx_path)
assert name == "input"
assert dims == [1, 3, 448, 640]
# ---------------------------------------------------------------------------
# TestPrepareCalibrationData
# ---------------------------------------------------------------------------
class TestPrepareCalibrationData:
"""Tests for ``_prepare_calibration_data()``."""
@pytest.fixture()
def _mock_onnx_info(self) -> Generator:
"""Mock ``_get_onnx_input_info`` to return a known shape."""
with mock.patch(
"rfdetr.export._tflite.converter._get_onnx_input_info",
return_value=("input", [1, 3, 256, 256]),
):
yield
def test_none_generates_random_data(self, tmp_path: Path, _mock_onnx_info: None) -> None:
onnx_path = tmp_path / "model.onnx"
onnx_path.write_bytes(b"\x00")
npy_path = _prepare_calibration_data(onnx_path, None, tmp_path, "fp32")
assert isinstance(npy_path, Path)
assert npy_path.is_file()
data = np.load(str(npy_path))
assert data.shape == (_DEFAULT_CALIB_SAMPLES, 256, 256, 3)
assert data.dtype == np.float32
def test_none_int8_emits_warning(self, tmp_path: Path, _mock_onnx_info: None) -> None:
onnx_path = tmp_path / "model.onnx"
onnx_path.write_bytes(b"\x00")
with mock.patch("rfdetr.export._tflite.converter.logger") as mock_logger:
_prepare_calibration_data(onnx_path, None, tmp_path, "int8")
mock_logger.warning.assert_called_once()
assert "INT8" in mock_logger.warning.call_args[0][0]
def test_ndarray_saves_to_npy(self, tmp_path: Path, _mock_onnx_info: None) -> None:
onnx_path = tmp_path / "model.onnx"
onnx_path.write_bytes(b"\x00")
calib = np.random.rand(10, 256, 256, 3).astype(np.float32)
npy_path = _prepare_calibration_data(onnx_path, calib, tmp_path, "fp32")
loaded = np.load(str(npy_path))
np.testing.assert_array_equal(loaded, calib)
def test_path_string_used_directly(self, tmp_path: Path, _mock_onnx_info: None) -> None:
onnx_path = tmp_path / "model.onnx"
onnx_path.write_bytes(b"\x00")
npy_file = tmp_path / "my_calib.npy"
np.save(str(npy_file), np.zeros((5, 256, 256, 3), dtype=np.float32))
npy_path = _prepare_calibration_data(onnx_path, str(npy_file), tmp_path, "fp32")
assert npy_path == npy_file
def test_directory_loads_images(self, tmp_path: Path, _mock_onnx_info: None) -> None:
"""A directory path triggers image loading and .npy creation."""
from PIL import Image
onnx_path = tmp_path / "model.onnx"
onnx_path.write_bytes(b"\x00")
img_dir = tmp_path / "images"
img_dir.mkdir()
for i in range(5):
img = Image.new("RGB", (100, 80), color=(i * 50, 0, 0))
img.save(img_dir / f"img_{i:03d}.jpg")
npy_path = _prepare_calibration_data(onnx_path, str(img_dir), tmp_path, "int8")
assert npy_path.is_file()
data = np.load(str(npy_path))
# _mock_onnx_info returns [1, 3, 256, 256] → H=256, W=256
assert data.shape == (5, 256, 256, 3)
assert data.dtype == np.float32
def test_directory_respects_max_images(self, tmp_path: Path, _mock_onnx_info: None) -> None:
"""Verify that max_images limits the number of images loaded from a directory."""
from PIL import Image
onnx_path = tmp_path / "model.onnx"
onnx_path.write_bytes(b"\x00")
img_dir = tmp_path / "images"
img_dir.mkdir()
for i in range(10):
img = Image.new("RGB", (100, 80), color=(i * 25, 0, 0))
img.save(img_dir / f"img_{i:03d}.jpg")
npy_path = _prepare_calibration_data(onnx_path, str(img_dir), tmp_path, "int8", max_images=3)
assert npy_path.is_file()
data = np.load(str(npy_path))
assert data.shape[0] == 3 # only 3 of 10 images loaded
def test_missing_path_raises_file_not_found(self, tmp_path: Path, _mock_onnx_info: None) -> None:
onnx_path = tmp_path / "model.onnx"
onnx_path.write_bytes(b"\x00")
with pytest.raises(FileNotFoundError, match="Calibration data path not found"):
_prepare_calibration_data(onnx_path, "/nonexistent/calib.npy", tmp_path, "fp32")
# ---------------------------------------------------------------------------
# TestLoadCalibrationImages
# ---------------------------------------------------------------------------
class TestLoadCalibrationImages:
"""Tests for ``_load_calibration_images()``."""
@staticmethod
def _make_images(directory: Path, count: int = 5, size: tuple[int, int] = (100, 80)) -> list[Path]:
"""Create *count* small JPEG images in *directory*."""
from PIL import Image
paths: list[Path] = []
for i in range(count):
p = directory / f"img_{i:04d}.jpg"
Image.new("RGB", size, color=(i * 40 % 256, 0, 0)).save(p)
paths.append(p)
return paths
def test_loads_images_with_correct_shape(self, tmp_path: Path) -> None:
self._make_images(tmp_path, count=3)
result = _load_calibration_images(tmp_path, height=128, width=256)
assert isinstance(result, np.ndarray)
assert result.shape == (3, 128, 256, 3)
assert result.dtype == np.float32
assert result.min() >= 0.0
assert result.max() <= 1.0
def test_respects_max_images(self, tmp_path: Path) -> None:
self._make_images(tmp_path, count=10)
result = _load_calibration_images(tmp_path, height=64, width=64, max_images=4)
assert result.shape[0] == 4
def test_empty_directory_raises(self, tmp_path: Path) -> None:
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(FileNotFoundError, match="No supported image files"):
_load_calibration_images(empty, height=64, width=64)
def test_nonexistent_directory_raises(self, tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError, match="Calibration image directory not found"):
_load_calibration_images(tmp_path / "does_not_exist", height=64, width=64)
def test_unsupported_extensions_ignored(self, tmp_path: Path) -> None:
"""Only image extensions are loaded; .txt files are skipped."""
self._make_images(tmp_path, count=2)
(tmp_path / "readme.txt").write_text("hello")
(tmp_path / "data.csv").write_text("a,b")
result = _load_calibration_images(tmp_path, height=32, width=32)
assert result.shape[0] == 2
def test_skips_unreadable_files(self, tmp_path: Path) -> None:
"""Corrupt images are skipped without raising."""
self._make_images(tmp_path, count=3)
# Write a corrupt file with a supported extension
(tmp_path / "corrupt.jpg").write_bytes(b"not-a-jpeg")
result = _load_calibration_images(tmp_path, height=32, width=32)
assert result.shape[0] == 3 # only the 3 valid images
def test_all_unreadable_raises(self, tmp_path: Path) -> None:
"""If all files are unreadable, raises FileNotFoundError."""
(tmp_path / "bad1.jpg").write_bytes(b"garbage")
(tmp_path / "bad2.png").write_bytes(b"garbage")
with pytest.raises(FileNotFoundError, match="No readable images"):
_load_calibration_images(tmp_path, height=32, width=32)
def test_png_and_jpeg_both_loaded(self, tmp_path: Path) -> None:
"""Both .jpg and .png formats are loaded."""
from PIL import Image
Image.new("RGB", (50, 50), "red").save(tmp_path / "a.jpg")
Image.new("RGB", (50, 50), "blue").save(tmp_path / "b.png")
result = _load_calibration_images(tmp_path, height=32, width=32)
assert result.shape[0] == 2
def test_constants_are_reasonable(self) -> None:
"""Sanity-check the module-level constants."""
assert _DEFAULT_DIR_CALIB_SAMPLES > 0
assert ".jpg" in _IMAGE_EXTENSIONS
assert ".png" in _IMAGE_EXTENSIONS
# ---------------------------------------------------------------------------
# TestCheckOnnx2tfAvailable
# ---------------------------------------------------------------------------
@onnx2tf_available
class TestCheckOnnx2tfAvailable:
"""Tests for ``_check_onnx2tf_available()``."""
def test_available_when_importable(self, fake_onnx2tf: Any) -> None:
_check_onnx2tf_available() # should not raise
def test_raises_when_not_importable(self) -> None:
"""ImportError is raised with install hint when onnx2tf is absent."""
_remove_fake_onnx2tf()
with mock.patch.dict(sys.modules, {"onnx2tf": None}):
with pytest.raises(ImportError, match="onnx2tf is not installed"):
_check_onnx2tf_available()
# ---------------------------------------------------------------------------
# TestGridSampleOnnxRewrite
# ---------------------------------------------------------------------------
onnx_gs_available = pytest.mark.skipif(
not all(
__import__("importlib").util.find_spec(p) is not None for p in ("onnx", "onnx_graphsurgeon", "onnxruntime")
),
reason="onnx, onnx_graphsurgeon, and onnxruntime required",
)
def _build_gridsample_onnx(
path: Path,
*,
n: int = 1,
c: int = 4,
h: int = 8,
w: int = 8,
h_out: int = 4,
w_out: int = 4,
) -> None:
"""Write a minimal ONNX model with one GridSample node to *path*."""
import onnx
from onnx import TensorProto, helper
im = helper.make_tensor_value_info("im", TensorProto.FLOAT, [n, c, h, w])
grid = helper.make_tensor_value_info("grid", TensorProto.FLOAT, [n, h_out, w_out, 2])
out = helper.make_tensor_value_info("out", TensorProto.FLOAT, [n, c, h_out, w_out])
node = helper.make_node(
"GridSample",
inputs=["im", "grid"],
outputs=["out"],
mode="bilinear",
padding_mode="zeros",
align_corners=0,
)
graph = helper.make_graph([node], "gs_test", [im, grid], [out])
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 16)])
onnx.save(model, str(path))
@pytest.fixture()
def gridsample_onnx(tmp_path: Path) -> Path:
"""Build a tiny ONNX file with a single GridSample node."""
p = tmp_path / "gs_model.onnx"
_build_gridsample_onnx(p)
return p
class TestGridSampleOnnxRewrite:
"""Tests for the ONNX-level GridSample → Gather(axis=0) rewrite."""
def test_module_import_does_not_raise(self) -> None:
"""Importing the converter module must succeed regardless of onnx2tf version."""
import rfdetr.export._tflite.converter # noqa: F401
@onnx_gs_available
def test_no_gridsample_nodes_after_rewrite(self, gridsample_onnx: Path, tmp_path: Path) -> None:
"""_replace_gridsample_for_tflite removes all GridSample nodes from the graph."""
import onnx
import onnx_graphsurgeon as gs
from rfdetr.export._tflite.converter import _replace_gridsample_for_tflite
patched_path = _replace_gridsample_for_tflite(gridsample_onnx, tmp_path)
model = onnx.load(str(patched_path))
graph = gs.import_onnx(model)
remaining = [n for n in graph.nodes if n.op == "GridSample"]
assert remaining == [], f"Expected no GridSample nodes; found {len(remaining)}"
@onnx_gs_available
def test_gather_nodes_present_after_rewrite(self, gridsample_onnx: Path, tmp_path: Path) -> None:
"""Rewritten graph contains Gather nodes (the TFLite-safe replacement ops)."""
import onnx
import onnx_graphsurgeon as gs
from rfdetr.export._tflite.converter import _replace_gridsample_for_tflite
patched_path = _replace_gridsample_for_tflite(gridsample_onnx, tmp_path)
model = onnx.load(str(patched_path))
graph = gs.import_onnx(model)
gather_nodes = [n for n in graph.nodes if n.op == "Gather"]
assert len(gather_nodes) >= 4, f"Expected ≥4 Gather nodes (one per bilinear corner); found {len(gather_nodes)}"
@onnx_gs_available
def test_numerical_equivalence_vs_pytorch(self, gridsample_onnx: Path, tmp_path: Path) -> None:
"""Rewritten ONNX output matches torch.nn.functional.grid_sample within 1e-5."""
import onnxruntime as ort
import torch
import torch.nn.functional as F # noqa: N812
from rfdetr.export._tflite.converter import _replace_gridsample_for_tflite
rng = np.random.default_rng(0)
im_np = rng.standard_normal((1, 4, 8, 8)).astype(np.float32)
grid_np = rng.uniform(-1, 1, (1, 4, 4, 2)).astype(np.float32)
ref = F.grid_sample(
torch.from_numpy(im_np),
torch.from_numpy(grid_np),
mode="bilinear",
padding_mode="zeros",
align_corners=False,
).numpy()
patched_path = _replace_gridsample_for_tflite(gridsample_onnx, tmp_path)
sess = ort.InferenceSession(str(patched_path), providers=["CPUExecutionProvider"])
(result,) = sess.run(None, {"im": im_np, "grid": grid_np})
np.testing.assert_allclose(
result, ref, atol=1e-5, rtol=0, err_msg="Gather(axis=0) rewrite output diverges from F.grid_sample"
)