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

785 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for TFLite inference helpers.
Covers:
* ``_create_interpreter()`` — interpreter loading with tflite_runtime / tensorflow fallback
* ``_run_inference()`` — image preprocessing, invocation, and detection decoding
* ``_decode_masks()`` — segmentation mask upsampling and thresholding
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest import mock
import numpy as np
import pytest
import supervision as sv
from PIL import Image as PILImage
from rfdetr.export._tflite.inference import (
_bilinear_resize_half_pixel,
_create_interpreter,
_decode_masks,
_preprocess_image,
_run_inference,
)
# ---------------------------------------------------------------------------
# Shared helpers / factories
# ---------------------------------------------------------------------------
_INPUT_SHAPE = [1, 224, 224, 3]
_DET_OUTPUT = {"shape": [1, 10, 4], "name": "serving_default_dets:0", "index": 1}
_LABEL_OUTPUT = {"shape": [1, 10, 82], "name": "serving_default_labels:0", "index": 2}
def _make_boxes() -> np.ndarray:
"""Return (1, 10, 4) array of normalised cxcywh boxes all centred at 0.5."""
return np.array([[[0.5, 0.5, 0.1, 0.1]] * 10], dtype=np.float32)
def _make_logits(high_conf_idx: int | None = 0) -> np.ndarray:
"""Return (1, 10, 82) logits with one high-confidence entry when requested.
Background fill is -10.0 so sigmoid scores are near zero (~0.0001) for all entries except the explicitly boosted one
(logit=+10.0, sigmoid≈0.9999). This ensures the helper works correctly under per-class sigmoid scoring.
"""
logits = np.full((1, 10, 82), -10.0, dtype=np.float32)
if high_conf_idx is not None:
logits[0, high_conf_idx, 0] = 10.0
return logits
def _make_interp(
input_shape: list[int] | None = None,
out_dets: list[dict] | None = None,
boxes: np.ndarray | None = None,
logits: np.ndarray | None = None,
) -> mock.MagicMock:
"""Build a mock TFLite interpreter with configurable I/O details."""
if input_shape is None:
input_shape = _INPUT_SHAPE
out_dets = out_dets if out_dets is not None else [_DET_OUTPUT, _LABEL_OUTPUT]
if boxes is None:
boxes = _make_boxes()
if logits is None:
logits = _make_logits()
def _get_tensor(index: int) -> np.ndarray:
if index == _DET_OUTPUT["index"]:
return boxes
if index == _LABEL_OUTPUT["index"]:
return logits
raise ValueError(f"Unknown tensor index: {index}")
interp = mock.MagicMock()
interp.get_input_details.return_value = [{"shape": input_shape, "index": 0, "dtype": np.float32}]
interp.get_output_details.return_value = out_dets
interp.get_tensor.side_effect = _get_tensor
return interp
def _save_rgb_image(path: Path, size: tuple[int, int] = (64, 64)) -> None:
"""Write a small solid-colour RGB JPEG to *path*."""
PILImage.new("RGB", size, color=(100, 150, 200)).save(path)
def _save_grayscale_image(path: Path, size: tuple[int, int] = (64, 64)) -> None:
"""Write a small solid-colour grayscale PNG to *path*."""
PILImage.new("L", size, color=128).save(path)
# ---------------------------------------------------------------------------
# TestCreateInterpreter
# ---------------------------------------------------------------------------
# Shared masking entries for mock.patch.dict(sys.modules, ...) that force
# ``_create_interpreter`` to skip the ai_edge_litert backend probe.
_AI_EDGE_LITERT_MASK: dict[str, None] = {
"ai_edge_litert": None,
"ai_edge_litert.interpreter": None,
}
class TestCreateInterpreter:
"""Tests for ``_create_interpreter()``."""
@pytest.fixture()
def _mock_tflite_runtime(self):
"""Inject a fake tflite_runtime.interpreter into sys.modules and mask ai_edge_litert.
``_create_interpreter`` probes backends in priority order: ``ai_edge_litert`` first, then ``tflite_runtime``,
then ``tensorflow``. Masking ``ai_edge_litert`` and ``ai_edge_litert.interpreter`` to ``None`` forces the import
loop to fall through to the ``tflite_runtime`` path so tests exercise that branch regardless of what is
installed.
Python's import machinery resolves ``import tflite_runtime.interpreter`` by looking up
``sys.modules["tflite_runtime.interpreter"]`` directly. We also set the ``interpreter`` attribute on the parent
package mock so attribute-path resolution is consistent regardless of Python version.
"""
interp_instance = mock.MagicMock()
interp_instance.get_input_details.return_value = [{"shape": [1, 640, 640, 3], "dtype": np.float32}]
interp_instance.get_output_details.return_value = [
{"shape": [1, 300, 4], "name": "dets"},
{"shape": [1, 300, 81], "name": "labels"},
]
interp_cls = mock.MagicMock(return_value=interp_instance)
# Build the submodule with a real Interpreter attribute
import types
mod = types.ModuleType("tflite_runtime.interpreter")
mod.Interpreter = interp_cls # type: ignore[attr-defined]
# Build parent package that exposes mod as .interpreter
parent_mod = types.ModuleType("tflite_runtime")
parent_mod.interpreter = mod # type: ignore[attr-defined]
with mock.patch.dict(
sys.modules,
{
**_AI_EDGE_LITERT_MASK,
"tflite_runtime": parent_mod,
"tflite_runtime.interpreter": mod,
},
):
yield interp_cls, interp_instance
def test_uses_tflite_runtime_when_ai_edge_litert_absent(self, _mock_tflite_runtime) -> None:
"""tflite_runtime is used as backend when ai_edge_litert is masked from the environment."""
interp_cls, interp_instance = _mock_tflite_runtime
_create_interpreter("model.tflite")
interp_cls.assert_called_once_with(model_path="model.tflite")
def test_allocate_tensors_called(self, _mock_tflite_runtime) -> None:
"""allocate_tensors() is always called after construction."""
_, interp_instance = _mock_tflite_runtime
_create_interpreter("model.tflite")
interp_instance.allocate_tensors.assert_called_once()
def test_falls_back_to_tensorflow_when_tflite_runtime_missing(self) -> None:
"""tensorflow.lite.Interpreter is used when tflite_runtime is absent."""
interp_instance = mock.MagicMock()
interp_instance.get_input_details.return_value = [{"shape": [1, 640, 640, 3], "dtype": np.float32}]
interp_instance.get_output_details.return_value = [{"shape": [1, 300, 4], "name": "dets"}]
tf_interp_cls = mock.MagicMock(return_value=interp_instance)
tf_lite_mod = mock.MagicMock()
tf_lite_mod.Interpreter = tf_interp_cls
tf_mod = mock.MagicMock()
tf_mod.lite = tf_lite_mod
with mock.patch.dict(
sys.modules,
{
**_AI_EDGE_LITERT_MASK,
"tflite_runtime": None,
"tflite_runtime.interpreter": None,
"tensorflow": tf_mod,
"tensorflow.lite": tf_lite_mod,
},
):
_create_interpreter("model.tflite")
tf_interp_cls.assert_called_once_with(model_path="model.tflite")
interp_instance.allocate_tensors.assert_called_once()
def test_returns_interpreter(self, _mock_tflite_runtime) -> None:
"""Return value is the interpreter instance (not the class)."""
_, interp_instance = _mock_tflite_runtime
result = _create_interpreter("model.tflite")
assert result is interp_instance
def test_logs_input_and_output_shapes(self, _mock_tflite_runtime) -> None:
"""Logger.debug is called with 'Input' and 'Output' shape lines."""
with mock.patch("rfdetr.export._tflite.inference.logger") as mock_logger:
_create_interpreter("model.tflite")
debug_msgs = [call.args[0] for call in mock_logger.debug.call_args_list]
assert any("Input" in m for m in debug_msgs)
assert any("Output" in m for m in debug_msgs)
def test_accepts_path_object(self, _mock_tflite_runtime) -> None:
"""Path objects are converted to strings before passing to Interpreter."""
interp_cls, _ = _mock_tflite_runtime
_create_interpreter(Path("model.tflite"))
call_kwargs = interp_cls.call_args[1]
assert call_kwargs["model_path"] == "model.tflite"
assert isinstance(call_kwargs["model_path"], str)
@pytest.fixture()
def _mock_ai_edge_litert(self):
"""Inject a fake ai_edge_litert.interpreter into sys.modules and mask lower-priority backends.
Mirrors ``_mock_tflite_runtime`` for the first-priority backend so the ``ai_edge_litert.interpreter`` branch of
``_create_interpreter`` can be exercised independently of whether the real package is installed.
"""
interp_instance = mock.MagicMock()
interp_instance.get_input_details.return_value = [{"shape": [1, 640, 640, 3], "dtype": np.float32}]
interp_instance.get_output_details.return_value = [
{"shape": [1, 300, 4], "name": "dets"},
{"shape": [1, 300, 81], "name": "labels"},
]
interp_cls = mock.MagicMock(return_value=interp_instance)
import types
mod = types.ModuleType("ai_edge_litert.interpreter")
mod.Interpreter = interp_cls # type: ignore[attr-defined]
parent_mod = types.ModuleType("ai_edge_litert")
parent_mod.interpreter = mod # type: ignore[attr-defined]
with mock.patch.dict(
sys.modules,
{
"ai_edge_litert": parent_mod,
"ai_edge_litert.interpreter": mod,
"tflite_runtime": None,
"tflite_runtime.interpreter": None,
},
):
yield interp_cls, interp_instance
def test_uses_ai_edge_litert_when_available(self, _mock_ai_edge_litert) -> None:
"""ai_edge_litert is used as the first-priority backend when it is importable."""
interp_cls, _ = _mock_ai_edge_litert
_create_interpreter("model.tflite")
interp_cls.assert_called_once_with(model_path="model.tflite")
def test_ai_edge_litert_allocate_tensors_called(self, _mock_ai_edge_litert) -> None:
"""allocate_tensors() is called after construction via the ai_edge_litert backend."""
_, interp_instance = _mock_ai_edge_litert
_create_interpreter("model.tflite")
interp_instance.allocate_tensors.assert_called_once()
def test_ai_edge_litert_returns_interpreter(self, _mock_ai_edge_litert) -> None:
"""Return value is the ai_edge_litert interpreter instance."""
_, interp_instance = _mock_ai_edge_litert
result = _create_interpreter("model.tflite")
assert result is interp_instance
def test_raises_when_no_backend_available(self) -> None:
"""ImportError with a helpful install message is raised when all backends are absent."""
with mock.patch.dict(
sys.modules,
{
**_AI_EDGE_LITERT_MASK,
"tflite_runtime": None,
"tflite_runtime.interpreter": None,
"tensorflow": None,
"tensorflow.lite": None,
},
):
with pytest.raises(ImportError, match="TFLite inference requires"):
_create_interpreter("model.tflite")
# ---------------------------------------------------------------------------
# TestRunInference
# ---------------------------------------------------------------------------
class TestRunInference:
"""Tests for ``_run_inference()``."""
@pytest.fixture()
def rgb_image(self, tmp_path: Path) -> Path:
"""Write a small RGB JPEG to a temp file and return its path."""
p = tmp_path / "image.jpg"
_save_rgb_image(p)
return p
@pytest.fixture()
def grayscale_image(self, tmp_path: Path) -> Path:
"""Write a small grayscale PNG to a temp file and return its path."""
p = tmp_path / "gray.png"
_save_grayscale_image(p)
return p
def test_returns_detections_and_image(self, rgb_image: Path) -> None:
"""Return type is tuple[sv.Detections, PIL.Image.Image]."""
interp = _make_interp()
result = _run_inference(interp, rgb_image)
assert isinstance(result, tuple)
dets, img = result
assert isinstance(dets, sv.Detections)
assert isinstance(img, PILImage.Image)
def test_detections_above_threshold_kept(self, rgb_image: Path) -> None:
"""At least one detection is returned when one logit is high-confidence."""
interp = _make_interp(logits=_make_logits(high_conf_idx=0))
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert len(dets) >= 1
def test_detections_below_threshold_filtered(self, rgb_image: Path) -> None:
"""No detections survive when all logits are zero (uniform probs < 0.3)."""
interp = _make_interp(logits=_make_logits(high_conf_idx=None))
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert len(dets) == 0
def test_boxes_in_pixel_space(self, rgb_image: Path) -> None:
"""Xyxy coordinates are scaled to image pixel dimensions, not 01 range."""
img_size = (200, 100) # (width, height) for PIL
PILImage.new("RGB", img_size, color=(100, 150, 200)).save(rgb_image)
# One centred box: cx=0.5, cy=0.5, w=0.2, h=0.2
boxes = np.array([[[0.5, 0.5, 0.2, 0.2]] + [[0.0, 0.0, 0.0, 0.0]] * 9], dtype=np.float32)
logits = _make_logits(high_conf_idx=0)
interp = _make_interp(boxes=boxes, logits=logits)
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
# With cx=0.5*200=100, cy=0.5*100=50, bw=0.2*200=40, bh=0.2*100=20
# xyxy expected: [80, 40, 120, 60]
assert dets.xyxy[0, 0] > 1.0 # x1 in pixel coords, not 01
def test_set_tensor_called_with_correct_shape(self, rgb_image: Path) -> None:
"""set_tensor receives a tensor matching (1, H, W, C)."""
_, H, W, C = _INPUT_SHAPE # noqa: N806
interp = _make_interp()
_run_inference(interp, rgb_image)
call_args = interp.set_tensor.call_args
tensor_arg = call_args[0][1]
assert tensor_arg.shape == (1, H, W, C)
def test_invoke_called_exactly_once(self, rgb_image: Path) -> None:
"""interp.invoke() is called exactly once per inference call."""
interp = _make_interp()
_run_inference(interp, rgb_image)
interp.invoke.assert_called_once()
def test_grayscale_image_accepted(self, grayscale_image: Path) -> None:
"""Grayscale (L-mode) input with C=1 is accepted without error."""
input_shape = [1, 224, 224, 1]
det_out = {"shape": [1, 10, 4], "name": "serving_default_dets:0", "index": 1}
label_out = {"shape": [1, 10, 82], "name": "serving_default_labels:0", "index": 2}
interp = _make_interp(input_shape=input_shape, out_dets=[det_out, label_out])
dets, _ = _run_inference(interp, grayscale_image)
assert isinstance(dets, sv.Detections)
def test_output_lookup_by_name_robust_to_ordering(self, rgb_image: Path) -> None:
"""Swapping dets/labels order in get_output_details returns same detections."""
logits = _make_logits(high_conf_idx=0)
boxes = _make_boxes()
# Canonical order: dets first, labels second
interp_normal = _make_interp(boxes=boxes, logits=logits)
dets_normal, _ = _run_inference(interp_normal, rgb_image, threshold=0.3)
# Swapped order: labels first, dets second
det_out_swapped = {"shape": [1, 10, 4], "name": "serving_default_dets:0", "index": 1}
label_out_swapped = {"shape": [1, 10, 82], "name": "serving_default_labels:0", "index": 2}
interp_swapped = _make_interp(
out_dets=[label_out_swapped, det_out_swapped],
boxes=boxes,
logits=logits,
)
dets_swapped, _ = _run_inference(interp_swapped, rgb_image, threshold=0.3)
assert len(dets_normal) == len(dets_swapped)
def test_raises_for_non_float32_input_dtype(self, rgb_image: Path) -> None:
"""ValueError raised when model input dtype is not float32."""
interp = mock.MagicMock()
interp.get_input_details.return_value = [{"shape": _INPUT_SHAPE, "index": 0, "dtype": np.uint8}]
interp.get_output_details.return_value = [_DET_OUTPUT, _LABEL_OUTPUT]
with pytest.raises(ValueError, match="float32"):
_run_inference(interp, rgb_image)
# ---------------------------------------------------------------------------
# TestSigmoidScoring
# ---------------------------------------------------------------------------
class TestSigmoidScoring:
"""Tests for per-class sigmoid scoring introduced in _run_inference."""
@pytest.fixture()
def rgb_image(self, tmp_path: Path) -> Path:
"""Write a small RGB JPEG to a temp file and return its path."""
p = tmp_path / "image.jpg"
_save_rgb_image(p)
return p
def test_high_logit_yields_confidence_near_one(self, rgb_image: Path) -> None:
"""Logit of 10.0 produces sigmoid ≈ 0.9999; confidence[0] > 0.99."""
logits = _make_logits(high_conf_idx=0) # logits[0, 0, 0] = 10.0
interp = _make_interp(logits=logits)
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert dets.confidence[0] > 0.99
def test_low_logit_filtered_at_threshold(self, rgb_image: Path) -> None:
"""Logit of -10.0 produces sigmoid ≈ 0.0001; detection filtered at threshold=0.3."""
logits = np.full((1, 10, 82), -10.0, dtype=np.float32)
interp = _make_interp(logits=logits)
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert len(dets) == 0
def test_multiclass_class_id_is_argmax_of_logits(self, rgb_image: Path) -> None:
"""Argmax of sigmoid equals argmax of logits; query with [5,2,1,...] gets class_id==0."""
# Shape (1, 10, 82): first query has logits [5, 2, 1, 0, ...], rest are -100
logits = np.full((1, 10, 82), -100.0, dtype=np.float32)
logits[0, 0, 0] = 5.0
logits[0, 0, 1] = 2.0
logits[0, 0, 2] = 1.0
interp = _make_interp(logits=logits)
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
# argmax of sigmoid == argmax of logits because sigmoid is monotone increasing
assert dets.class_id[0] == 0
# ---------------------------------------------------------------------------
# TestShapeBasedOutputFallback
# ---------------------------------------------------------------------------
# Generic output detail dicts used across shape-based fallback tests.
# Indices mirror the canonical ones so _make_interp's _get_tensor dispatch works.
_GENERIC_DET_OUTPUT = {"shape": [1, 10, 4], "name": "Identity_0", "index": 1}
_GENERIC_LABEL_OUTPUT = {"shape": [1, 10, 82], "name": "Identity_1", "index": 2}
class TestShapeBasedOutputFallback:
"""Tests for the shape-based output matching fallback in _run_inference."""
@pytest.fixture()
def rgb_image(self, tmp_path: Path) -> Path:
"""Write a small RGB JPEG to a temp file and return its path."""
p = tmp_path / "image.jpg"
_save_rgb_image(p)
return p
def test_unambiguous_shapes_inferred_correctly(self, rgb_image: Path) -> None:
"""Generic names with shapes [1,10,4] and [1,10,82] resolve without error."""
interp = _make_interp(
out_dets=[_GENERIC_DET_OUTPUT, _GENERIC_LABEL_OUTPUT],
logits=_make_logits(high_conf_idx=0),
)
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert isinstance(dets, sv.Detections)
assert len(dets) >= 1
def test_ambiguous_shapes_two_outputs_positional_fallback(self, rgb_image: Path) -> None:
"""When both outputs have last-dim==4 (num_classes==3) and there are exactly 2, positional fallback is used."""
# num_classes=3 → logits shape last-dim==4; boxes last-dim==4 → ambiguous
# Positional order: index 0 = boxes (Identity_0, tensor index 1), index 1 = logits (Identity_1, tensor index 2)
ambiguous_dets = {"shape": [1, 10, 4], "name": "Identity_0", "index": 1}
ambiguous_labels = {"shape": [1, 10, 4], "name": "Identity_1", "index": 2}
# Build logits of shape (1, 10, 4) so last col is dropped → (10, 3) per-class
logits_ambiguous = np.full((1, 10, 4), -10.0, dtype=np.float32)
logits_ambiguous[0, 0, 0] = 10.0 # first query, first class → high confidence
interp = _make_interp(
out_dets=[ambiguous_dets, ambiguous_labels],
logits=logits_ambiguous,
)
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert isinstance(dets, sv.Detections)
assert len(dets) >= 1
def test_three_outputs_all_dim4_raises_value_error(self, rgb_image: Path) -> None:
"""3 outputs all with last-dim==4 and no name match raises ValueError with expected message."""
# Need a third tensor index; extend _get_tensor via a custom mock
third_output = {"shape": [1, 10, 4], "name": "Identity_2", "index": 3}
boxes = _make_boxes()
logits = _make_logits()
def _get_tensor(index: int) -> np.ndarray:
if index == 1:
return boxes
if index in (2, 3):
return logits
raise ValueError(f"Unknown tensor index: {index}")
interp = mock.MagicMock()
interp.get_input_details.return_value = [{"shape": _INPUT_SHAPE, "index": 0, "dtype": np.float32}]
interp.get_output_details.return_value = [
{"shape": [1, 10, 4], "name": "Identity_0", "index": 1},
{"shape": [1, 10, 4], "name": "Identity_1", "index": 2},
third_output,
]
interp.get_tensor.side_effect = _get_tensor
with pytest.raises(ValueError, match="Shape-based TFLite output matching failed"):
_run_inference(interp, rgb_image, threshold=0.3)
def test_three_outputs_with_rank4_masks_resolves_correctly(self, rgb_image: Path) -> None:
"""3-output segmentation export (boxes/logits/masks) with generic names resolves without error.
Ensures the shape fallback ignores the rank-4 masks tensor and correctly identifies boxes [1,Q,4] and logits
[1,Q,C+1] as rank-3 candidates.
"""
boxes = _make_boxes()
logits = _make_logits(high_conf_idx=0)
masks = np.zeros((1, 10, 28, 28), dtype=np.float32)
def _get_tensor(index: int) -> np.ndarray:
if index == 1:
return boxes
if index == 2:
return logits
if index == 3:
return masks
raise ValueError(f"Unknown tensor index: {index}")
interp = mock.MagicMock()
interp.get_input_details.return_value = [{"shape": _INPUT_SHAPE, "index": 0, "dtype": np.float32}]
interp.get_output_details.return_value = [
{"shape": [1, 10, 4], "name": "Identity_0", "index": 1},
{"shape": [1, 10, 82], "name": "Identity_1", "index": 2},
{"shape": [1, 10, 28, 28], "name": "Identity_2", "index": 3},
]
interp.get_tensor.side_effect = _get_tensor
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert isinstance(dets, sv.Detections)
assert len(dets) >= 1
# ---------------------------------------------------------------------------
# TestMaskDecoding
# ---------------------------------------------------------------------------
class TestMaskDecoding:
"""Tests for ``_decode_masks()`` and mask decoding in ``_run_inference()``."""
@pytest.fixture()
def rgb_image(self, tmp_path: Path) -> Path:
"""Write a small RGB JPEG to a temp file and return its path."""
p = tmp_path / "image.jpg"
_save_rgb_image(p)
return p
def test_decode_masks_shape_and_dtype(self) -> None:
"""Output shape is (K, height, width) from out_size=(width, height); dtype is bool."""
out = _decode_masks(np.zeros((3, 10, 10), dtype=np.float32), (40, 20))
assert out.shape == (3, 20, 40)
assert out.dtype == bool
def test_decode_masks_thresholds_at_zero(self) -> None:
"""Positive logits decode to True, negative logits to False."""
logits = np.stack(
[
np.full((8, 8), 5.0, dtype=np.float32),
np.full((8, 8), -5.0, dtype=np.float32),
]
)
out = _decode_masks(logits, (16, 16))
assert out[0].all()
assert not out[1].any()
def test_decode_masks_empty_input(self) -> None:
"""Zero masks in yields a (0, height, width) array, not an error."""
out = _decode_masks(np.zeros((0, 10, 10), dtype=np.float32), (32, 32))
assert out.shape == (0, 32, 32)
def test_run_inference_decodes_masks_for_seg_model(self, rgb_image: Path) -> None:
"""A 3-output segmentation export populates Detections.mask at image size."""
boxes = _make_boxes()
logits = _make_logits(high_conf_idx=0)
masks = np.full((1, 10, 28, 28), -10.0, dtype=np.float32)
masks[0, 0] = 10.0 # query 0 (the kept detection) gets an all-positive mask
def _get_tensor(index: int) -> np.ndarray:
return {1: boxes, 2: logits, 3: masks}[index]
interp = mock.MagicMock()
interp.get_input_details.return_value = [{"shape": _INPUT_SHAPE, "index": 0, "dtype": np.float32}]
interp.get_output_details.return_value = [
{"shape": [1, 10, 4], "name": "Identity_0", "index": 1},
{"shape": [1, 10, 82], "name": "Identity_1", "index": 2},
{"shape": [1, 10, 28, 28], "name": "Identity_2", "index": 3},
]
interp.get_tensor.side_effect = _get_tensor
dets, img = _run_inference(interp, rgb_image, threshold=0.3)
assert dets.mask is not None
assert dets.mask.shape == (len(dets), img.height, img.width)
assert dets.mask.dtype == bool
assert dets.mask[0].all() # query 0's all-positive logits decode to a full mask
def test_run_inference_no_mask_for_detection_model(self, rgb_image: Path) -> None:
"""A 2-output detection export leaves Detections.mask as None."""
interp = _make_interp(logits=_make_logits(high_conf_idx=0))
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert dets.mask is None
def test_run_inference_name_based_mask_detection(self, rgb_image: Path) -> None:
"""Output named 'masks:0' exercises the name-based path and sets Detections.mask."""
boxes = _make_boxes()
logits = _make_logits(high_conf_idx=0)
masks = np.full((1, 10, 28, 28), 10.0, dtype=np.float32)
def _get_tensor(index: int) -> np.ndarray:
return {1: boxes, 2: logits, 3: masks}[index]
interp = mock.MagicMock()
interp.get_input_details.return_value = [{"shape": _INPUT_SHAPE, "index": 0, "dtype": np.float32}]
interp.get_output_details.return_value = [
{"shape": [1, 10, 4], "name": "serving_default_dets:0", "index": 1},
{"shape": [1, 10, 82], "name": "serving_default_labels:0", "index": 2},
{"shape": [1, 10, 28, 28], "name": "serving_default_masks:0", "index": 3},
]
interp.get_tensor.side_effect = _get_tensor
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert dets.mask is not None
def test_run_inference_seg_model_no_detections_returns_none_mask(self, rgb_image: Path) -> None:
"""Seg model with all scores below threshold returns mask=None (keep.any() is False)."""
boxes = _make_boxes()
logits = _make_logits(high_conf_idx=None) # all scores near zero, below threshold
masks = np.full((1, 10, 28, 28), 10.0, dtype=np.float32)
def _get_tensor(index: int) -> np.ndarray:
return {1: boxes, 2: logits, 3: masks}[index]
interp = mock.MagicMock()
interp.get_input_details.return_value = [{"shape": _INPUT_SHAPE, "index": 0, "dtype": np.float32}]
interp.get_output_details.return_value = [
{"shape": [1, 10, 4], "name": "Identity_0", "index": 1},
{"shape": [1, 10, 82], "name": "Identity_1", "index": 2},
{"shape": [1, 10, 28, 28], "name": "Identity_2", "index": 3},
]
interp.get_tensor.side_effect = _get_tensor
dets, _ = _run_inference(interp, rgb_image, threshold=0.3)
assert len(dets) == 0
assert dets.mask is None
def test_decode_masks_raises_on_wrong_rank(self) -> None:
"""_decode_masks raises ValueError when input is not rank-3."""
with pytest.raises(ValueError, match="rank-3"):
_decode_masks(np.zeros((10, 28, 28, 1), dtype=np.float32), (56, 56))
def test_decode_masks_exact_zero_logit_decodes_to_false(self) -> None:
"""Logit exactly 0.0 is not > 0.0 and decodes to False (strict threshold)."""
zero_logits = np.zeros((1, 8, 8), dtype=np.float32)
out = _decode_masks(zero_logits, (16, 16))
assert not out.any()
def test_decode_masks_non_square_logit_input(self) -> None:
"""Non-square logit map (K, Hm, Wm) with Hm != Wm resizes to the correct output shape."""
logits = np.full((3, 7, 14), 5.0, dtype=np.float32)
out = _decode_masks(logits, (56, 28)) # out_size=(width=56, height=28)
assert out.shape == (3, 28, 56)
assert out.all() # all-positive logits → all True
def test_decode_masks_parity_positive_negative_regions(self) -> None:
"""Positive/negative logit regions map correctly after bilinear upsample + threshold.
Uses high-magnitude logits (±10) so no ambiguity near the boundary; verifies the core _decode_masks contract
matches the >0 PostProcess.forward equivalent.
"""
logits = np.full((1, 14, 14), -10.0, dtype=np.float32)
logits[0, :7, :] = 10.0 # top half strongly positive, bottom half strongly negative
out = _decode_masks(logits, (28, 28))
# Interior rows well away from the half-way boundary
assert out[0, 1:6, :].all() # top rows → all True
assert not out[0, 15:27, :].any() # bottom rows → all False
# ---------------------------------------------------------------------------
# TestBilinearResizeHalfPixel
# ---------------------------------------------------------------------------
class TestBilinearResizeHalfPixel:
"""Tests for ``_bilinear_resize_half_pixel()``."""
def test_output_shape(self) -> None:
"""Output shape is (K, out_h, out_w)."""
src = np.ones((3, 8, 8), dtype=np.float32)
out = _bilinear_resize_half_pixel(src, 16, 16)
assert out.shape == (3, 16, 16)
def test_output_dtype_is_float32(self) -> None:
"""Output dtype is float32 regardless of input magnitude."""
src = np.ones((1, 4, 4), dtype=np.float32)
out = _bilinear_resize_half_pixel(src, 8, 8)
assert out.dtype == np.float32
def test_identity_when_no_resize(self) -> None:
"""Output equals input when target dimensions match source dimensions."""
rng = np.random.default_rng(0)
src = rng.random((2, 8, 8)).astype(np.float32)
out = _bilinear_resize_half_pixel(src, 8, 8)
np.testing.assert_allclose(out, src, atol=1e-6)
@pytest.mark.parametrize(
("src_shape", "out_h", "out_w"),
[
pytest.param((1, 4, 4), 8, 8, id="upsample_square"),
pytest.param((3, 7, 5), 14, 10, id="upsample_nonsquare"),
pytest.param((2, 8, 8), 4, 4, id="downsample"),
pytest.param((1, 1, 1), 3, 3, id="degenerate_1x1"),
],
)
def test_parity_with_torch_interpolate(self, src_shape: tuple[int, int, int], out_h: int, out_w: int) -> None:
"""Output matches F.interpolate(mode='bilinear', align_corners=False) to within 1e-5."""
torch = pytest.importorskip("torch")
import torch.nn.functional as F # noqa: N812
rng = np.random.default_rng(42)
src = rng.random(src_shape).astype(np.float32)
result = _bilinear_resize_half_pixel(src, out_h, out_w)
t = torch.from_numpy(src).unsqueeze(0)
with torch.no_grad():
ref = F.interpolate(t, size=(out_h, out_w), mode="bilinear", align_corners=False)
ref_np = ref.squeeze(0).numpy()
np.testing.assert_allclose(result, ref_np, atol=1e-5)
# ---------------------------------------------------------------------------
# TestPreprocessImage
# ---------------------------------------------------------------------------
class TestPreprocessImage:
"""Tests for ``_preprocess_image()``."""
def test_output_shape_rgb(self) -> None:
"""RGB image returns float32 array of shape (1, H, W, 3)."""
pil_img = PILImage.new("RGB", (100, 80))
out = _preprocess_image(pil_img, (64, 64))
assert out.shape == (1, 64, 64, 3)
assert out.dtype == np.float32
def test_output_shape_grayscale(self) -> None:
"""Grayscale image with channels=1 returns float32 array of shape (1, H, W, 1)."""
pil_img = PILImage.new("L", (100, 80))
out = _preprocess_image(pil_img, (64, 64), channels=1)
assert out.shape == (1, 64, 64, 1)
assert out.dtype == np.float32
def test_output_values_are_normalized(self) -> None:
"""ImageNet normalization shifts black-pixel output below -1.0."""
pil_img = PILImage.new("RGB", (32, 32), color=(0, 0, 0))
out = _preprocess_image(pil_img, (32, 32))
# pixel 0 → 0.0 → (0.0 - 0.485) / 0.229 ≈ -2.12
assert out.min() < -1.0
def test_pil_fallback_when_torch_unavailable(self) -> None:
"""PIL path is used when torch is masked from sys.modules."""
pil_img = PILImage.new("RGB", (100, 80))
with mock.patch.dict(
sys.modules,
{
"torch": None,
"torchvision": None,
"torchvision.transforms": None,
"torchvision.transforms.functional": None,
},
):
out = _preprocess_image(pil_img, (64, 64))
assert out.shape == (1, 64, 64, 3)
assert out.dtype == np.float32