Skip to content

Commit 34b3e3d

Browse files
committed
test: add new unit tests for RFDETR model prediction API and move API contract tests
- Added unit tests to verify `RFDETR.predict()` output for detection and segmentation models. - Moved API contract tests to `test_predict.py` for clearer separation from COCO inference benchmarks. - Simplified benchmark tests by removing redundant API checks.
1 parent cca0bab commit 34b3e3d

2 files changed

Lines changed: 93 additions & 72 deletions

File tree

tests/benchmarks/test_coco_inference.py

Lines changed: 65 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,18 @@
88
For every detection and segmentation model variant, this module:
99
1010
1. Loads pretrained weights via the :class:`~rfdetr.detr.RFDETR` wrapper.
11-
2. Verifies :meth:`~rfdetr.detr.RFDETR.predict` returns valid
12-
:class:`supervision.Detections` objects.
13-
3. Copies the same weights into a fresh :class:`~rfdetr.training.RFDETRModule`.
14-
4. Evaluates via ``Trainer.validate`` and asserts mAP thresholds.
11+
2. Copies the weights into a fresh :class:`~rfdetr.training.RFDETRModule`.
12+
3. Evaluates via ``Trainer.validate`` and asserts mAP thresholds.
13+
14+
API contract tests (return type of ``predict()``) live in
15+
``tests/models/test_predict.py`` and do not require a COCO download.
1516
1617
Test functions:
1718
18-
- :func:`test_inference_detection_rfdetr_predict` — ``RFDETR.predict()``
19-
returns valid detections for detection models (Nano/Small/Medium/Large).
20-
- :func:`test_inference_segmentation_rfdetr_predict` — ``RFDETR.predict()``
21-
returns detections with masks for segmentation models (Nano through 2XLarge).
19+
- :func:`test_inference_detection_rfdetr_predict` — asserts mAP@50 for detection
20+
models (Nano/Small/Medium/Large).
21+
- :func:`test_inference_segmentation_rfdetr_predict` — asserts mAP@50 for
22+
segmentation models (Nano through 2XLarge).
2223
- :func:`test_inference_detection_ptl_predict` — ``trainer.predict()`` exercises
2324
the PTL predict loop (50 samples) then asserts mAP via ``Trainer.validate``.
2425
- :func:`test_inference_segmentation_ptl_predict` — same for segmentation models.
@@ -29,7 +30,6 @@
2930
from typing import Optional
3031

3132
import pytest
32-
import supervision as sv
3333
import torch
3434
from pytorch_lightning import LightningModule
3535

@@ -152,35 +152,34 @@ def _build_ptl_module(rfdetr_obj: RFDETR, train_config: TrainConfig) -> RFDETRMo
152152

