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
402 lines
15 KiB
Python
402 lines
15 KiB
Python
# ------------------------------------------------------------------------
|
|
# RF-DETR
|
|
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
|
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
|
# ------------------------------------------------------------------------
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from rfdetr.models import matcher as matcher_module
|
|
from rfdetr.models.matcher import HungarianMatcher
|
|
|
|
|
|
@pytest.fixture()
|
|
def matcher() -> HungarianMatcher:
|
|
"""Shared HungarianMatcher instance."""
|
|
return HungarianMatcher()
|
|
|
|
|
|
@pytest.fixture()
|
|
def standard_target() -> dict[str, torch.Tensor]:
|
|
"""Single-class target with one box at (0.5, 0.5, 0.2, 0.2)."""
|
|
return {
|
|
"labels": torch.tensor([0], dtype=torch.int64),
|
|
"boxes": torch.tensor([[0.5, 0.5, 0.2, 0.2]], dtype=torch.float32),
|
|
}
|
|
|
|
|
|
class TestHungarianMatcherNonFiniteCosts:
|
|
"""Tests for non-finite cost matrix sanitization in the Hungarian matcher."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"invalid_value",
|
|
[
|
|
pytest.param(float("nan"), id="nan"),
|
|
pytest.param(float("inf"), id="inf"),
|
|
pytest.param(float("-inf"), id="-inf"),
|
|
],
|
|
)
|
|
def test_replaces_non_finite_costs_before_assignment(
|
|
self,
|
|
matcher: HungarianMatcher,
|
|
standard_target: dict[str, torch.Tensor],
|
|
invalid_value: float,
|
|
) -> None:
|
|
"""Matcher should sanitize non-finite costs so assignment still succeeds."""
|
|
outputs = {
|
|
"pred_logits": torch.tensor([[[0.0], [10.0]]], dtype=torch.float32),
|
|
"pred_boxes": torch.tensor(
|
|
[
|
|
[
|
|
[invalid_value, 0.5, 0.2, 0.2],
|
|
[0.5, 0.5, 0.2, 0.2],
|
|
]
|
|
],
|
|
dtype=torch.float32,
|
|
),
|
|
}
|
|
|
|
matched_queries, matched_targets = matcher(outputs, [standard_target])[0]
|
|
|
|
assert matched_queries.tolist() == [1]
|
|
assert matched_targets.tolist() == [0]
|
|
|
|
def test_all_nonfinite_produces_valid_assignment(
|
|
self,
|
|
matcher: HungarianMatcher,
|
|
standard_target: dict[str, torch.Tensor],
|
|
) -> None:
|
|
"""When ALL costs are non-finite, the fallback sentinel (``dtype_info.max``)
|
|
should allow ``linear_sum_assignment`` to complete with a valid 1-to-1
|
|
assignment: exactly one match, query index in [0, num_queries), target index 0.
|
|
|
|
This exercises the ``else: replacement_cost = C.new_tensor(dtype_info.max)`` branch.
|
|
"""
|
|
nan = float("nan")
|
|
outputs = {
|
|
"pred_logits": torch.tensor([[[nan], [nan]]], dtype=torch.float32),
|
|
"pred_boxes": torch.tensor(
|
|
[
|
|
[
|
|
[nan, nan, nan, nan],
|
|
[nan, nan, nan, nan],
|
|
]
|
|
],
|
|
dtype=torch.float32,
|
|
),
|
|
}
|
|
|
|
matched_queries, matched_targets = matcher(outputs, [standard_target])[0]
|
|
|
|
assert len(matched_queries) == len(matched_targets) == 1
|
|
assert 0 <= matched_queries.item() < 2
|
|
assert matched_targets.item() == 0
|
|
|
|
def test_negative_costs_with_nan_selects_valid_query(
|
|
self,
|
|
matcher: HungarianMatcher,
|
|
standard_target: dict[str, torch.Tensor],
|
|
) -> None:
|
|
"""Regression test: when all finite costs are negative and one query produces NaN, the matcher must select the
|
|
valid query, not the NaN one.
|
|
|
|
This guards against the bug where ``max_cost * 2`` (the old replacement formula) could be smaller than
|
|
``max_cost`` when all costs are negative, causing the NaN query to appear cheaper than valid queries.
|
|
"""
|
|
nan = float("nan")
|
|
# Query 0: NaN box coordinates -> produces non-finite costs
|
|
# Query 1: valid box, low logit -> all-negative but finite costs
|
|
outputs = {
|
|
"pred_logits": torch.tensor([[[0.0], [-10.0]]], dtype=torch.float32),
|
|
"pred_boxes": torch.tensor(
|
|
[
|
|
[
|
|
[nan, nan, nan, nan],
|
|
[0.5, 0.5, 0.2, 0.2],
|
|
]
|
|
],
|
|
dtype=torch.float32,
|
|
),
|
|
}
|
|
|
|
matched_queries, matched_targets = matcher(outputs, [standard_target])[0]
|
|
|
|
# The valid query (index 1) must be matched, not the NaN query.
|
|
assert matched_queries.tolist() == [1]
|
|
assert matched_targets.tolist() == [0]
|
|
|
|
@pytest.mark.parametrize(
|
|
"image_idx, expected_query_idx",
|
|
[
|
|
pytest.param(0, 1, id="image0"),
|
|
pytest.param(1, 0, id="image1"),
|
|
],
|
|
)
|
|
def test_batch_size_greater_than_one(
|
|
self,
|
|
matcher: HungarianMatcher,
|
|
image_idx: int,
|
|
expected_query_idx: int,
|
|
) -> None:
|
|
"""Exercises the ``C.split(sizes, -1)`` loop with batch_size > 1.
|
|
|
|
Each image has 2 queries and 1 target. One query per image has NaN coordinates; the matcher must select the
|
|
valid query in each case.
|
|
"""
|
|
nan = float("nan")
|
|
outputs = {
|
|
"pred_logits": torch.tensor(
|
|
[
|
|
[[0.0], [10.0]], # image 0: query 1 is valid
|
|
[[10.0], [0.0]], # image 1: query 0 is valid
|
|
],
|
|
dtype=torch.float32,
|
|
),
|
|
"pred_boxes": torch.tensor(
|
|
[
|
|
[
|
|
[nan, 0.5, 0.2, 0.2], # image 0, query 0: NaN
|
|
[0.5, 0.5, 0.2, 0.2], # image 0, query 1: valid
|
|
],
|
|
[
|
|
[0.5, 0.5, 0.2, 0.2], # image 1, query 0: valid
|
|
[nan, 0.5, 0.2, 0.2], # image 1, query 1: NaN
|
|
],
|
|
],
|
|
dtype=torch.float32,
|
|
),
|
|
}
|
|
targets = [
|
|
{
|
|
"labels": torch.tensor([0], dtype=torch.int64),
|
|
"boxes": torch.tensor([[0.5, 0.5, 0.2, 0.2]], dtype=torch.float32),
|
|
},
|
|
{
|
|
"labels": torch.tensor([0], dtype=torch.int64),
|
|
"boxes": torch.tensor([[0.5, 0.5, 0.2, 0.2]], dtype=torch.float32),
|
|
},
|
|
]
|
|
|
|
results = matcher(outputs, targets)
|
|
|
|
assert len(results) == 2
|
|
|
|
matched_queries, matched_targets = results[image_idx]
|
|
assert matched_queries.tolist() == [expected_query_idx]
|
|
assert matched_targets.tolist() == [0]
|
|
|
|
def test_group_detr_with_nonfinite_costs(
|
|
self,
|
|
matcher: HungarianMatcher,
|
|
standard_target: dict[str, torch.Tensor],
|
|
) -> None:
|
|
"""Sanitization runs on the full cost matrix before splitting by group, so non-finite entries must be handled
|
|
correctly when ``group_detr > 1``.
|
|
|
|
4 queries, 2 groups of 2. Query 0 has a NaN box; query 2 (the best valid match in group 1) must be selected
|
|
across groups.
|
|
"""
|
|
nan = float("nan")
|
|
outputs = {
|
|
"pred_logits": torch.tensor(
|
|
[[[0.0], [10.0], [0.0], [10.0]]],
|
|
dtype=torch.float32,
|
|
),
|
|
"pred_boxes": torch.tensor(
|
|
[
|
|
[
|
|
[nan, nan, nan, nan], # group 0, query 0: NaN
|
|
[0.5, 0.5, 0.2, 0.2], # group 0, query 1: valid
|
|
[nan, nan, nan, nan], # group 1, query 0: NaN
|
|
[0.5, 0.5, 0.2, 0.2], # group 1, query 1: valid
|
|
]
|
|
],
|
|
dtype=torch.float32,
|
|
),
|
|
}
|
|
|
|
results = matcher(outputs, [standard_target], group_detr=2)
|
|
|
|
assert len(results) == 1
|
|
matched_queries, matched_targets = results[0]
|
|
# Each group contributes one match; both must map to target 0
|
|
assert matched_targets.tolist() == [0, 0]
|
|
# The valid query in each group (indices 1 and 3) must be selected
|
|
assert set(matched_queries.tolist()) == {1, 3}
|
|
|
|
def test_warns_once_per_matcher_instance(
|
|
self, standard_target: dict[str, torch.Tensor], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Non-finite-cost warning should be emitted once per matcher instance."""
|
|
expected_warning = (
|
|
"Non-finite values detected in matcher cost matrix; "
|
|
"replacing with finite sentinel. "
|
|
"Check for numerical instability."
|
|
)
|
|
warning_messages: list[str] = []
|
|
|
|
def record_warning(msg: str, *args: object, **kwargs: object) -> None:
|
|
warning_messages.append(msg)
|
|
|
|
monkeypatch.setattr(matcher_module.logger, "warning", record_warning)
|
|
|
|
outputs = {
|
|
"pred_logits": torch.tensor([[[0.0], [10.0]]], dtype=torch.float32),
|
|
"pred_boxes": torch.tensor(
|
|
[
|
|
[
|
|
[float("nan"), 0.5, 0.2, 0.2],
|
|
[0.5, 0.5, 0.2, 0.2],
|
|
]
|
|
],
|
|
dtype=torch.float32,
|
|
),
|
|
}
|
|
|
|
first_matcher = HungarianMatcher()
|
|
second_matcher = HungarianMatcher()
|
|
|
|
first_matcher(outputs, [standard_target])
|
|
first_matcher(outputs, [standard_target])
|
|
second_matcher(outputs, [standard_target])
|
|
|
|
assert warning_messages == [expected_warning, expected_warning]
|
|
|
|
|
|
class TestHungarianMatcherSanitization:
|
|
"""Unit tests for the private matcher cost sanitization helper."""
|
|
|
|
def test_sanitize_cost_matrix_replaces_non_finite_entries(self) -> None:
|
|
"""Non-finite entries should be replaced with a larger finite sentinel."""
|
|
cost_matrix = torch.tensor(
|
|
[
|
|
[1.0, float("nan")],
|
|
[float("inf"), -2.0],
|
|
],
|
|
dtype=torch.float32,
|
|
)
|
|
|
|
sanitized = HungarianMatcher._sanitize_cost_matrix(cost_matrix)
|
|
|
|
assert torch.isfinite(sanitized).all()
|
|
assert sanitized[0, 1] == 4.0
|
|
assert sanitized[1, 0] == 4.0
|
|
assert sanitized[0, 0] == 1.0
|
|
assert sanitized[1, 1] == -2.0
|
|
|
|
def test_sanitize_cost_matrix_all_non_finite_fallback(self) -> None:
|
|
"""All-non-finite matrices should fall back to the dtype maximum."""
|
|
cost_matrix = torch.tensor(
|
|
[
|
|
[float("nan"), float("inf")],
|
|
[float("-inf"), float("nan")],
|
|
],
|
|
dtype=torch.float32,
|
|
)
|
|
|
|
sanitized = HungarianMatcher._sanitize_cost_matrix(cost_matrix)
|
|
|
|
assert torch.isfinite(sanitized).all()
|
|
assert torch.all(sanitized == torch.finfo(cost_matrix.dtype).max)
|
|
|
|
def test_sanitize_cost_matrix_clamps_overflowing_replacement_cost(self) -> None:
|
|
"""Overflow in the computed replacement cost should clamp to dtype max."""
|
|
dtype_max = torch.finfo(torch.float32).max
|
|
cost_matrix = torch.tensor(
|
|
[
|
|
[dtype_max, float("nan")],
|
|
[0.0, 1.0],
|
|
],
|
|
dtype=torch.float32,
|
|
)
|
|
|
|
sanitized = HungarianMatcher._sanitize_cost_matrix(cost_matrix)
|
|
|
|
assert torch.isfinite(sanitized).all()
|
|
assert sanitized[0, 1] == dtype_max
|
|
|
|
|
|
class TestHungarianMatcherFocalAlpha:
|
|
"""The configured ``focal_alpha`` must drive the classification matching cost."""
|
|
|
|
def test_focal_alpha_changes_assignment(self) -> None:
|
|
"""Two matchers differing only in ``focal_alpha`` must be able to produce different assignments.
|
|
|
|
``focal_alpha`` is accepted, documented as "used in the classification cost", and stored on the matcher, so it
|
|
must actually influence matching. This input is chosen so the optimal query->target pairing flips between
|
|
``focal_alpha=0.25`` and ``focal_alpha=0.90``; if the cost ignores the configured alpha, both assignments
|
|
collapse to the same result.
|
|
"""
|
|
outputs = {
|
|
"pred_logits": torch.tensor(
|
|
[[[2.3936, -1.4217], [2.3731, -2.1974]]],
|
|
dtype=torch.float32,
|
|
),
|
|
"pred_boxes": torch.tensor(
|
|
[[[0.3898, 0.4340, 0.5331, 0.1901], [0.4256, 0.1002, 0.6955, 0.7815]]],
|
|
dtype=torch.float32,
|
|
),
|
|
}
|
|
targets = [
|
|
{
|
|
"labels": torch.tensor([0, 1], dtype=torch.int64),
|
|
"boxes": torch.tensor(
|
|
[[0.2111, 0.6630, 0.7569, 0.8855], [0.7750, 0.4393, 0.8838, 0.8792]],
|
|
dtype=torch.float32,
|
|
),
|
|
}
|
|
]
|
|
|
|
def assignment(focal_alpha: float) -> list[int]:
|
|
matcher = HungarianMatcher(cost_class=2.0, cost_bbox=5.0, cost_giou=2.0, focal_alpha=focal_alpha)
|
|
matched_queries, matched_targets = matcher(outputs, targets)[0]
|
|
# Queries ordered by the target index they are matched to.
|
|
return matched_queries[matched_targets.argsort()].tolist()
|
|
|
|
assert assignment(0.25) != assignment(0.90)
|
|
# Pin the exact expected mappings so a misapplied-alpha refactor is caught even when
|
|
# the two values remain different for unrelated reasons.
|
|
assert assignment(0.25) == [0, 1]
|
|
assert assignment(0.90) == [1, 0]
|
|
|
|
@pytest.mark.parametrize(
|
|
"focal_alpha, expected",
|
|
[
|
|
pytest.param(0.0, [0, 1], id="alpha_zero_pos_cost_zeroed"),
|
|
pytest.param(1.0, [1, 0], id="alpha_one_neg_cost_zeroed"),
|
|
],
|
|
)
|
|
def test_focal_alpha_boundary_values_no_nan(self, focal_alpha: float, expected: list[int]) -> None:
|
|
"""Degenerate focal_alpha values (0.0 and 1.0) must not produce NaN and must yield a valid assignment.
|
|
|
|
focal_alpha=0.0 zeroes ``pos_cost_class``; focal_alpha=1.0 zeroes ``neg_cost_class``. Neither path touches
|
|
``log(prob)`` directly (formula uses logsigmoid of logits), so no division-by-zero or NaN can occur.
|
|
"""
|
|
outputs = {
|
|
"pred_logits": torch.tensor(
|
|
[[[2.3936, -1.4217], [2.3731, -2.1974]]],
|
|
dtype=torch.float32,
|
|
),
|
|
"pred_boxes": torch.tensor(
|
|
[[[0.3898, 0.4340, 0.5331, 0.1901], [0.4256, 0.1002, 0.6955, 0.7815]]],
|
|
dtype=torch.float32,
|
|
),
|
|
}
|
|
targets = [
|
|
{
|
|
"labels": torch.tensor([0, 1], dtype=torch.int64),
|
|
"boxes": torch.tensor(
|
|
[[0.2111, 0.6630, 0.7569, 0.8855], [0.7750, 0.4393, 0.8838, 0.8792]],
|
|
dtype=torch.float32,
|
|
),
|
|
}
|
|
]
|
|
|
|
matcher = HungarianMatcher(cost_class=2.0, cost_bbox=5.0, cost_giou=2.0, focal_alpha=focal_alpha)
|
|
matched_queries, matched_targets = matcher(outputs, targets)[0]
|
|
|
|
assert not matcher._warned_non_finite_costs, "boundary focal_alpha produced non-finite costs"
|
|
result = matched_queries[matched_targets.argsort()].tolist()
|
|
assert result == expected
|