16031aae96
CPU tests Workflow / Testing (ubuntu-latest, 3.12) (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.13) (push) Failing after 0s
Mypy Type Check / Type Check (push) Failing after 0s
Docs/Test WorkFlow / Test docs build (push) Failing after 1s
PR Conflict Labeler / labeling (push) Failing after 1s
Dependency resolution / Resolve [tflite] extra — Python 3.12 (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.10) (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.13) (push) Failing after 1s
CPU tests Workflow / build-pkg (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.10) (push) Failing after 0s
CPU tests Workflow / Testing (ubuntu-latest, 3.11) (push) Failing after 0s
Smoke Tests / try-all-models (macos-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (macos-latest, 3.13) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / testing-guardian (push) Has been cancelled
GPU tests Workflow / Testing (push) Has been cancelled
135 lines
5.0 KiB
Python
135 lines
5.0 KiB
Python
# ------------------------------------------------------------------------
|
|
# RF-DETR
|
|
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
|
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
|
# ------------------------------------------------------------------------
|
|
"""Unit tests for SetCriterion edge paths: _output_device and num_boxes_for_targets."""
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from rfdetr.models.criterion import SetCriterion
|
|
|
|
|
|
class _MatcherStub:
|
|
"""Minimal matcher that returns identity indices for every target in the batch."""
|
|
|
|
def __call__(self, outputs, targets, group_detr=1):
|
|
return [(torch.arange(len(t["labels"])), torch.arange(len(t["labels"]))) for t in targets]
|
|
|
|
|
|
def _bare_criterion() -> SetCriterion:
|
|
"""Return a SetCriterion with no losses so forward() is a no-op."""
|
|
criterion = SetCriterion.__new__(SetCriterion)
|
|
criterion.training = True
|
|
criterion.group_detr = 1
|
|
criterion.sum_group_losses = False
|
|
criterion.losses = []
|
|
criterion.weight_dict = {}
|
|
criterion.matcher = _MatcherStub()
|
|
criterion.num_keypoints_per_class = []
|
|
return criterion
|
|
|
|
|
|
class TestOutputDevice:
|
|
"""Tests for SetCriterion._output_device — probes top-level tensor values only."""
|
|
|
|
def test_returns_device_of_first_tensor(self):
|
|
"""Device inferred from the first tensor value in outputs."""
|
|
outputs = {"pred_logits": torch.zeros(1, 1, 1)}
|
|
|
|
device = SetCriterion._output_device(outputs)
|
|
|
|
assert device == torch.device("cpu")
|
|
|
|
def test_raises_when_no_tensor_present(self):
|
|
"""ValueError raised when no top-level value is a tensor."""
|
|
outputs = {"meta": "string_value", "count": 42}
|
|
|
|
with pytest.raises(ValueError, match="at least one tensor"):
|
|
SetCriterion._output_device(outputs)
|
|
|
|
def test_skips_non_tensor_values(self):
|
|
"""Non-tensor entries at the top level are skipped; first tensor wins."""
|
|
outputs = {"meta": "ignored", "pred_logits": torch.zeros(1, 1, 1)}
|
|
|
|
device = SetCriterion._output_device(outputs)
|
|
|
|
assert device == torch.device("cpu")
|
|
|
|
|
|
class TestNumBoxesForTargets:
|
|
"""Tests for SetCriterion.num_boxes_for_targets — clamp and empty-target edge cases."""
|
|
|
|
def test_returns_tensor_gte_one(self):
|
|
"""Result must be clamped to >= 1.0 to prevent division by zero."""
|
|
criterion = _bare_criterion()
|
|
outputs = {"pred_logits": torch.zeros(1, 1, 1)}
|
|
targets = [{"labels": torch.tensor([0, 1])}]
|
|
|
|
result = criterion.num_boxes_for_targets(outputs, targets)
|
|
|
|
assert result.item() >= 1.0
|
|
|
|
def test_clamps_zero_box_count_to_one(self):
|
|
"""Empty targets (no labels) must clamp to 1.0 to avoid zero denominator."""
|
|
criterion = _bare_criterion()
|
|
outputs = {"pred_logits": torch.zeros(1, 1, 1)}
|
|
targets = [{"labels": torch.zeros(0, dtype=torch.int64)}]
|
|
|
|
result = criterion.num_boxes_for_targets(outputs, targets)
|
|
|
|
assert result.item() == pytest.approx(1.0)
|
|
|
|
def test_clamps_empty_target_list(self):
|
|
"""Empty target list (batch_size=0 edge case) must also clamp to 1.0."""
|
|
criterion = _bare_criterion()
|
|
outputs = {"pred_logits": torch.zeros(1, 1, 1)}
|
|
targets = []
|
|
|
|
result = criterion.num_boxes_for_targets(outputs, targets)
|
|
|
|
assert result.item() == pytest.approx(1.0)
|
|
|
|
def test_counts_labels_correctly(self):
|
|
"""Box count equals total number of labels across all targets in the batch."""
|
|
criterion = _bare_criterion()
|
|
outputs = {"pred_logits": torch.zeros(1, 1, 1)}
|
|
targets = [
|
|
{"labels": torch.tensor([0, 1])},
|
|
{"labels": torch.tensor([0])},
|
|
]
|
|
|
|
result = criterion.num_boxes_for_targets(outputs, targets)
|
|
|
|
# 2 + 1 = 3 boxes; single-process so no all-reduce
|
|
assert result.item() == pytest.approx(3.0)
|
|
|
|
|
|
class TestLossMasksEmptyMatch:
|
|
"""Tests for the dict-path zero-GT branch of SetCriterion.loss_masks."""
|
|
|
|
def test_dict_path_zero_gt_stays_connected_to_graph(self):
|
|
"""Zero-match dict path returns a loss that back-propagates to every segmentation-head output."""
|
|
criterion = _bare_criterion()
|
|
spatial_features = torch.randn(1, 4, 8, 8, requires_grad=True)
|
|
query_features = torch.randn(1, 5, 4, requires_grad=True)
|
|
bias = torch.randn(1, requires_grad=True)
|
|
outputs = {
|
|
"pred_masks": {
|
|
"spatial_features": spatial_features,
|
|
"query_features": query_features,
|
|
"bias": bias,
|
|
}
|
|
}
|
|
empty = torch.empty(0, dtype=torch.long)
|
|
indices = [(empty, empty)]
|
|
|
|
losses = criterion.loss_masks(outputs, targets=[{}], indices=indices, num_boxes=1)
|
|
|
|
assert losses["loss_mask_ce"].requires_grad
|
|
(losses["loss_mask_ce"] + losses["loss_mask_dice"]).backward()
|
|
assert spatial_features.grad is not None
|
|
assert query_features.grad is not None
|
|
assert bias.grad is not None
|