Files
Jesús Royeth 5a3e625e9d perf(inference): fuse common uint8 image conversion (#1390)
* perf(inference): fuse common uint8 image conversion
* test: cover one-channel uint8 predict dispatch


---------

Co-authored-by: Jesús Royeth <JESUSROYETH@users.noreply.github.com>
Co-authored-by: jirka <6035284+borda@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
2026-08-24 17:30:44 +02:00

1926 lines
91 KiB
Python

# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
import io
import warnings
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
import numpy as np
import PIL.Image
import pytest
import requests
import supervision as sv
import torch
import torchvision.transforms.functional as F # noqa: N812
import rfdetr.detr as detr_module
from rfdetr import RFDETRNano, RFDETRSegNano
from rfdetr.detr import RFDETR
from rfdetr.utilities.keypoints import precision_cholesky_to_pixel_covariance
from tests._online import is_online
from .helpers import _DummyModel, _DummyRFDETR
_HTTP_IMAGE_URL = "http://images.cocodataset.org/val2017/000000397133.jpg"
_HTTP_HOST = "images.cocodataset.org"
_HTTP_PORT = 80
class TestPredictReturnTypes:
"""``RFDETR.predict()`` API contract tests using synthetic images.
Quality is not assessed here — see ``tests/benchmarks/test_inference_coco.py``.
"""
def test_detection_returns_sv_detections(self) -> None:
"""Detection model returns a list of ``sv.Detections``."""
img = PIL.Image.new("RGB", (640, 640), color=(128, 128, 128))
model = RFDETRNano(pretrain_weights=None)
detections = model.predict([img, img], threshold=0.3)
assert isinstance(detections, list), "predict() must return a list for multiple inputs"
assert all(isinstance(d, sv.Detections) for d in detections), "Each result must be sv.Detections"
def test_segmentation_returns_sv_detections_with_masks(self) -> None:
"""Segmentation model returns ``sv.Detections`` with the mask field always set."""
img = PIL.Image.new("RGB", (640, 640), color=(128, 128, 128))
model = RFDETRSegNano(pretrain_weights=None)
detections = model.predict([img, img], threshold=0.3)
assert isinstance(detections, list), "predict() must return a list for multiple inputs"
assert all(isinstance(d, sv.Detections) for d in detections), "Each result must be sv.Detections"
assert all(d.mask is not None for d in detections), (
"Segmentation predict() must always set the mask field, even when no objects are detected"
)
def test_keypoint_single_and_batch_return_sv_keypoints(self) -> None:
"""Keypoint model returns one KeyPoints for one image and list[KeyPoints] for multiple images."""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
model.model = _DummyModel(labels=[0, 1], include_keypoints=True)
single = model.predict(img)
batch = model.predict([img, img])
assert isinstance(single, sv.KeyPoints)
assert isinstance(batch, list)
assert all(isinstance(result, sv.KeyPoints) for result in batch)
class TestPredictScoreThresholdEquivalence:
"""The mask pre-filter perf path must not change ``predict()``'s final output."""
def test_mask_prefilter_output_equivalent_at_predict_boundary(self) -> None:
"""``predict()`` masks/boxes/scores must be bit-identical whether the mask pre-filter runs or not.
The optimization forwards ``predict(threshold=...)`` into ``PostProcess`` so below-threshold masks skip
upsampling. Disabling the pre-filter (``score_threshold=None``) makes ``PostProcess`` upsample every mask and
lets ``predict()``'s own downstream filter drop them instead. The two paths must produce the same
``Detections``, proving the pre-filter drops only rows ``predict()`` itself discards — the equivalence that the
unit test at ``_postprocess_masks`` level asserts, now verified end-to-end through the real ``predict()`` path.
"""
img = PIL.Image.new("RGB", (640, 640), color=(128, 128, 128))
model = RFDETRSegNano(pretrain_weights=None)
threshold = 0.3
optimized = model.predict(img, threshold=threshold)
real_postprocess = model.model.postprocess
def postprocess_without_prefilter(predictions, target_sizes, score_threshold=None):
return real_postprocess(predictions, target_sizes, score_threshold=None)
model.model.postprocess = postprocess_without_prefilter
baseline = model.predict(img, threshold=threshold)
np.testing.assert_array_equal(optimized.xyxy, baseline.xyxy)
np.testing.assert_array_equal(optimized.confidence, baseline.confidence)
np.testing.assert_array_equal(optimized.class_id, baseline.class_id)
np.testing.assert_array_equal(optimized.mask, baseline.mask)
class _TupleOutputModelContext:
"""Model context whose forward returns a 3-tuple, mirroring ``forward_export()`` after ``inference()``.
Regression fixture for GitHub #1208: ``predict()`` mislabels the tuple's third element as ``pred_masks`` instead of
``pred_keypoints`` because it reads a nonexistent ``model.model_config`` attribute instead of ``model.args``.
"""
def __init__(self) -> None:
self.device = torch.device("cpu")
self.resolution = 28
self.class_names = ["object"]
self.args = SimpleNamespace(use_grouppose_keypoints=True, num_keypoints_per_class=[17])
self.model = torch.nn.Identity()
self.inference_model = self._forward
self.captured_predictions: dict[str, torch.Tensor] | None = None
self.captured_score_threshold: float | None = None
def _forward(self, batch_tensor: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
batch = batch_tensor.shape[0]
boxes = torch.tensor([[[0.5, 0.5, 0.2, 0.2]]] * batch)
logits = torch.full((batch, 1, 1), 10.0)
keypoints = torch.full((batch, 1, 17, 3), 0.5)
return boxes, logits, keypoints
def postprocess(
self,
predictions: dict[str, torch.Tensor],
target_sizes: torch.Tensor,
score_threshold: float | None = None,
) -> list[dict[str, torch.Tensor]]:
self.captured_predictions = predictions
self.captured_score_threshold = score_threshold
batch = target_sizes.shape[0]
results = []
for _ in range(batch):
result: dict[str, torch.Tensor] = {
"scores": torch.tensor([0.9]),
"labels": torch.tensor([0]),
"boxes": torch.tensor([[0.0, 0.0, 1.0, 1.0]]),
}
if "pred_keypoints" in predictions:
result["keypoints"] = torch.full((1, 17, 3), 0.5)
results.append(result)
return results
def _make_optimized_keypoint_model() -> tuple[RFDETR, _TupleOutputModelContext]:
"""Build a ``_DummyRFDETR`` wired to look like it already ran ``inference()``.
Examples:
>>> model, stub = _make_optimized_keypoint_model()
>>> model._is_optimized_for_inference
True
>>> isinstance(stub, _TupleOutputModelContext)
True
"""
model = _DummyRFDETR()
stub = _TupleOutputModelContext()
model.model = stub
model._is_optimized_for_inference = True
model._optimized_resolution = stub.resolution
model._optimized_has_been_compiled = False
model._optimized_dtype = torch.float32
return model, stub
class TestPredictOptimizedInferenceKeypoints:
"""Regression tests for GitHub #1208: inference() breaks keypoint predict()."""
def test_tuple_output_labels_third_slot_as_keypoints_not_masks(self) -> None:
"""The 3rd tuple slot must be labeled pred_keypoints, not pred_masks, when use_grouppose_keypoints=True."""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model, stub = _make_optimized_keypoint_model()
model.predict(img)
assert stub.captured_predictions is not None
assert "pred_keypoints" in stub.captured_predictions
assert "pred_masks" not in stub.captured_predictions
def test_optimized_keypoint_model_predict_returns_sv_keypoints(self) -> None:
"""Predict() must return sv.KeyPoints, not sv.Detections, for an optimized keypoint model."""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model, _stub = _make_optimized_keypoint_model()
result = model.predict(img)
assert isinstance(result, sv.KeyPoints), (
f"expected sv.KeyPoints for optimized keypoint model, got {type(result)}"
)
def test_predict_forwards_threshold_to_postprocess(self) -> None:
"""Predict must pass its public threshold to post-processing before mask work begins."""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model, stub = _make_optimized_keypoint_model()
model.predict(img, threshold=0.31)
assert stub.captured_score_threshold == 0.31
def test_predict_accepts_image_url() -> None:
if not is_online(_HTTP_HOST, _HTTP_PORT):
pytest.skip("Offline environment, skipping HTTP predict URL test.")
model = _DummyRFDETR()
detections = model.predict(_HTTP_IMAGE_URL)
assert isinstance(detections, sv.Detections)
assert detections.xyxy.shape == (1, 4)
class TestPredictSourceData:
"""Verify ``predict()`` source metadata behavior."""
def test_source_image_included_by_default(self) -> None:
"""source_image remains included by default for API compatibility."""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
detections = model.predict(img)
assert "source_image" in detections.metadata
assert isinstance(detections.metadata["source_image"], np.ndarray)
assert detections.metadata["source_image"].shape == (48, 64, 3)
assert np.array_equal(detections.data["source_shape"], np.array([[48, 64]]))
def test_source_image_included_by_default_tensor(self) -> None:
"""Tensor input keeps source_image by default for API compatibility."""
tensor = torch.rand(3, 48, 64)
model = _DummyRFDETR()
detections = model.predict(tensor)
assert "source_image" in detections.metadata
assert isinstance(detections.metadata["source_image"], np.ndarray)
assert detections.metadata["source_image"].dtype == np.uint8
assert detections.metadata["source_image"].shape == (48, 64, 3)
assert np.array_equal(detections.data["source_shape"], np.array([[48, 64]]))
def test_source_image_can_be_disabled(self) -> None:
"""include_source_image=False omits source_image for memory-sensitive paths."""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
detections = model.predict(img, include_source_image=False)
assert "source_image" not in detections.metadata
assert np.array_equal(detections.data["source_shape"], np.array([[48, 64]]))
def test_source_image_from_pil(self) -> None:
"""PIL input stores the original image as a numpy array."""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
detections = model.predict(img, include_source_image=True)
assert "source_image" in detections.metadata
assert isinstance(detections.metadata["source_image"], np.ndarray)
assert detections.metadata["source_image"].shape == (48, 64, 3)
def test_source_shape_from_pil(self) -> None:
"""PIL input stores source_shape as a per-detection numpy array."""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
detections = model.predict(img)
assert "source_shape" in detections.data
assert isinstance(detections.data["source_shape"], np.ndarray)
assert detections.data["source_shape"].dtype == np.int64
assert detections.data["source_shape"].shape == (len(detections), 2)
assert np.array_equal(detections.data["source_shape"][0], [48, 64])
def test_source_image_from_tensor(self) -> None:
"""Tensor input stores the original image as a uint8 numpy array."""
tensor = torch.rand(3, 48, 64)
model = _DummyRFDETR()
detections = model.predict(tensor, include_source_image=True)
assert "source_image" in detections.metadata
assert isinstance(detections.metadata["source_image"], np.ndarray)
assert detections.metadata["source_image"].dtype == np.uint8
assert detections.metadata["source_image"].shape == (48, 64, 3)
@pytest.mark.gpu
@pytest.mark.parametrize(
("dtype", "shape", "expected_transfer_dtype"),
[
pytest.param(torch.float16, (3, 48, 64), torch.uint8, id="float16"),
pytest.param(torch.float32, (3, 48, 64), torch.uint8, id="float32"),
pytest.param(torch.float64, (3, 48, 64), torch.uint8, id="float64"),
pytest.param(torch.float32, (3, 1, 64), torch.float32, id="degenerate_height"),
pytest.param(torch.float32, (3, 48, 1), torch.float32, id="degenerate_width"),
],
)
def test_cuda_source_image_transfers_exact_bytes(
self,
dtype: torch.dtype,
shape: tuple[int, int, int],
expected_transfer_dtype: torch.dtype,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The default public path transfers uint8 storage without changing source metadata."""
values = torch.rand(shape, generator=torch.Generator().manual_seed(20260821))
boundary_count = min(256, values.numel())
values.view(-1)[:boundary_count] = torch.arange(boundary_count, dtype=torch.float32) / 255
tensor = values.to(device="cuda", dtype=dtype)
expected = (tensor.permute(1, 2, 0).cpu().numpy() * 255).astype(np.uint8)
expected_source_shape = (shape[1], shape[2], shape[0])
transferred_dtypes: list[torch.dtype] = []
original_cpu = torch.Tensor.cpu
def record_source_transfer(image: torch.Tensor) -> torch.Tensor:
"""Record the source-image transfer dtype before delegating to PyTorch.
Examples:
This closure requires CUDA and is covered by the enclosing GPU test:
>>> record_source_transfer(torch.zeros(3, 48, 64, device="cuda")) # doctest: +SKIP
"""
if image.device.type == "cuda" and tuple(image.shape) == expected_source_shape:
transferred_dtypes.append(image.dtype)
return original_cpu(image)
monkeypatch.setattr(torch.Tensor, "cpu", record_source_transfer)
detections = _DummyRFDETR().predict(tensor)
actual = detections.metadata["source_image"]
assert transferred_dtypes == [expected_transfer_dtype]
assert actual.shape == expected.shape
assert actual.dtype == expected.dtype
assert actual.strides == expected.strides
assert actual.flags.owndata == expected.flags.owndata
assert actual.flags.writeable == expected.flags.writeable
assert actual.tobytes() == expected.tobytes()
@pytest.mark.gpu
def test_cuda_source_image_transfers_exact_bytes_strided_input(self) -> None:
"""The fast path matches the previous NumPy path byte-for-byte for a non-contiguous CUDA tensor."""
values = torch.rand((3, 96, 128), generator=torch.Generator().manual_seed(20260821))
tensor = values.to(device="cuda", dtype=torch.float32)[:, ::2, ::2]
assert not tensor.is_contiguous()
expected = (tensor.permute(1, 2, 0).cpu().numpy() * 255).astype(np.uint8)
detections = _DummyRFDETR().predict(tensor)
actual = detections.metadata["source_image"]
assert actual.shape == expected.shape
assert actual.dtype == expected.dtype
assert actual.strides == expected.strides
assert actual.flags.owndata == expected.flags.owndata
assert actual.flags.writeable == expected.flags.writeable
assert actual.tobytes() == expected.tobytes()
@pytest.mark.gpu
def test_cuda_source_image_nan_conversion_emits_no_warning(self) -> None:
"""The on-device cast does not emit NumPy's incidental invalid-cast RuntimeWarning that the old CPU cast did."""
tensor = torch.full((3, 4, 5), torch.nan, device="cuda", dtype=torch.float32)
expected = np.zeros((4, 5, 3), dtype=np.uint8)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
detections = _DummyRFDETR().predict(tensor)
assert caught == []
assert "source_image" in detections.metadata
actual = detections.metadata["source_image"]
assert actual.shape == expected.shape
assert actual.dtype == expected.dtype
assert actual.tobytes() == expected.tobytes()
def test_tensor_with_negative_values_raises(self) -> None:
"""Tensor with negative pixel values raises ValueError."""
tensor = torch.full((3, 48, 64), -0.1)
model = _DummyRFDETR()
with pytest.raises(ValueError, match="below 0"):
model.predict(tensor)
def test_source_image_batch(self) -> None:
"""Batch predict stores a source_image per detection."""
img1 = PIL.Image.new("RGB", (64, 48), color=(100, 100, 100))
img2 = PIL.Image.new("RGB", (32, 24), color=(200, 200, 200))
model = _DummyRFDETR()
detections_list = model.predict([img1, img2], include_source_image=True)
assert isinstance(detections_list, list)
assert detections_list[0].metadata["source_image"].shape == (48, 64, 3)
assert detections_list[1].metadata["source_image"].shape == (24, 32, 3)
assert np.array_equal(detections_list[0].data["source_shape"], np.array([[48, 64]]))
assert np.array_equal(detections_list[1].data["source_shape"], np.array([[24, 32]]))
def test_source_shape_survives_detections_iteration(self) -> None:
"""Iterating sv.Detections must not raise TypeError and must yield correct values.
Regression test for https://github.com/roboflow/rf-detr/issues/963. supervision's Detections.__iter__ calls
get_data_item() on every data value, which requires array-like types — storing source_shape as a Python tuple
raised TypeError: Unsupported data type for key 'source_shape': <class 'tuple'>.
"""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
detections = model.predict(img)
# sv.Detections.__iter__ yields (xyxy, mask, confidence, class_id, tracker_id, data)
iterated = list(detections)
assert len(iterated) == len(detections)
# Each iterated element's data dict must contain a 1-D [h, w] array
for det_tuple in iterated:
data = det_tuple[-1]
assert np.array_equal(data["source_shape"], [48, 64])
def test_source_image_survives_boolean_index(self) -> None:
"""Boolean-mask indexing must not raise IndexError when source_image is present.
Regression test for https://github.com/roboflow/rf-detr/issues/968. source_image was stored as (H, W, C) in
detections.data; supervision's __getitem__ tried to index it with a per-detection boolean mask, raising
IndexError because H != N.
"""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
model.model = _DummyModel(labels=[0, 1]) # 2 detections
detections = model.predict(img) # include_source_image=True by default
# Boolean-mask filtering — the pattern from issue #968
mask = detections.confidence > 0.5
filtered = detections[mask]
assert len(filtered) == int(mask.sum())
# source_image must survive the index operation unchanged (not dropped, not sliced)
assert "source_image" in filtered.metadata
assert filtered.metadata["source_image"].shape == (48, 64, 3)
def test_source_image_survives_class_id_boolean_index(self) -> None:
"""Boolean index on class_id must not raise IndexError — exact issue #968 pattern.
The reporter used ``detections.class_id == 1`` to filter by class, producing a partial boolean mask (1 of 2
detections). This is the primary reproduction path from the original bug report.
"""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
model.model = _DummyModel(labels=[0, 1]) # class_id 0 and 1
detections = model.predict(img)
# Exact pattern from issue #968: filter by class_id
mask = detections.class_id == 1 # partial mask — 1 of 2 detections
filtered = detections[mask]
assert len(filtered) == 1
assert "source_image" in filtered.metadata
assert filtered.metadata["source_image"].shape == (48, 64, 3)
def test_source_image_survives_integer_index(self) -> None:
"""Integer indexing must pass metadata["source_image"] through unchanged."""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
model.model = _DummyModel(labels=[0, 1]) # 2 detections
detections = model.predict(img)
single = detections[0]
assert "source_image" in single.metadata
assert single.metadata["source_image"].shape == (48, 64, 3)
def test_predict_keypoints_return_supervision_keypoints(self) -> None:
"""Keypoint predictions return ``sv.KeyPoints`` after threshold filtering."""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
model.model = _DummyModel(labels=[0, 1], include_keypoints=True)
key_points = model.predict(img)
assert isinstance(key_points, sv.KeyPoints)
assert key_points.xy.shape == (2, 17, 2)
assert np.allclose(key_points.xy, 0.5)
assert np.allclose(key_points.keypoint_confidence, 0.5)
np.testing.assert_array_equal(key_points.visible, np.full((2, 17), True))
np.testing.assert_array_equal(key_points.class_id, np.array([0, 1]))
np.testing.assert_allclose(key_points.data["xyxy"], np.array([[0, 0, 1, 1], [0, 0, 1, 1]], dtype=np.float32))
np.testing.assert_allclose(key_points.detection_confidence, np.array([0.9, 0.9], dtype=np.float32))
assert "keypoint_precision_cholesky" in key_points.data
keypoint_precision = key_points.data["keypoint_precision_cholesky"]
assert isinstance(keypoint_precision, np.ndarray)
assert keypoint_precision.shape == (2, 17, 3)
assert np.allclose(keypoint_precision, 0.25)
assert "covariance" in key_points.data
np.testing.assert_allclose(
key_points.data["covariance"],
precision_cholesky_to_pixel_covariance(
precision_cholesky=keypoint_precision, source_shape=key_points.data["source_shape"]
),
rtol=1e-4,
atol=1e-6,
)
def test_predict_keypoints_empty_threshold_return_supervision_keypoints(self) -> None:
"""Keypoint predictions remain ``sv.KeyPoints`` when all detections are filtered."""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
model.model = _DummyModel(labels=[0, 1], include_keypoints=True)
key_points = model.predict(img, threshold=1.1)
assert isinstance(key_points, sv.KeyPoints)
assert len(key_points) == 0
assert key_points.xy.shape == (0, 17, 2)
assert key_points.keypoint_confidence.shape == (0, 17)
assert key_points.detection_confidence.shape == (0,)
assert key_points.visible.shape == (0, 17)
np.testing.assert_allclose(key_points.data["xyxy"], np.empty((0, 4), dtype=np.float32))
assert key_points.as_detections().is_empty()
def test_predict_non_keypoint_no_keypoints_key_in_data(self) -> None:
"""Non-keypoint predictions do not attach keypoint fields."""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
detections = model.predict(img)
assert "keypoints" not in detections.data
assert not hasattr(detections, "keypoints")
def test_source_shape_survives_detections_indexing(self) -> None:
"""Integer and boolean-mask indexing of sv.Detections must work correctly.
Regression test for https://github.com/roboflow/rf-detr/issues/963. MeanAveragePrecision.compute() uses
__getitem__ (not just __iter__) on Detections objects — both paths go through get_data_item() and would have
crashed on the old tuple format.
"""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
model.model = _DummyModel(labels=[0, 1]) # 2 detections
detections = model.predict(img)
# Integer indexing: detections[i] returns a Detections with 1 element
single = detections[0]
assert np.array_equal(single.data["source_shape"], np.array([[48, 64]]))
# Boolean-mask indexing: used by supervision metrics to filter detections
mask = detections.confidence > 0.5
filtered = detections[mask]
assert filtered.data["source_shape"].shape == (int(mask.sum()), 2)
assert np.all(filtered.data["source_shape"] == np.array([48, 64]))
def test_source_shape_correct_for_zero_detections(self) -> None:
"""source_shape must have shape (0, 2) when threshold filters all detections.
Regression test for https://github.com/roboflow/rf-detr/issues/963. The zero-detection path must not raise and
must produce an empty array, not a scalar or a (1, 2) array.
"""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
# confidence=0.9 < 1.1 → all detections filtered
detections = model.predict(img, threshold=1.1)
assert "source_shape" in detections.data
assert isinstance(detections.data["source_shape"], np.ndarray)
assert detections.data["source_shape"].shape == (0, 2)
def test_source_shape_correct_for_multiple_detections(self) -> None:
"""source_shape must have shape (N, 2) for N detections, each row [height, width].
Regression test for https://github.com/roboflow/rf-detr/issues/963.
"""
img = PIL.Image.new("RGB", (64, 48), color=(128, 128, 128))
model = _DummyRFDETR()
model.model = _DummyModel(labels=[0, 1]) # 2 detections
detections = model.predict(img)
assert "source_shape" in detections.data
assert isinstance(detections.data["source_shape"], np.ndarray)
assert detections.data["source_shape"].shape == (2, 2)
assert np.all(detections.data["source_shape"] == np.array([48, 64]))
class TestPredictImagePinning:
"""``predict()`` should pin CPU-resident image tensors before an accelerator transfer.
A pageable-memory ``.to(device)`` copy onto CUDA is meaningfully slower than a pinned-memory one because the CUDA
driver has to pin the source buffer itself first. But ``Tensor.pin_memory()`` only accepts CPU tensors: a caller who
already placed the input tensor on the model's own accelerator (a legitimate use of the tensor-input path, e.g. to
skip a host round-trip) must never have that tensor routed through ``pin_memory()``, and a CPU-only target device
gets no benefit from pinning at all.
"""
def test_cpu_tensor_inputs_transfer_pinned_buffers_to_cuda_non_blocking(self) -> None:
"""Every CPU input transfers its own pinned buffer to CUDA and reaches the model boundary.
The pinning call returns a different tensor, so this catches the regression where ``predict()`` pins an input
but transfers the original pageable tensor instead. CUDA allocation is intercepted so this dispatch contract
also runs in CPU-only CI; the separate value-parity test exercises the real CUDA transfer.
"""
model = _DummyRFDETR()
model.model.device = torch.device("cuda", 0)
source_tensors = [torch.zeros(3, 48, 64), torch.ones(3, 48, 64)]
pinned_tensors = [
torch.full_like(source_tensors[0], 0.25),
torch.full_like(source_tensors[1], 0.75),
]
transferred_tensors = [
torch.full_like(source_tensors[0], 0.125),
torch.full_like(source_tensors[1], 0.875),
]
real_to = torch.Tensor.to
real_tensor = torch.tensor
pinned_inputs: list[torch.Tensor] = []
cuda_transfer_sources: list[torch.Tensor] = []
model_inputs: list[torch.Tensor] = []
def pin_spy(self: torch.Tensor, *args: object, **kwargs: object) -> torch.Tensor:
"""Return the distinct pseudo-pinned buffer paired with a source tensor.
Examples:
This test-local closure requires its enclosing tensors.
>>> pin_spy(source_tensors[0]) # doctest: +SKIP
"""
source_index = next(
(index for index, source_tensor in enumerate(source_tensors) if self is source_tensor),
None,
)
assert source_index is not None, "pin_memory() must be called on an original input tensor"
pinned_inputs.append(self)
return pinned_tensors[source_index]
def to_spy(self: torch.Tensor, *args: object, **kwargs: object) -> torch.Tensor:
"""Return a distinct pseudo-CUDA buffer for each pinned transfer source.
Examples:
This test-local closure requires its enclosing tensors.
>>> to_spy(pinned_tensors[0], torch.device("cuda"), non_blocking=True) # doctest: +SKIP
"""
if args and isinstance(args[0], torch.device) and args[0].type == "cuda":
cuda_transfer_sources.append(self)
transfer_index = next(
(index for index, pinned_tensor in enumerate(pinned_tensors) if self is pinned_tensor),
None,
)
if transfer_index is not None:
assert kwargs.get("non_blocking") is True
return transferred_tensors[transfer_index]
return self
return real_to(self, *args, **kwargs)
def tensor_spy(data: object, **kwargs: object) -> torch.Tensor:
"""Redirect CUDA target-size construction to CPU for the simulated transfer.
Examples:
This test-local closure requires the captured real tensor constructor.
>>> tensor_spy([[48, 64]], device=torch.device("cuda")) # doctest: +SKIP
"""
device = kwargs.get("device")
if isinstance(device, torch.device) and device.type == "cuda":
kwargs["device"] = torch.device("cpu")
return real_tensor(data, **kwargs)
def capture_model_input(_module: torch.nn.Module, args: tuple[torch.Tensor, ...]) -> None:
"""Capture the normalized batch delivered to the model boundary.
Examples:
This test-local closure requires the enclosing capture list.
>>> capture_model_input(torch.nn.Identity(), (torch.zeros(1, 3, 28, 28),)) # doctest: +SKIP
"""
model_inputs.append(args[0].detach().clone())
model_module = model.model.model
assert model_module is not None
with model_module.register_forward_pre_hook(capture_model_input):
with (
patch.object(torch.Tensor, "pin_memory", pin_spy),
patch.object(torch.Tensor, "to", to_spy),
patch.object(torch, "tensor", tensor_spy),
):
model.predict(source_tensors)
assert len(pinned_inputs) == len(source_tensors)
assert all(pinned_input is source_tensor for pinned_input, source_tensor in zip(pinned_inputs, source_tensors))
assert len(cuda_transfer_sources) == len(pinned_tensors)
assert all(
cuda_transfer_source is pinned_tensor
for cuda_transfer_source, pinned_tensor in zip(cuda_transfer_sources, pinned_tensors)
)
assert len(model_inputs) == 1
captured_batch = model_inputs[0].cpu()
expected_batch = torch.stack(
[
(
torch.full((3, model.model.resolution, model.model.resolution), value)
- torch.tensor(model.means)[:, None, None]
)
/ torch.tensor(model.stds)[:, None, None]
for value in (0.125, 0.875)
]
)
torch.testing.assert_close(captured_batch, expected_batch)
@pytest.mark.gpu
def test_pinned_non_blocking_transfer_preserves_values(self) -> None:
"""Pinning + non_blocking must not change the transferred values versus a plain, blocking .to()."""
torch.manual_seed(0)
source = torch.rand(3, 48, 64)
device = torch.device("cuda", 0)
plain = source.to(device)
pinned = source.pin_memory().to(device, non_blocking=True)
torch.cuda.synchronize()
assert torch.equal(plain, pinned)
@pytest.mark.gpu
def test_already_cuda_tensor_input_is_not_pinned(self) -> None:
"""A tensor the caller already placed on the accelerator must never be routed through pin_memory()."""
model = _DummyRFDETR()
model.model.device = torch.device("cuda", 0)
tensor = torch.rand(3, 48, 64, device="cuda")
pin_spy = MagicMock(side_effect=AssertionError("pin_memory() must not be called on a CUDA tensor"))
with patch.object(torch.Tensor, "pin_memory", pin_spy):
model.predict(tensor) # must not raise: pin_memory() on a CUDA tensor would itself raise RuntimeError
pin_spy.assert_not_called()
def test_cpu_target_device_does_not_pin(self) -> None:
"""A CPU-only target device gets no benefit from pinning, so it must be skipped entirely."""
model = _DummyRFDETR() # _DummyModel defaults to a CPU device
tensor = torch.rand(3, 48, 64)
pin_spy = MagicMock(side_effect=AssertionError("pin_memory() must not be called for a CPU target device"))
with patch.object(torch.Tensor, "pin_memory", pin_spy):
model.predict(tensor)
pin_spy.assert_not_called()
@pytest.mark.gpu
def test_cuda_tensor_input_to_cpu_model_is_not_non_blocking(self) -> None:
"""A tensor already on CUDA moving to a CPU-device model must use a blocking transfer.
``non_blocking=True`` only pays off, and is only safe without an explicit sync, when the destination is CUDA —
matching ``transfer_batch_to_device()`` in ``training/module_data.py``. Here the ``.to()`` call allocates a
fresh, unpinned CPU tensor as its destination, so an async D2H copy could leave that tensor holding an in-flight
(partially written) result if read before the copy stream drains.
"""
model = _DummyRFDETR() # _DummyModel defaults to a CPU device
tensor = torch.rand(3, 48, 64, device="cuda")
real_to = torch.Tensor.to
captured: list[bool] = []
def to_spy(self: torch.Tensor, *args: object, **kwargs: object) -> torch.Tensor:
if self is tensor:
captured.append(bool(kwargs.get("non_blocking", False)))
return real_to(self, *args, **kwargs)
with patch.object(torch.Tensor, "to", to_spy):
model.predict(tensor)
assert captured, "expected predict() to move the image tensor with .to()"
assert not any(captured), "CUDA tensor -> CPU-model transfer must not set non_blocking=True"
class TestPredictUint8Conversion:
"""The fused uint8 path must retain torchvision's exact conversion semantics."""
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16, torch.float32, torch.float64])
@pytest.mark.parametrize("grayscale", [False, True])
def test_converter_is_bit_exact_for_supported_default_dtypes(self, dtype: torch.dtype, grayscale: bool) -> None:
"""Layout/dtype fusion and in-place division produce the same raw floating-point bits."""
rng = np.random.default_rng(20260821)
image = rng.integers(0, 256, size=(17, 29, 3), dtype=np.uint8)[:, ::2]
if grayscale:
image = image[:, :, 0]
previous_dtype = torch.get_default_dtype()
try:
torch.set_default_dtype(dtype)
expected = F.to_tensor(image)
actual = detr_module._uint8_image_to_tensor(image)
finally:
torch.set_default_dtype(previous_dtype)
assert actual.dtype == expected.dtype
assert actual.shape == expected.shape
assert actual.stride() == expected.stride()
assert actual.contiguous().view(torch.uint8).equal(expected.contiguous().view(torch.uint8))
@pytest.mark.parametrize(
"image",
[
pytest.param(PIL.Image.new("RGB", (29, 17), color=(1, 127, 255)), id="pil_rgb"),
pytest.param(np.full((17, 29, 3), 127, dtype=np.uint8), id="numpy_uint8"),
],
)
@pytest.mark.parametrize("include_source_image", [False, True])
def test_predict_uses_fused_path_for_uint8_images(self, image: object, include_source_image: bool) -> None:
"""PIL and valid uint8 NumPy inputs do not fall back to torchvision's allocating path."""
model = _DummyRFDETR()
to_tensor_spy = MagicMock(side_effect=AssertionError("uint8 input unexpectedly used F.to_tensor"))
with patch("rfdetr.detr.F.to_tensor", to_tensor_spy):
model.predict(image, include_source_image=include_source_image)
to_tensor_spy.assert_not_called()
@pytest.mark.parametrize(
"image",
[
pytest.param(np.full((17, 29), 127, dtype=np.uint8), id="numpy_uint8_grayscale"),
pytest.param(np.full((17, 29, 1), 127, dtype=np.uint8), id="numpy_uint8_single_channel_hwc"),
],
)
def test_predict_uses_fused_path_for_single_channel_uint8_images(self, image: np.ndarray[Any, Any]) -> None:
"""One-channel uint8 images use the fused converter at the public ``predict()`` boundary."""
model = _DummyRFDETR()
model.model_config.num_channels = 1
model.means = model.means[:1]
model.stds = model.stds[:1]
to_tensor_spy = MagicMock(side_effect=AssertionError("uint8 input unexpectedly used F.to_tensor"))
with (
patch("rfdetr.detr._uint8_image_to_tensor", wraps=detr_module._uint8_image_to_tensor) as converter_spy,
patch("rfdetr.detr.F.to_tensor", to_tensor_spy),
):
model.predict(image)
converter_spy.assert_called_once_with(image)
to_tensor_spy.assert_not_called()
def test_converter_matches_torchvision_for_contiguous_single_channel_hwc(self) -> None:
"""A freshly-allocated (not sliced) ``(H, W, 1)`` array exercises ``num_channels=1`` models
(``ModelConfig.num_channels``, ``config.py``).
Unlike a channel-sliced view, this array is already C-contiguous on input, which is exactly when torchvision's
own ``to_tensor`` leaves the size-1 leading dimension's stride un-normalized -- so equivalence is checked on
every dimension that actually has memory-layout meaning (size > 1), plus dtype/shape/contiguity/raw bits.
"""
rng = np.random.default_rng(20260821)
image = rng.integers(0, 256, size=(17, 29, 1), dtype=np.uint8)
expected = F.to_tensor(image)
actual = detr_module._uint8_image_to_tensor(image)
assert actual.dtype == expected.dtype
assert actual.shape == expected.shape
assert actual.is_contiguous() == expected.is_contiguous()
assert actual.stride()[1:] == expected.stride()[1:]
assert actual.contiguous().view(torch.uint8).equal(expected.contiguous().view(torch.uint8))
def test_predict_reuses_pil_source_array_for_conversion(self) -> None:
"""Default PIL prediction converts the same NumPy allocation retained as source metadata."""
model = _DummyRFDETR()
image = PIL.Image.new("RGB", (29, 17), color=(1, 127, 255))
with patch("rfdetr.detr._uint8_image_to_tensor", wraps=detr_module._uint8_image_to_tensor) as converter_spy:
detections = model.predict(image)
converter_spy.assert_called_once()
assert converter_spy.call_args.args[0] is detections.metadata["source_image"]
def test_predict_keeps_original_readonly_numpy_as_tensor_source(self) -> None:
"""NumPy conversion retains the caller's storage, so it still triggers ``torch.from_numpy``'s not-writable
``UserWarning`` exactly like ``F.to_tensor`` would on the same array."""
model = _DummyRFDETR()
image = np.full((17, 29, 3), 127, dtype=np.uint8)
image.flags.writeable = False
with (
patch("rfdetr.detr._uint8_image_to_tensor", wraps=detr_module._uint8_image_to_tensor) as converter_spy,
pytest.warns(UserWarning, match="not writable"),
):
detections = model.predict(image)
converter_spy.assert_called_once()
assert converter_spy.call_args.args[0] is image
assert detections.metadata["source_image"] is not image
assert detections.metadata["source_image"].flags.writeable
def test_predict_keeps_torchvision_fallback_for_float_numpy(self) -> None:
"""Non-uint8 NumPy inputs retain torchvision's no-scaling conversion semantics."""
model = _DummyRFDETR()
image = np.full((17, 29, 3), 0.5, dtype=np.float32)
with patch("rfdetr.detr.F.to_tensor", wraps=F.to_tensor) as to_tensor_spy:
model.predict(image)
to_tensor_spy.assert_called_once_with(image)
def test_predict_keeps_torchvision_fallback_for_invalid_uint8_numpy_rank(self) -> None:
"""A uint8 NumPy input outside the documented 2-D/3-D shape keeps torchvision's validation error."""
model = _DummyRFDETR()
image = np.zeros((1, 3, 17, 29), dtype=np.uint8)
with (
patch("rfdetr.detr.F.to_tensor", wraps=F.to_tensor) as to_tensor_spy,
pytest.raises(ValueError, match="2/3 dimensional"),
):
model.predict(image)
to_tensor_spy.assert_called_once_with(image)
class TestPredictPixelRangeValidation:
"""``predict()`` must still reject out-of-[0, 1]-range tensor inputs, now that the range check is deferred (see the
``pending_checks`` comment in ``detr.py``) instead of raised inline, per image, inside the conversion loop."""
def test_raises_for_pixel_value_above_one(self) -> None:
"""A tensor with any pixel above 1 still raises after the range check moved off the hot path."""
model = _DummyRFDETR()
img = torch.zeros(3, 8, 8)
img[0, 0, 0] = 1.5
with pytest.raises(ValueError, match="pixel values above 1"):
model.predict(img)
def test_valid_images_do_not_raise(self) -> None:
"""A batch of in-range tensors must not raise, deferred check included."""
model = _DummyRFDETR()
img = torch.full((3, 8, 8), 0.5)
model.predict([img, img]) # must not raise
@pytest.mark.parametrize(
"image",
[
pytest.param(PIL.Image.new("F", (8, 8), color=10_000.0), id="pil"),
pytest.param(np.full((8, 8, 3), 255, dtype=np.uint8), id="uint8_numpy"),
],
)
def test_known_valid_converted_image_skips_range_scans(self, image: PIL.Image.Image | np.ndarray[Any, Any]) -> None:
"""PIL conversion and uint8 NumPy scaling already guarantee values in [0, 1]."""
model = _DummyRFDETR()
# ``torchvision.normalize`` has its own unrelated ``std.any()`` guard, so replace it to isolate the image-range
# scans exercised by this test.
with (
patch("rfdetr.detr.F.normalize", return_value=torch.zeros(1, 3, 28, 28)),
patch.object(torch.Tensor, "any", side_effect=AssertionError("unexpected range scan")),
):
model.predict(image, include_source_image=False)
def test_known_valid_url_skips_range_scans(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A successful URL fetch becomes a PIL image and must bypass range scans."""
model = _DummyRFDETR()
def fake_get(url: str, **kwargs: object) -> _FakeResponse:
"""Return a valid image response for the URL conversion boundary.
Examples:
>>> fake_get("https://example.com/image.png").status_code
200
"""
return _FakeResponse(content=_png_bytes(), status_code=200)
monkeypatch.setattr(requests, "get", fake_get)
with (
patch("rfdetr.detr.F.normalize", return_value=torch.zeros(1, 3, 28, 28)),
patch.object(torch.Tensor, "any", side_effect=AssertionError("unexpected range scan")),
):
model.predict("https://example.com/image.png", include_source_image=False)
def test_mixed_known_valid_and_float_numpy_images_still_check_each_image(self) -> None:
"""A known-valid first image must not suppress a later float image's range error."""
model = _DummyRFDETR()
known_valid = PIL.Image.new("RGB", (8, 8), color=(255, 255, 255))
out_of_range = np.full((8, 8, 3), 1.5, dtype=np.float32)
with pytest.raises(ValueError, match="pixel values above 1"):
model.predict([known_valid, out_of_range], include_source_image=False)
def test_known_valid_file_path_skips_range_scans(self, tmp_path: Path) -> None:
"""A file-path input is opened into a PIL image before the range check, so it skips the scan too."""
img_path = tmp_path / "known_valid.png"
PIL.Image.new("RGB", (8, 8), color=(255, 255, 255)).save(str(img_path))
model = _DummyRFDETR()
with (
patch("rfdetr.detr.F.normalize", return_value=torch.zeros(1, 3, 28, 28)),
patch.object(torch.Tensor, "any", side_effect=AssertionError("unexpected range scan")),
):
model.predict(str(img_path), include_source_image=False)
def test_known_valid_image_skips_range_scans_with_source_image_capture(self) -> None:
"""The scan skip must still apply under ``include_source_image=True``, the public default."""
model = _DummyRFDETR()
image = PIL.Image.new("RGB", (8, 8), color=(255, 255, 255))
with (
patch("rfdetr.detr.F.normalize", return_value=torch.zeros(1, 3, 28, 28)),
patch.object(torch.Tensor, "any", side_effect=AssertionError("unexpected range scan")),
):
model.predict(image)
@pytest.mark.parametrize(
("value", "expected_message"),
[
pytest.param(1.5, "pixel values above 1", id="above_one"),
pytest.param(-0.5, "pixel values below 0", id="below_zero"),
],
)
def test_float_numpy_image_still_checks_range(self, value: float, expected_message: str) -> None:
"""Floating NumPy input can preserve values outside [0, 1] on either bound, so it still needs validation."""
model = _DummyRFDETR()
image = np.full((8, 8, 3), value, dtype=np.float32)
with pytest.raises(ValueError, match=expected_message):
model.predict(image, include_source_image=False)
@pytest.mark.parametrize(
("first_image_violation", "second_image_violation", "expected_message"),
[
pytest.param(1.5, -0.5, "pixel values above 1", id="first_image_above_wins"),
pytest.param(-0.5, 1.5, "pixel values below 0", id="first_image_below_wins"),
],
)
def test_first_offending_image_in_input_order_determines_which_message_raises(
self, first_image_violation: float, second_image_violation: float, expected_message: str
) -> None:
"""Matches the original inline check's per-image precedence: image 0 is fully checked (both conditions) before
image 1 is considered at all, so image 0's own violation always raises first -- distinguishing this from a buggy
"condition-first" implementation that would scan every image's "above" violations before any "below" ones, which
would raise "above 1" from image 1 in the second case below instead of "below 0" from image 0."""
first_image = torch.zeros(3, 8, 8)
first_image[0, 0, 0] = first_image_violation
second_image = torch.zeros(3, 8, 8)
second_image[0, 0, 0] = second_image_violation
model = _DummyRFDETR()
with pytest.raises(ValueError, match=expected_message):
model.predict([first_image, second_image])
def test_all_images_are_processed_before_a_later_raise(self) -> None:
"""Regression pin for the deferred-check design: unlike the original per-image inline raise, which stopped
converting/transferring images the moment an earlier one failed, every image now has its conversion and transfer
queued before any range-check result is forced to a Python bool -- so a later, valid image is still fully
processed even though an earlier image ultimately makes the whole call raise."""
invalid_first = torch.zeros(3, 8, 8)
invalid_first[0, 0, 0] = 1.5
valid_second = torch.full((3, 8, 8), 0.5)
model = _DummyRFDETR()
real_to = torch.Tensor.to
transferred: list[bool] = []
def to_spy(self: torch.Tensor, *args: object, **kwargs: object) -> torch.Tensor:
"""Record whether ``valid_second`` reached its ``.to()`` transfer.
Examples:
This test-local closure requires its enclosing tensors.
>>> to_spy(valid_second, torch.device("cuda")) # doctest: +SKIP
"""
if self is valid_second:
transferred.append(True)
return real_to(self, *args, **kwargs)
with patch.object(torch.Tensor, "to", to_spy), pytest.raises(ValueError, match="pixel values above 1"):
model.predict([invalid_first, valid_second])
assert transferred, "the later, valid image must still be transferred before the earlier image's error raises"
@pytest.mark.gpu
def test_mixed_cpu_and_cuda_tensor_images_are_each_checked_on_their_own_device(self) -> None:
"""Pins the reason ``pending_checks`` keeps one check per image instead of ``torch.stack``-ing them:
images in a single ``predict()`` call are not guaranteed to share a device, and stacking a CPU-resident and a
CUDA-resident check result would raise ``RuntimeError`` instead of validating each on its own device.
"""
model = _DummyRFDETR()
model.model.device = torch.device("cuda", 0)
cpu_valid = torch.full((3, 8, 8), 0.5)
cuda_invalid = torch.full((3, 8, 8), 0.5, device="cuda")
cuda_invalid[0, 0, 0] = 1.5
with pytest.raises(ValueError, match="pixel values above 1"):
model.predict([cpu_valid, cuda_invalid])
# And the same mix with no violation must not raise.
cuda_valid = torch.full((3, 8, 8), 0.5, device="cuda")
model.predict([cpu_valid, cuda_valid])
def test_range_violation_raises_before_shape_violation_on_the_same_image(self) -> None:
"""Matches the original inline check's within-image precedence: the original code raised the range error before
ever reaching the shape check for that same image.
Both checks are now deferred past the conversion loop, so this pins that the deferred range check still runs
first in the post-loop raise order -- a tensor that is invalid on both axes at once must not surface the shape
message instead.
"""
model = _DummyRFDETR()
wrong_channels_and_out_of_range = torch.zeros(4, 8, 8)
wrong_channels_and_out_of_range[0, 0, 0] = 1.5
with pytest.raises(ValueError, match="pixel values above 1"):
model.predict(wrong_channels_and_out_of_range)
def test_non_3d_tensor_raises_shape_error_not_an_unpacking_crash(self) -> None:
"""A 2D tensor (no channel dimension at all, not just the wrong channel count) must still raise
the clear "Invalid tensor image shape" ``ValueError`` -- not an internal ``ValueError: not
enough values to unpack`` from ``h, w = img_tensor.shape[1:]`` unconditionally unpacking a
shape that was never checked to have 3 dims in the first place, now that the shape-check raise
is deferred past that unpacking instead of happening inline before it.
"""
model = _DummyRFDETR()
no_channel_dim = torch.zeros(4, 8) # (H, W)-shaped, not (C, H, W)
with pytest.raises(ValueError, match="Invalid tensor image shape"):
model.predict(no_channel_dim, include_source_image=False)
def test_non_3d_tensor_raises_shape_error_with_default_source_image(self) -> None:
"""Malformed tensor ranks must validate before default source-image conversion."""
model = _DummyRFDETR()
no_channel_dim = torch.zeros(4, 8)
with pytest.raises(ValueError, match="Invalid tensor image shape"):
model.predict(no_channel_dim)
class TestPredictShape:
"""Verify that ``predict(shape=...)`` controls the resize target.
Regression tests for https://github.com/roboflow/rf-detr/issues/682.
"""
def test_predict_uses_resolution_when_no_shape_provided(self) -> None:
"""Without ``shape=``, resize uses ``(resolution, resolution)``."""
from unittest.mock import patch
import torchvision.transforms.functional as F # noqa: N812
model = _DummyRFDETR()
img = PIL.Image.new("RGB", (100, 80), color=(64, 64, 64))
with patch("rfdetr.detr.F.resize", wraps=F.resize) as mock_resize:
model.predict(img)
resize_size = list(mock_resize.call_args[0][1])
assert resize_size == [28, 28], f"Expected resize to model resolution (28, 28), got {resize_size}"
def test_predict_uses_provided_rectangular_shape(self) -> None:
# Regression test for #682
from unittest.mock import patch
import torchvision.transforms.functional as F # noqa: N812
model = _DummyRFDETR()
img = PIL.Image.new("RGB", (100, 80), color=(64, 64, 64))
with patch("rfdetr.detr.F.resize", wraps=F.resize) as mock_resize:
model.predict(img, shape=(378, 672))
resize_size = list(mock_resize.call_args[0][1])
assert resize_size == [378, 672], (
f"Expected resize to user-provided shape (378, 672), got {resize_size}. "
"predict() must honour the shape parameter instead of falling back "
"to (resolution, resolution)."
)
def test_predict_shape_square_override(self) -> None:
# Regression test for #682 — square shape different from model resolution.
from unittest.mock import patch
import torchvision.transforms.functional as F # noqa: N812
model = _DummyRFDETR()
img = PIL.Image.new("RGB", (100, 80), color=(64, 64, 64))
with patch("rfdetr.detr.F.resize", wraps=F.resize) as mock_resize:
model.predict(img, shape=(56, 56))
resize_size = list(mock_resize.call_args[0][1])
assert resize_size == [56, 56], (
f"Expected resize to user-provided shape (56, 56), got {resize_size}. "
"predict() must honour the shape parameter even for square sizes "
"that differ from the model's default resolution."
)
@pytest.mark.parametrize(
"int_shape",
[
pytest.param((np.int64(378), np.int64(672)), id="numpy_int64"),
pytest.param((np.int32(378), np.int32(672)), id="numpy_int32"),
pytest.param((torch.tensor(378), torch.tensor(672)), id="torch_scalar"),
],
)
def test_predict_shape_accepts_integer_like_types(self, int_shape: tuple) -> None:
"""Predict() accepts integer-like types (numpy, torch) via the __index__ protocol."""
from unittest.mock import patch
import torchvision.transforms.functional as F # noqa: N812
model = _DummyRFDETR()
img = PIL.Image.new("RGB", (100, 80), color=(64, 64, 64))
with patch("rfdetr.detr.F.resize", wraps=F.resize) as mock_resize:
model.predict(img, shape=int_shape) # type: ignore[arg-type]
resize_size = list(mock_resize.call_args[0][1])
assert resize_size == [378, 672], f"predict() must accept integer-like shape types, got resize {resize_size}"
@pytest.mark.parametrize(
"bad_shape",
[
pytest.param((378, 671), id="width_not_div_14"), # 671 % 14 != 0
pytest.param((371, 672), id="height_not_div_14"), # 371 % 14 != 0
],
)
def test_predict_shape_not_divisible_by_14_raises(self, bad_shape: tuple[int, int]) -> None:
"""Predict() must reject shapes with dimensions not divisible by 14."""
model = _DummyRFDETR()
img = PIL.Image.new("RGB", (100, 80), color=(64, 64, 64))
with pytest.raises(ValueError, match="divisible by 14"):
model.predict(img, shape=bad_shape)
@pytest.mark.parametrize(
"bad_shape",
[
pytest.param((378.0, 672.0), id="float_dims"),
pytest.param((378,), id="wrong_arity_one_element"),
pytest.param((378, 672, 3), id="wrong_arity_three_elements"),
pytest.param((0, 56), id="zero_height"),
pytest.param((-14, 56), id="negative_height"),
pytest.param((56, 0), id="zero_width"),
pytest.param((56, -14), id="negative_width"),
pytest.param((True, 56), id="bool_height"),
pytest.param((56, False), id="bool_width"),
],
)
def test_predict_shape_invalid_raises(self, bad_shape: tuple[int | float | bool, ...]) -> None:
"""Predict() must raise ValueError for invalid shape values."""
model = _DummyRFDETR()
img = PIL.Image.new("RGB", (100, 80), color=(64, 64, 64))
with pytest.raises(ValueError, match="shape"):
model.predict(img, shape=bad_shape) # type: ignore[arg-type]
class TestPredictResizeMatchesTrainingInterpolation:
"""Verify ``predict()`` resize matches the training-time interpolation.
Regression test for https://github.com/roboflow/rf-detr/issues/1203.
Training/validation uses Albumentations ``Resize`` (cv2 bilinear, no
antialiasing). torchvision's ``F.resize`` defaults to antialiased
bilinear, which drifts bbox/confidence values relative to the
pretrained checkpoints' reference preprocessing. ``predict()`` must
disable antialiasing to match the training resize.
"""
def test_predict_resize_disables_antialias(self) -> None:
"""``predict()`` calls ``F.resize`` with ``antialias=False``."""
from unittest.mock import patch
import torchvision.transforms.functional as F # noqa: N812
model = _DummyRFDETR()
img = PIL.Image.new("RGB", (100, 80), color=(64, 64, 64))
with patch("rfdetr.detr.F.resize", wraps=F.resize) as mock_resize:
model.predict(img)
assert mock_resize.call_args.kwargs.get("antialias") is False, (
"predict() must resize with antialias=False to match the antialias-free "
"bilinear resize (cv2.INTER_LINEAR) used during training. Antialiasing "
"on drifts bbox/confidence values away from the pretrained checkpoints' "
"reference preprocessing."
)
class TestPredictPatchSize:
"""Predict() patch_size resolution and validation tests."""
def _make_model_with_config(self, patch_size: int, num_windows: int) -> _DummyRFDETR:
"""Return a _DummyRFDETR whose model_config carries patch_size and num_windows."""
from types import SimpleNamespace
model = _DummyRFDETR()
model.model_config = SimpleNamespace(patch_size=patch_size, num_windows=num_windows, num_channels=3)
return model
def test_predict_defaults_patch_size_from_model_config(self) -> None:
"""Predict() reads patch_size from model_config when not provided by the caller."""
# patch_size=16, num_windows=2 → block_size=32; shape=(64,64) is valid
model = self._make_model_with_config(patch_size=16, num_windows=2)
img = PIL.Image.new("RGB", (100, 80), color=(64, 64, 64))
# Should not raise — 64 % 32 == 0
model.predict(img, shape=(64, 64))
def test_predict_shape_must_be_divisible_by_block_size(self) -> None:
"""Predict() rejects shapes not divisible by patch_size * num_windows."""
# patch_size=16, num_windows=2 → block_size=32; shape (48, 64) fails (48%32==16)
model = self._make_model_with_config(patch_size=16, num_windows=2)
img = PIL.Image.new("RGB", (100, 80), color=(64, 64, 64))
with pytest.raises(ValueError, match="divisible by 32"):
model.predict(img, shape=(48, 64))
@pytest.mark.parametrize("bad_patch_size", [0, -1, True, False])
def test_predict_invalid_patch_size_raises(self, bad_patch_size: int) -> None:
"""Predict() must raise ValueError when patch_size is not a positive integer."""
model = _DummyRFDETR()
img = PIL.Image.new("RGB", (100, 80), color=(64, 64, 64))
with pytest.raises(ValueError, match="patch_size must be a positive integer"):
model.predict(img, patch_size=bad_patch_size) # type: ignore[arg-type]
def test_predict_patch_size_mismatch_raises(self) -> None:
"""Predict() must raise ValueError when caller's patch_size != model_config.patch_size."""
# model has patch_size=16; passing patch_size=14 should raise immediately
model = self._make_model_with_config(patch_size=16, num_windows=1)
img = PIL.Image.new("RGB", (100, 80), color=(64, 64, 64))
with pytest.raises(ValueError, match="does not match"):
model.predict(img, shape=(16, 16), patch_size=14)
def test_predict_explicit_patch_size_matching_config_succeeds(self) -> None:
"""predict(patch_size=X) must succeed when X matches model_config.patch_size."""
# patch_size=16, num_windows=2 → block_size=32; shape=(64,64) is valid
model = self._make_model_with_config(patch_size=16, num_windows=2)
img = PIL.Image.new("RGB", (100, 80), color=(64, 64, 64))
# Should not raise — patch_size matches config, 64 % 32 == 0
model.predict(img, shape=(64, 64), patch_size=16)
@pytest.mark.parametrize("bad_num_windows", [0, -1, True])
def test_predict_invalid_num_windows_raises(self, bad_num_windows: int) -> None:
"""Predict() must raise ValueError when model_config.num_windows is not a positive integer."""
model = self._make_model_with_config(patch_size=14, num_windows=1)
model.model_config.num_windows = bad_num_windows
img = PIL.Image.new("RGB", (100, 80), color=(64, 64, 64))
with pytest.raises(ValueError, match="num_windows must be a positive integer"):
model.predict(img, shape=(14, 14))
def test_predict_default_resolution_not_divisible_by_block_size_raises(self) -> None:
"""Predict() with shape=None must raise ValueError when model.resolution % block_size != 0."""
# patch_size=14, num_windows=1 → block_size=14; set resolution=25 (not divisible)
model = self._make_model_with_config(patch_size=14, num_windows=1)
model.model.resolution = 25
img = PIL.Image.new("RGB", (100, 80), color=(64, 64, 64))
with pytest.raises(ValueError, match="default resolution"):
model.predict(img)
class TestPredictClassNameData:
"""Verify that ``predict()`` populates ``data["class_name"]`` in the returned Detections.
class IDs are always 0-indexed (COCO category IDs are remapped during training); including the class name string in
``data`` lets callers read the class directly without a separate lookup into ``model.class_names``.
"""
def _make_model_with_class_names(self, class_names: list[str], labels: list[int]) -> _DummyRFDETR:
"""Return a _DummyRFDETR whose inner model carries custom class_names and returns given labels."""
model = _DummyRFDETR()
model.model = _DummyModel(class_names=class_names, labels=labels)
return model
def test_class_name_key_present_in_detections_data(self) -> None:
"""Predict() must include 'class_name' in detections.data when class_names is set."""
model = self._make_model_with_class_names(["cat", "dog"], labels=[0])
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
assert "class_name" in detections.data, "data['class_name'] must be present"
def test_class_name_values_match_class_id(self) -> None:
"""class_name at each position must equal class_names[class_id]."""
model = self._make_model_with_class_names(["cat", "dog", "bird"], labels=[0, 1, 2])
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
np.testing.assert_array_equal(
detections.data["class_name"],
np.array(["cat", "dog", "bird"]),
err_msg="class_name must match class_names[class_id] for each detection",
)
def test_class_name_with_remapped_coco_dataset(self) -> None:
"""Simulates a single-class COCO dataset where category_id=1 is remapped to label=0.
After training with remap_category_ids=True, the model outputs class_id=0 for the first class. class_name must
correctly map 0 → the first class name.
"""
# Single-class model: category_id=1 was remapped to label=0 during training.
model = self._make_model_with_class_names(["myclass"], labels=[0])
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
assert detections.class_id[0] == 0, "class_id must be 0 (0-indexed)"
assert detections.data["class_name"][0] == "myclass", (
"class_name must be 'myclass' even though the original COCO category_id was 1"
)
def test_class_name_falls_back_to_coco_when_no_custom_names(self) -> None:
"""Without custom class_names, class_name maps class_id via COCO_CLASS_NAMES."""
from rfdetr.assets.coco_classes import COCO_CLASS_NAMES
# _DummyModel with no custom class_names; labels=[1] → COCO_CLASS_NAMES[1]
model = _DummyRFDETR()
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
assert "class_name" in detections.data
assert detections.data["class_name"][0] == COCO_CLASS_NAMES[1], (
"class_name must fall back to COCO_CLASS_NAMES[class_id]"
)
def test_class_name_empty_array_when_no_detections(self) -> None:
"""When threshold filters all detections, data['class_name'] must be an empty array."""
model = self._make_model_with_class_names(["cat"], labels=[0])
img = PIL.Image.new("RGB", (28, 28))
# threshold=1.1 filters out all detections (confidence=0.9 < 1.1)
detections = model.predict(img, threshold=1.1)
assert "class_name" in detections.data
assert len(detections.data["class_name"]) == 0, "class_name must be empty when no detections pass threshold"
assert detections.data["class_name"].dtype == object, (
"class_name dtype must be object even when the array is empty (not float64)"
)
def test_class_name_out_of_bounds_class_id_returns_empty_string(self) -> None:
"""A class_id >= len(class_names) must map to an empty string (no IndexError)."""
# class_names has 2 entries but labels includes out-of-bounds id=5
model = self._make_model_with_class_names(["cat", "dog"], labels=[5])
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
assert detections.data["class_name"][0] == "", "Out-of-bounds class_id must produce empty string"
def test_class_name_negative_class_id_returns_empty_string(self) -> None:
"""A negative class_id must map to an empty string (bounds check: 0 <= cid)."""
model = self._make_model_with_class_names(["cat", "dog"], labels=[-1])
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
assert detections.data["class_name"][0] == "", "Negative class_id must produce empty string"
def test_class_name_populated_for_each_image_in_batch(self) -> None:
"""class_name must be correctly populated for every Detections in a batch prediction."""
model = self._make_model_with_class_names(["cat", "dog"], labels=[0, 1])
img1 = PIL.Image.new("RGB", (28, 28))
img2 = PIL.Image.new("RGB", (28, 28))
results = model.predict([img1, img2])
assert isinstance(results, list), "batch predict must return a list"
assert len(results) == 2, "one Detections per input image"
for idx, det in enumerate(results):
assert "class_name" in det.data, f"image {idx}: class_name must be present"
assert list(det.data["class_name"]) == ["cat", "dog"], (
f"image {idx}: class_name must match class_names[class_id]"
)
def test_background_class_id_maps_to_background_label(self) -> None:
"""DETR's background/no-object class (class_id == n) must map to '__background__'.
RF-DETR internally allocates num_classes + 1 outputs; the extra class at index n is the background/no-object
class. Returning it as '__background__' is unambiguous, whereas the previous empty string was indistinguishable
from a genuine OOB error.
Regression / contract test for https://github.com/roboflow/rf-detr/pull/966 post-merge issue reported by
@Alarmod.
"""
# class_names has 2 entries (n=2); background class is label index 2
model = self._make_model_with_class_names(["cat", "dog"], labels=[2])
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
assert detections.data["class_name"][0] == "__background__", (
"Background class (class_id == num_classes) must map to '__background__', not empty string"
)
def test_background_class_id_does_not_emit_oob_warning(self) -> None:
"""Predicting the background class must not emit an out-of-range warning.
The background class (class_id == num_classes) is expected DETR behaviour, not a model error. Warning on it
misleads users into thinking something is wrong.
Uses _warned_once state (not caplog) because the RF-DETR logger has propagate=False, which prevents caplog from
capturing records via the root-logger handler.
Regression / contract test for https://github.com/roboflow/rf-detr/pull/966 post-merge issue reported by
@Alarmod.
"""
from rfdetr.utilities.logger import get_logger
# Reset warning_once state so this test is not affected by earlier tests that may
# have already triggered the same message template, masking a reintroduced warning.
logger = get_logger()
logger._warned_once.clear()
model = self._make_model_with_class_names(["cat", "dog"], labels=[2])
img = PIL.Image.new("RGB", (28, 28))
model.predict(img)
unmapped_warnings = [msg for msg in logger._warned_once if "unmapped class_id" in msg]
assert not unmapped_warnings, "Background class must not trigger unmapped-class-id warning"
def test_truly_oob_class_id_still_maps_to_empty_string_and_warns(self) -> None:
"""A class_id strictly above num_classes still maps to empty string AND emits a warning.
class_id == n is background (no warning); class_id > n is truly unexpected — must produce '' AND trigger the
out-of-range warning so the caller knows something is wrong.
Uses _warned_once state (not caplog) because the RF-DETR logger has propagate=False, which prevents caplog from
capturing records via the root-logger handler.
"""
from rfdetr.utilities.logger import get_logger
# Reset warning_once state so this test is not affected by deduplication from earlier tests.
logger = get_logger()
logger._warned_once.clear()
# n=2, background is class_id=2; class_id=5 is truly OOB (> n)
model = self._make_model_with_class_names(["cat", "dog"], labels=[5])
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
assert detections.data["class_name"][0] == "", "Truly OOB class_id (> num_classes) must produce empty string"
unmapped_warnings = [msg for msg in logger._warned_once if "unmapped class_id" in msg]
assert unmapped_warnings, "Truly OOB class_id (> num_classes) must trigger an unmapped-class-id warning"
@pytest.mark.parametrize(
("class_id", "expected_name"),
[
pytest.param(18, "dog", id="coco_id_18_dog"),
pytest.param(27, "backpack", id="coco_id_27_backpack"),
pytest.param(3, "car", id="coco_id_3_car"),
],
)
def test_coco_pretrained_sparse_id_mapping(self, class_id: int, expected_name: str) -> None:
"""Pretrained COCO models use raw COCO category IDs (1-indexed, with gaps) as class_ids.
When num_classes=90 and class_names has 80 entries, class_id 18 must resolve to 'dog' (COCO category 18), not
'sheep' (COCO_CLASS_NAMES[18] via 0-indexed lookup).
Regression test for
https://github.com/roboflow/rf-detr/issues/988.
"""
from rfdetr.assets.coco_classes import COCO_CLASS_NAMES
coco_model = _DummyModel(class_names=list(COCO_CLASS_NAMES), labels=[class_id])
coco_model.args = SimpleNamespace(num_classes=90)
model = _DummyRFDETR()
model.model = coco_model
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
assert detections.data["class_name"][0] == expected_name, (
f"class_id={class_id} must map to '{expected_name}', got '{detections.data['class_name'][0]}'"
)
def test_coco_pretrained_dataset_file_roboflow(self) -> None:
"""Pretrained COCO weights packaged as dataset_file='roboflow' must still use sparse-ID mapping.
RF-DETR pretrained checkpoints (e.g. RFDETRSegSmall) can have dataset_file='roboflow' even though they were
trained on COCO. The fix must not depend on dataset_file value.
Regression test for
https://github.com/roboflow/rf-detr/issues/988
(post-revert follow-up).
"""
from rfdetr.assets.coco_classes import COCO_CLASS_NAMES
coco_model = _DummyModel(class_names=list(COCO_CLASS_NAMES), labels=[18])
coco_model.args = SimpleNamespace(num_classes=90, dataset_file="roboflow")
model = _DummyRFDETR()
model.model = coco_model
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
assert detections.data["class_name"][0] == "dog", (
f"dataset_file='roboflow' COCO pretrained: class_id=18 must map to 'dog', "
f"got '{detections.data['class_name'][0]}'"
)
def test_finetuned_coco_names_uses_direct_indexing(self) -> None:
"""Fine-tuned 80-class model with COCO names must use direct 0-indexed lookup, not sparse remap.
When num_classes == len(COCO_CLASS_NAMES) (not strictly greater), the COCO sparse-ID branch must NOT activate.
"""
from rfdetr.assets.coco_classes import COCO_CLASS_NAMES
coco_model = _DummyModel(class_names=list(COCO_CLASS_NAMES), labels=[18])
coco_model.args = SimpleNamespace(num_classes=80, dataset_file="coco")
model = _DummyRFDETR()
model.model = coco_model
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
assert detections.data["class_name"][0] == COCO_CLASS_NAMES[18], (
f"Fine-tuned 80-class model must use direct indexing; got '{detections.data['class_name'][0]}'"
)
def test_custom_names_high_num_classes_no_coco_remap(self) -> None:
"""Custom class_names with num_classes>80 must NOT activate sparse COCO remap.
Guard: a custom model with num_classes=90 but non-COCO class_names must use
direct 0-indexed mapping (class_names != COCO_CLASS_NAMES fails the guard).
"""
custom_names = [f"custom_{i}" for i in range(80)]
coco_model = _DummyModel(class_names=custom_names, labels=[18])
coco_model.args = SimpleNamespace(num_classes=90)
model = _DummyRFDETR()
model.model = coco_model
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
assert detections.data["class_name"][0] == "custom_18", (
f"Custom class names must use direct indexing; got '{detections.data['class_name'][0]}'"
)
def test_coco_names_without_model_args_fires_warning(self) -> None:
"""Predict() must warn when COCO class_names present but model has no 'args' attribute.
Without args, num_logit_slots falls back to n so _is_coco_pretrained stays False. The warning is the caller's
only signal that sparse COCO-ID mapping cannot activate, which may cause wrong class names for pretrained COCO
checkpoints loaded without args.
"""
from rfdetr.assets.coco_classes import COCO_CLASS_NAMES
from rfdetr.utilities.logger import get_logger
logger = get_logger()
logger._warned_once.clear()
no_args_model = _DummyModel(class_names=list(COCO_CLASS_NAMES), labels=[0])
# Do NOT set no_args_model.args — this is the scenario under test.
model = _DummyRFDETR()
model.model = no_args_model
img = PIL.Image.new("RGB", (28, 28))
model.predict(img)
coco_warnings = [msg for msg in logger._warned_once if "COCO sparse-ID mapping cannot activate" in msg]
assert coco_warnings, (
"predict() must emit a warning when class_names matches COCO_CLASS_NAMES "
"but model has no 'args' attribute (sparse-ID mapping cannot activate)"
)
def test_non_coco_names_without_model_args_no_warning_uses_direct_index(self) -> None:
"""No warning and direct indexing for non-COCO class_names when model has no 'args'.
When model has no 'args' AND class_names != COCO_CLASS_NAMES, neither the COCO warning nor sparse-ID mapping
activates. class_id maps directly to class_names[class_id].
"""
from rfdetr.utilities.logger import get_logger
logger = get_logger()
logger._warned_once.clear()
no_args_model = _DummyModel(class_names=["cat", "dog"], labels=[0])
# Do NOT set no_args_model.args.
model = _DummyRFDETR()
model.model = no_args_model
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
coco_warnings = [msg for msg in logger._warned_once if "COCO" in msg]
assert not coco_warnings, "Non-COCO class_names with no args must not emit a COCO warning"
assert detections.data["class_name"][0] == "cat", (
f"Direct-index mapping: class_id=0 must map to 'cat', got '{detections.data['class_name'][0]}'"
)
def test_coco_pretrained_oob_gap_class_id_maps_to_empty_string_and_warns(self) -> None:
"""COCO category gap ID 12 must produce empty string and OOB warning in pretrained branch.
COCO skips category ID 12 (gap between fire hydrant=11 and stop sign=13). A pretrained model emitting cid=12 has
no mapping in _class_id_to_name and must trigger the out-of-range warning even in the COCO-pretrained branch.
"""
from rfdetr.assets.coco_classes import COCO_CLASS_NAMES
from rfdetr.utilities.logger import get_logger
logger = get_logger()
logger._warned_once.clear()
coco_model = _DummyModel(class_names=list(COCO_CLASS_NAMES), labels=[12])
coco_model.args = SimpleNamespace(num_classes=90)
model = _DummyRFDETR()
model.model = coco_model
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
assert detections.data["class_name"][0] == "", "COCO gap ID 12 (no such category) must produce empty string"
unmapped_warnings = [msg for msg in logger._warned_once if "unmapped class_id" in msg]
assert unmapped_warnings, "COCO gap ID 12 must trigger an unmapped-class-id warning"
def test_coco_pretrained_class_id_90_maps_to_toothbrush_not_background(self) -> None:
"""COCO class ID 90 ('toothbrush') must not be mislabelled '__background__' in pretrained branch.
For COCO-pretrained models num_logit_slots==90, which is also a valid COCO category (toothbrush). Background is
implicit (below threshold), not a sentinel label. The background sentinel check must be scoped to fine-tuned
models only.
Regression test for HIGH-1 finding in /review of PR #1051.
"""
from rfdetr.assets.coco_classes import COCO_CLASS_NAMES
from rfdetr.utilities.logger import get_logger
logger = get_logger()
logger._warned_once.clear()
coco_model = _DummyModel(class_names=list(COCO_CLASS_NAMES), labels=[90])
coco_model.args = SimpleNamespace(num_classes=90)
model = _DummyRFDETR()
model.model = coco_model
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
assert detections.data["class_name"][0] == "toothbrush", (
f"COCO pretrained: class_id=90 must map to 'toothbrush', got '{detections.data['class_name'][0]}'"
)
unmapped_warnings = [msg for msg in logger._warned_once if "unmapped class_id" in msg]
assert not unmapped_warnings, "class_id=90 (valid COCO category) must not trigger unmapped-class-id warning"
class TestPredictKeypointClassNameMapping:
"""class_name mapping for keypoint and detection models (issue #1150).
Active-first keypoint models use normal 0-based class IDs. Legacy background-first checkpoints use slot 0 as
background and start real classes at slot 1; that path must keep class-name mapping compatible.
"""
@pytest.mark.parametrize(
"class_names,labels,num_kp_per_class,expected_class_name",
[
# Background-first schema (regression for https://github.com/roboflow/rf-detr/issues/1150)
pytest.param(["person"], [1], [0, 17], "person", id="bg-first-slot-1-maps-to-person"),
pytest.param(["person"], [0], [0, 17], "__background__", id="bg-first-slot-0-maps-to-background"),
pytest.param(
["person", "bicycle"], [1], [0, 17, 4], "person", id="bg-first-multi-class-slot-1-maps-to-person"
),
pytest.param(
["person", "bicycle"], [2], [0, 17, 4], "bicycle", id="bg-first-multi-class-slot-2-maps-to-bicycle"
),
# Active-first schemas (no leading zero) — fallback path must stay correct
pytest.param(["person"], [0], [25], "person", id="active-first-single-class-slot-0-maps-to-person"),
pytest.param(
["person"], [1], [25], "__background__", id="active-first-single-class-slot-1-maps-to-background"
),
pytest.param(
["person", "bicycle"], [0], [17, 4], "person", id="active-first-multi-class-slot-0-maps-to-person"
),
],
)
def test_keypoint_class_name_mapping(
self,
class_names: list[str],
labels: list[int],
num_kp_per_class: list[int],
expected_class_name: str,
) -> None:
"""class_name resolved correctly for background-first and active-first keypoint schemas."""
kp_model = _DummyModel(class_names=class_names, labels=labels, include_keypoints=True)
kp_model.args = SimpleNamespace(num_classes=len(class_names), num_keypoints_per_class=num_kp_per_class)
model = _DummyRFDETR()
model.model = kp_model
img = PIL.Image.new("RGB", (28, 28))
key_points = model.predict(img)
assert isinstance(key_points, sv.KeyPoints)
assert key_points.data["class_name"][0] == expected_class_name, (
f"schema={num_kp_per_class}, class_id={labels[0]}: "
f"expected '{expected_class_name}', got '{key_points.data['class_name'][0]}'"
)
def test_detection_model_class_names_unaffected_by_keypoint_branch(self) -> None:
"""Detection models (no num_keypoints_per_class) map class_id via 0-based index, unchanged by fix."""
det_model = _DummyModel(class_names=["cat"], labels=[0], include_keypoints=False)
det_model.args = SimpleNamespace(num_classes=1)
model = _DummyRFDETR()
model.model = det_model
img = PIL.Image.new("RGB", (28, 28))
detections = model.predict(img)
assert isinstance(detections, sv.Detections)
assert detections.data["class_name"][0] == "cat", (
f"Detection model: class_id=0 must map to 'cat', got '{detections.data['class_name'][0]}'"
)
# ---------------------------------------------------------------------------
# Fix F — non-RGB PIL / file-path inputs are auto-converted to RGB
# ---------------------------------------------------------------------------
class TestPredictNonRGBAutoConvert:
"""Predict() must silently convert non-RGB PIL images and file paths to RGB.
Standard detector contract: callers pass images in any PIL mode (L, LA,
RGBA, P …) and expect detection results, not opaque tensor-shape errors.
Tensor inputs with wrong channel count are still the caller's error.
"""
@pytest.mark.parametrize(
"pil_mode",
[
pytest.param("L", id="grayscale-L"),
pytest.param("LA", id="grayscale-with-alpha-LA"),
pytest.param("RGBA", id="rgba"),
pytest.param("P", id="palette-P"),
pytest.param("CMYK", id="cmyk"),
],
)
def test_non_rgb_pil_image_succeeds(self, pil_mode: str) -> None:
"""PIL images in any mode are auto-converted to RGB and return sv.Detections."""
import supervision as sv
img = PIL.Image.new(pil_mode, (28, 28))
model = _DummyRFDETR()
detections = model.predict(img)
assert isinstance(detections, sv.Detections)
def test_grayscale_file_path_succeeds(self, tmp_path) -> None:
"""Grayscale image opened from a file path is auto-converted and returns sv.Detections."""
import supervision as sv
img_path = tmp_path / "gray.png"
PIL.Image.new("L", (28, 28)).save(str(img_path))
model = _DummyRFDETR()
detections = model.predict(str(img_path))
assert isinstance(detections, sv.Detections)
def test_wrong_channel_tensor_still_raises(self) -> None:
"""Tensor inputs with wrong channel count must still raise ValueError with helpful message."""
import torch
# 1-channel tensor — caller is responsible for correct shape
tensor = torch.rand(1, 28, 28)
model = _DummyRFDETR()
with pytest.raises(ValueError, match="PIL Image or a file path"):
model.predict(tensor)
def _png_bytes(size: tuple[int, int] = (28, 28)) -> bytes:
"""Return the PNG-encoded bytes of a solid grey image.
Examples:
>>> data = _png_bytes((8, 8))
>>> data[:4]
b'\\x89PNG'
"""
buf = io.BytesIO()
PIL.Image.new("RGB", size, (128, 128, 128)).save(buf, format="PNG")
return buf.getvalue()
class _FakeResponse:
"""Minimal stand-in for ``requests.Response`` used by ``predict()`` URL tests."""
def __init__(self, content: bytes = b"", status_code: int = 200) -> None:
"""Store the response body and status code."""
self.content = content
self.status_code = status_code
def raise_for_status(self) -> None:
"""Raise ``requests.HTTPError`` for 4xx/5xx status codes, mirroring requests."""
if self.status_code >= 400:
raise requests.HTTPError(f"{self.status_code} Client Error")
class TestPredictURLFetch:
"""``predict()`` URL handling: robust HTTP fetch and correct local-path classification."""
def test_http_error_status_raises_httperror(self, monkeypatch) -> None:
"""A 404 response surfaces as ``requests.HTTPError``, not an opaque PIL error."""
model = _DummyRFDETR()
def _fake_get(url: str, **kwargs: object) -> _FakeResponse:
return _FakeResponse(content=b"not found", status_code=404)
monkeypatch.setattr(requests, "get", _fake_get)
with pytest.raises(requests.HTTPError):
model.predict("http://example.com/missing.jpg")
def test_timeout_is_passed_to_requests_get(self, monkeypatch) -> None:
"""The HTTP fetch must pass an explicit ``timeout`` so it cannot hang forever."""
model = _DummyRFDETR()
captured: dict[str, object] = {}
def _fake_get(url: str, **kwargs: object) -> _FakeResponse:
captured.update(kwargs)
return _FakeResponse(content=_png_bytes(), status_code=200)
monkeypatch.setattr(requests, "get", _fake_get)
detections = model.predict("http://example.com/image.png")
assert isinstance(detections, sv.Detections)
assert captured.get("timeout") == 30, "predict() must pass timeout=30 to requests.get"
def test_local_file_named_like_http_is_not_fetched(self, tmp_path, monkeypatch) -> None:
"""A local file whose name starts with ``http`` must be opened, not fetched over HTTP."""
img_path = tmp_path / "httpcam_frame.png"
PIL.Image.new("RGB", (28, 28), (128, 128, 128)).save(str(img_path))
model = _DummyRFDETR()
def _fail_get(url: str, **kwargs: object) -> _FakeResponse:
raise AssertionError(f"requests.get must not be called for local path {url!r}")
monkeypatch.setattr(requests, "get", _fail_get)
detections = model.predict(str(img_path))
assert isinstance(detections, sv.Detections)
class TestPredictInputTypeReturnShape:
"""``predict()`` return shape is governed by input type, not runtime batch length."""
def test_single_element_list_returns_list(self) -> None:
"""A one-element list input returns a list, not a bare ``Detections``."""
img = PIL.Image.new("RGB", (28, 28), (128, 128, 128))
model = _DummyRFDETR()
result = model.predict([img])
assert isinstance(result, list), "list input must always return a list"
assert len(result) == 1
def test_bare_image_returns_detections(self) -> None:
"""A single (non-list) image returns a bare ``Detections``."""
img = PIL.Image.new("RGB", (28, 28), (128, 128, 128))
model = _DummyRFDETR()
result = model.predict(img)
assert isinstance(result, sv.Detections)
class TestExportInplaceOptimizeGuards:
"""Roboflow export methods raise a clear error after ``inference(inplace=True)``."""
def test_export_for_roboflow_raises_after_inplace_optimize(self, tmp_path) -> None:
"""``export_for_roboflow`` raises ``RuntimeError`` once the model has been cleared."""
model = _DummyRFDETR()
model._optimized_inplace = True
with pytest.raises(RuntimeError, match="inference"):
model.export_for_roboflow(str(tmp_path))
def test_deploy_to_roboflow_raises_after_inplace_optimize(self) -> None:
"""``deploy_to_roboflow`` raises ``RuntimeError`` before any auth/network calls."""
model = _DummyRFDETR()
model._optimized_inplace = True
with pytest.raises(RuntimeError, match="inference"):
model.deploy_to_roboflow("ws", "proj", 1)
class TestTrainAlreadyTrainedWarning:
"""Calling ``train()`` on an already-trained/loaded model emits a loud warning."""
def test_second_train_warns(self, monkeypatch) -> None:
"""A model flagged as trained warns that training restarts from pretrain_weights."""
model = _DummyRFDETR()
model._has_been_trained = True
class _StopTrainError(Exception):
pass
def _stub_get_train_config(self, **kwargs: object) -> None:
raise _StopTrainError
# Short-circuit train() right after the warning so no real training runs.
monkeypatch.setattr(RFDETR, "get_train_config", _stub_get_train_config, raising=False)
with pytest.warns(UserWarning, match="already been trained"):
with pytest.raises(_StopTrainError):
model.train()