153153
@pytest.mark.gpu
154154
@pytest.mark.parametrize(
155-
("model_cls", "threshold_map", "num_samples", "batch_size"),
155+
("model_cls", "threshold_map", "threshold_f1", "num_samples", "batch_size"),
156156
[
157-
pytest.param(RFDETRNano, 0.67, 2000, 6, id="det-nano"),
158-
pytest.param(RFDETRSmall, 0.72, 500, 6, id="det-small"),
159-
pytest.param(RFDETRMedium, 0.73, 500, 4, id="det-medium"),
160-
pytest.param(RFDETRLarge, 0.74, 500, 2, id="det-large"),
157+
pytest.param(RFDETRNano, 0.66, 0.66, 2000, 6, id="det-nano"),
158+
pytest.param(RFDETRSmall, 0.72, 0.70, 500, 6, id="det-small"),
159+
pytest.param(RFDETRMedium, 0.73, 0.71, 500, 4, id="det-medium"),
160+
pytest.param(RFDETRLarge, 0.74, 0.72, 500, 2, id="det-large"),
161161
],
162162
)
163163
def test_inference_detection_rfdetr_predict(
164164
tmp_path: Path,
165165
download_coco_val: tuple[Path, Path],
166166
model_cls: type[RFDETR],
167167
threshold_map: float,
168+
threshold_f1: float,
168169
num_samples: int,
169170
batch_size: int,
170171
) -> None:
171172
"""``RFDETR.predict()`` returns valid ``sv.Detections`` for detection models.
172173
173174
Loads a pretrained detection model, runs ``predict()`` on a sample of COCO
174-
val images, and asserts:
175-
176-
- Return type is a list of :class:`supervision.Detections`.
177-
- ``Trainer.validate`` on the same weights meets the mAP threshold.
175+
val images, and asserts ``Trainer.validate`` meets the mAP and F1 thresholds.
178176
179177
Args:
180178
tmp_path: Pytest-provided temporary directory.
181179
download_coco_val: Fixture providing ``(images_root, annotations_path)``.
182180
model_cls: Detection model class to instantiate with pretrained weights.
183181
threshold_map: Minimum ``val/mAP_50`` required.
182+
threshold_f1: Minimum ``val/F1`` (best macro-F1 across confidence sweep) required.
184183
num_samples: Number of val images used for ``Trainer.validate``.
185184
batch_size: DataLoader batch size for ``Trainer.validate``.
186185
"""
@@ -190,56 +189,51 @@ def test_inference_detection_rfdetr_predict(
190189

191190
rfdetr = model_cls(device=device_str)
192191

193-
# Verify RFDETR.predict() API returns sv.Detections.
194-
# predict() accepts str paths or PIL Images, not pathlib.Path objects.
195-
sample_images = [str(p) for p in sorted(images_root.glob("*.jpg"))[:4]]
196-
assert sample_images, "No COCO val images found."
197-
detections = rfdetr.predict(sample_images, threshold=0.3)
198-
assert isinstance(detections, list), "predict() should return a list for multiple images"
199-
assert all(isinstance(d, sv.Detections) for d in detections), "Each result must be sv.Detections"
200-
201-
# Verify mAP via Trainer.validate on the same pretrained weights.
192+
# Verify mAP and F1 via Trainer.validate on the pretrained weights.
202193
tc = _build_train_config(coco_root, tmp_path, batch_size)
203194
dm = _build_datamodule(rfdetr.model_config, tc, num_samples=num_samples)
204195
module = _build_ptl_module(rfdetr, tc)
205196
accelerator = "auto" if torch.cuda.is_available() else "cpu"
206197
trainer = build_trainer(tc, rfdetr.model_config, accelerator=accelerator)
207-
results = trainer.validate(module, datamodule=dm)
208-
map_val = results[0]["val/mAP_50"]
198+
(metrics,) = trainer.validate(module, datamodule=dm)
199+
map_val = metrics["val/mAP_50"]
200+
f1_val = metrics["val/F1"]
209201
assert map_val >= threshold_map, f"mAP@50 {map_val:.4f} < {threshold_map}"
202+
assert f1_val >= threshold_f1, f"F1 {f1_val:.4f} < {threshold_f1}"
210203

211204

212205
@pytest.mark.gpu
213206
@pytest.mark.parametrize(
214-
("model_cls", "threshold_map", "num_samples", "batch_size"),
207+
("model_cls", "threshold_map", "threshold_f1", "num_samples", "batch_size"),
215208
[
216-
pytest.param(RFDETRSegNano, 0.63, 500, 6, id="seg-nano"),
217-
pytest.param(RFDETRSegSmall, 0.66, 100, 6, id="seg-small"),
218-
pytest.param(RFDETRSegMedium, 0.68, 100, 4, id="seg-medium"),
219-
pytest.param(RFDETRSegLarge, 0.70, 100, 2, id="seg-large"),
220-
pytest.param(RFDETRSegXLarge, 0.72, 100, 2, id="seg-xlarge"),
221-
pytest.param(RFDETRSeg2XLarge, 0.73, 100, 2, id="seg-2xlarge"),
209+
pytest.param(RFDETRSegNano, 0.63, 0.64, 500, 6, id="seg-nano"),
210+
pytest.param(RFDETRSegSmall, 0.66, 0.67, 100, 6, id="seg-small"),
211+
pytest.param(RFDETRSegMedium, 0.68, 0.68, 100, 4, id="seg-medium"),
212+
pytest.param(RFDETRSegLarge, 0.70, 0.69, 100, 2, id="seg-large"),
213+
pytest.param(RFDETRSegXLarge, 0.72, 0.70, 100, 2, id="seg-xlarge"),
214+
pytest.param(RFDETRSeg2XLarge, 0.73, 0.71, 100, 2, id="seg-2xlarge"),
222215
],
223216
)
224217
def test_inference_segmentation_rfdetr_predict(
225218
tmp_path: Path,
226219
download_coco_val: tuple[Path, Path],
227220
model_cls: type[RFDETR],
228221
threshold_map: float,
222+
threshold_f1: float,
229223
num_samples: int,
230224
batch_size: int,
231225
) -> None:
232-
"""``RFDETR.predict()`` returns valid ``sv.Detections`` with masks for segmentation models.
226+
"""Asserts mAP and F1 thresholds for segmentation models via ``Trainer.validate``.
233227
234228
Same structure as :func:`test_inference_detection_rfdetr_predict` but for
235-
segmentation variants. Also asserts that the returned detections contain a
236-
non-``None`` ``mask`` field.
229+
segmentation variants.
237230
238231
Args:
239232
tmp_path: Pytest-provided temporary directory.
240233
download_coco_val: Fixture providing ``(images_root, annotations_path)``.
241234
model_cls: Segmentation model class to instantiate with pretrained weights.
242235
threshold_map: Minimum ``val/mAP_50`` (bbox) required.
236+
threshold_f1: Minimum ``val/F1`` (best macro-F1 across confidence sweep) required.
243237
num_samples: Number of val images used for ``Trainer.validate``.
244238
batch_size: DataLoader batch size for ``Trainer.validate``.
245239
"""
@@ -249,26 +243,17 @@ def test_inference_segmentation_rfdetr_predict(
249243

250244
rfdetr = model_cls(device=device_str)
251245

252-
# Verify RFDETR.predict() returns sv.Detections with masks.
253-
# predict() accepts str paths or PIL Images, not pathlib.Path objects.
254-
sample_images = [str(p) for p in sorted(images_root.glob("*.jpg"))[:4]]
255-
assert sample_images, "No COCO val images found."
256-
detections = rfdetr.predict(sample_images, threshold=0.3)
257-
assert isinstance(detections, list), "predict() should return a list for multiple images"
258-
assert all(isinstance(d, sv.Detections) for d in detections), "Each result must be sv.Detections"
259-
assert any(d.mask is not None for d in detections if len(d) > 0), (
260-
"Segmentation model predict() should return detections with masks"
261-
)
262-
263-
# Verify mAP via Trainer.validate on the same pretrained weights.
246+
# Verify mAP and F1 via Trainer.validate on the pretrained weights.
264247
tc = _build_train_config(coco_root, tmp_path, batch_size)
265248
dm = _build_datamodule(rfdetr.model_config, tc, num_samples=num_samples)
266249
module = _build_ptl_module(rfdetr, tc)
267250
accelerator = "auto" if torch.cuda.is_available() else "cpu"
268251
trainer = build_trainer(tc, rfdetr.model_config, accelerator=accelerator)
269-
results = trainer.validate(module, datamodule=dm)
270-
map_val = results[0]["val/mAP_50"]
252+
(metrics,) = trainer.validate(module, datamodule=dm)
253+
map_val = metrics["val/mAP_50"]
254+
f1_val = metrics["val/F1"]
271255
assert map_val >= threshold_map, f"mAP@50 {map_val:.4f} < {threshold_map}"
256+
assert f1_val >= threshold_f1, f"F1 {f1_val:.4f} < {threshold_f1}"
272257

273258

274259
# ---------------------------------------------------------------------------
@@ -278,19 +263,20 @@ def test_inference_segmentation_rfdetr_predict(
278263

279264
@pytest.mark.gpu
280265
@pytest.mark.parametrize(
281-
("model_cls", "threshold_map", "num_samples", "batch_size"),
266+
("model_cls", "threshold_map", "threshold_f1", "num_samples", "batch_size"),
282267
[
283-
pytest.param(RFDETRNano, 0.67, 2000, 6, id="det-nano"),
284-
pytest.param(RFDETRSmall, 0.72, 500, 6, id="det-small"),
285-
pytest.param(RFDETRMedium, 0.73, 500, 4, id="det-medium"),
286-
pytest.param(RFDETRLarge, 0.74, 500, 2, id="det-large"),
268+
pytest.param(RFDETRNano, 0.66, 0.66, 2000, 6, id="det-nano"),
269+
pytest.param(RFDETRSmall, 0.72, 0.70, 500, 6, id="det-small"),
270+
pytest.param(RFDETRMedium, 0.73, 0.71, 500, 4, id="det-medium"),
271+
pytest.param(RFDETRLarge, 0.74, 0.72, 500, 2, id="det-large"),
287272
],
288273
)
289274
def test_inference_detection_ptl_predict(
290275
tmp_path: Path,
291276
download_coco_val: tuple[Path, Path],
292277
model_cls: type[RFDETR],
293278
threshold_map: float,
279+
threshold_f1: float,
294280
num_samples: int,
295281
batch_size: int,
296282
) -> None:
@@ -299,13 +285,14 @@ def test_inference_detection_ptl_predict(
299285
Loads a pretrained detection model, copies weights into a
300286
:class:`~rfdetr.training.RFDETRModule`, runs ``trainer.predict()`` on a
301287
small subset (50 samples) to exercise :meth:`~rfdetr.training.RFDETRModule.predict_step`,
302-
then runs ``Trainer.validate`` on the full *num_samples* to assert mAP.
288+
then runs ``Trainer.validate`` on the full *num_samples* to assert mAP and F1.
303289
304290
Args:
305291
tmp_path: Pytest-provided temporary directory.
306292
download_coco_val: Fixture providing ``(images_root, annotations_path)``.
307293
model_cls: Detection model class to instantiate with pretrained weights.
308294
threshold_map: Minimum ``val/mAP_50`` required.
295+
threshold_f1: Minimum ``val/F1`` (best macro-F1 across confidence sweep) required.
309296
num_samples: Number of val samples used for ``Trainer.validate``.
310297
batch_size: DataLoader batch size.
311298
"""
@@ -325,30 +312,33 @@ def test_inference_detection_ptl_predict(
325312
assert predictions is not None, "trainer.predict() returned None"
326313
assert len(predictions) > 0, "trainer.predict() returned empty list"
327314

328-
# Verify mAP via Trainer.validate on the full num_samples.
315+
# Verify mAP and F1 via Trainer.validate on the full num_samples.
329316
val_dm = _build_datamodule(rfdetr.model_config, tc, num_samples=num_samples)
330-
results = trainer.validate(module, datamodule=val_dm)
331-
map_val = results[0]["val/mAP_50"]
317+
(metrics,) = trainer.validate(module, datamodule=val_dm)
318+
map_val = metrics["val/mAP_50"]
319+
f1_val = metrics["val/F1"]
332320
assert map_val >= threshold_map, f"mAP@50 {map_val:.4f} < {threshold_map}"
321+
assert f1_val >= threshold_f1, f"F1 {f1_val:.4f} < {threshold_f1}"
333322

334323

335324
@pytest.mark.gpu
336325
@pytest.mark.parametrize(
337-
("model_cls", "threshold_map", "num_samples", "batch_size"),
326+
("model_cls", "threshold_map", "threshold_f1", "num_samples", "batch_size"),
338327
[
339-
pytest.param(RFDETRSegNano, 0.63, 500, 6, id="seg-nano"),
340-
pytest.param(RFDETRSegSmall, 0.66, 100, 6, id="seg-small"),
341-
pytest.param(RFDETRSegMedium, 0.68, 100, 4, id="seg-medium"),
342-
pytest.param(RFDETRSegLarge, 0.70, 100, 2, id="seg-large"),
343-
pytest.param(RFDETRSegXLarge, 0.72, 100, 2, id="seg-xlarge"),
344-
pytest.param(RFDETRSeg2XLarge, 0.73, 100, 2, id="seg-2xlarge"),
328+
pytest.param(RFDETRSegNano, 0.63, 0.64, 500, 6, id="seg-nano"),
329+
pytest.param(RFDETRSegSmall, 0.66, 0.67, 100, 6, id="seg-small"),
330+
pytest.param(RFDETRSegMedium, 0.68, 0.68, 100, 4, id="seg-medium"),
331+
pytest.param(RFDETRSegLarge, 0.70, 0.69, 100, 2, id="seg-large"),
332+
pytest.param(RFDETRSegXLarge, 0.72, 0.70, 100, 2, id="seg-xlarge"),
333+
pytest.param(RFDETRSeg2XLarge, 0.73, 0.71, 100, 2, id="seg-2xlarge"),
345334
],
346335
)
347336
def test_inference_segmentation_ptl_predict(
348337
tmp_path: Path,
349338
download_coco_val: tuple[Path, Path],
350339
model_cls: type[RFDETR],
351340
threshold_map: float,
341+
threshold_f1: float,
352342
num_samples: int,
353343
batch_size: int,
354344
) -> None:
@@ -362,6 +352,7 @@ def test_inference_segmentation_ptl_predict(
362352
download_coco_val: Fixture providing ``(images_root, annotations_path)``.
363353
model_cls: Segmentation model class to instantiate with pretrained weights.
364354
threshold_map: Minimum ``val/mAP_50`` (bbox) required.
355+
threshold_f1: Minimum ``val/F1`` (best macro-F1 across confidence sweep) required.
365356
num_samples: Number of val samples used for ``Trainer.validate``.
366357
batch_size: DataLoader batch size.
367358
"""
@@ -381,8 +372,10 @@ def test_inference_segmentation_ptl_predict(
381372
assert predictions is not None, "trainer.predict() returned None"
382373
assert len(predictions) > 0, "trainer.predict() returned empty list"
383374

384-
# Verify mAP via Trainer.validate on the full num_samples.
375+
# Verify mAP and F1 via Trainer.validate on the full num_samples.
385376
val_dm = _build_datamodule(rfdetr.model_config, tc, num_samples=num_samples)
386-
results = trainer.validate(module, datamodule=val_dm)
387-
map_val = results[0]["val/mAP_50"]
377+
(metrics,) = trainer.validate(module, datamodule=val_dm)
378+
map_val = metrics["val/mAP_50"]
379+
f1_val = metrics["val/F1"]
388380
assert map_val >= threshold_map, f"mAP@50 {map_val:.4f} < {threshold_map}"
381+
assert f1_val >= threshold_f1, f"F1 {f1_val:.4f} < {threshold_f1}"

tests/models/test_predict.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
from types import SimpleNamespace
88
from typing import Any
99

10+
import PIL.Image
1011
import pytest
1112
import supervision as sv
1213
import torch
1314

15+
from rfdetr import RFDETRNano, RFDETRSegNano
1416
from rfdetr.detr import RFDETR
1517

1618
_HTTP_IMAGE_URL = "http://images.cocodataset.org/val2017/000000397133.jpg"
@@ -57,6 +59,32 @@ def get_model(self, config: SimpleNamespace) -> _DummyModel:
5759
return _DummyModel()
5860

5961

62+
class TestPredictReturnTypes:
63+
"""``RFDETR.predict()`` API contract tests using synthetic images.
64+
65+
Quality is not assessed here — see ``tests/benchmarks/test_coco_inference.py``.
66+
"""
67+
68+
def test_detection_returns_sv_detections(self) -> None:
69+
"""Detection model returns a list of ``sv.Detections``."""
70+
img = PIL.Image.new("RGB", (640, 640), color=(128, 128, 128))
71+
model = RFDETRNano()
72+
detections = model.predict([img, img], threshold=0.3)
73+
assert isinstance(detections, list), "predict() must return a list for multiple inputs"
74+
assert all(isinstance(d, sv.Detections) for d in detections), "Each result must be sv.Detections"
75+
76+
def test_segmentation_returns_sv_detections_with_masks(self) -> None:
77+
"""Segmentation model returns ``sv.Detections`` with the mask field always set."""
78+
img = PIL.Image.new("RGB", (640, 640), color=(128, 128, 128))
79+
model = RFDETRSegNano()
80+
detections = model.predict([img, img], threshold=0.3)
81+
assert isinstance(detections, list), "predict() must return a list for multiple inputs"
82+
assert all(isinstance(d, sv.Detections) for d in detections), "Each result must be sv.Detections"
83+
assert all(d.mask is not None for d in detections), (
84+
"Segmentation predict() must always set the mask field, even when no objects are detected"
85+
)
86+
87+
6088
def test_predict_accepts_image_url() -> None:
6189
if not _is_online(_HTTP_HOST, _HTTP_PORT):
6290
pytest.skip("Offline environment, skipping HTTP predict URL test.")

0 commit comments

Comments
 (0)