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
171 lines
7.5 KiB
Python
171 lines
7.5 KiB
Python
# ------------------------------------------------------------------------
|
||
# RF-DETR
|
||
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
||
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
||
# ------------------------------------------------------------------------
|
||
"""Tests for PostProcess box clamping behaviour."""
|
||
|
||
import pytest
|
||
import torch
|
||
|
||
from rfdetr.models.postprocess import PostProcess
|
||
|
||
|
||
class TestGatherAndScaleBoxes:
|
||
"""Tests for :meth:`PostProcess._gather_and_scale_boxes`."""
|
||
|
||
def test_clamps_boxes_to_image_bounds(self):
|
||
"""Boxes that extrapolate beyond [0, 1] in normalized space are clamped to pixel-space image dimensions after
|
||
scaling."""
|
||
# Three synthetic boxes in cxcywh normalized coords:
|
||
# [0] cx=0.01, w=0.10 → x1 = (0.01 - 0.05) * 640 = -25.6 ← negative
|
||
# [1] cx=0.99, w=0.10 → x2 = (0.99 + 0.05) * 640 = 665.6 ← overflow
|
||
# [2] cx=0.50, w=0.20 → fully in-bounds
|
||
out_bbox = torch.tensor(
|
||
[
|
||
[
|
||
[0.01, 0.01, 0.10, 0.10], # negative x1, y1 after scale
|
||
[0.99, 0.99, 0.10, 0.10], # x2 > img_w, y2 > img_h after scale
|
||
[0.50, 0.50, 0.20, 0.20], # in-bounds control
|
||
]
|
||
]
|
||
) # shape (B=1, Q=3, 4)
|
||
|
||
topk_boxes = torch.tensor([[0, 1, 2]]) # select all three
|
||
target_sizes = torch.tensor([[480, 640]]) # (h, w)
|
||
|
||
boxes = PostProcess._gather_and_scale_boxes(out_bbox, topk_boxes, target_sizes)
|
||
|
||
img_h, img_w = 480, 640
|
||
|
||
# All coords must be >= 0
|
||
assert (boxes >= 0).all(), f"Negative coords present: {boxes[boxes < 0]}"
|
||
# x1, x2 must be <= image width
|
||
assert (boxes[..., 0] <= img_w).all()
|
||
assert (boxes[..., 2] <= img_w).all()
|
||
# y1, y2 must be <= image height
|
||
assert (boxes[..., 1] <= img_h).all()
|
||
assert (boxes[..., 3] <= img_h).all()
|
||
|
||
# Exact clamped values — bounds-only check cannot catch a clamp returning e.g. 1.0 instead of 0.0
|
||
# box [0]: x1_raw=-25.6, y1_raw=-19.2 → clamped to 0.0
|
||
assert boxes[0, 0, 0].item() == pytest.approx(0.0), "x1 of underflowing box must clamp to 0"
|
||
assert boxes[0, 0, 1].item() == pytest.approx(0.0), "y1 of underflowing box must clamp to 0"
|
||
# box [1]: x2_raw=665.6 → clamped to img_w=640.0; y2_raw=499.2 → clamped to img_h=480.0
|
||
assert boxes[0, 1, 2].item() == pytest.approx(640.0), "x2 of overflowing box must clamp to img_w"
|
||
assert boxes[0, 1, 3].item() == pytest.approx(480.0), "y2 of overflowing box must clamp to img_h"
|
||
|
||
def test_in_bounds_boxes_unchanged(self):
|
||
"""Boxes already within image bounds are not altered by clamping."""
|
||
out_bbox = torch.tensor(
|
||
[
|
||
[
|
||
[0.30, 0.30, 0.20, 0.20],
|
||
[0.70, 0.60, 0.30, 0.40],
|
||
]
|
||
]
|
||
)
|
||
|
||
topk_boxes = torch.tensor([[0, 1]])
|
||
target_sizes = torch.tensor([[480, 640]])
|
||
|
||
boxes = PostProcess._gather_and_scale_boxes(out_bbox, topk_boxes, target_sizes)
|
||
|
||
# Manually computed expected values (no clamping needed)
|
||
expected = torch.tensor(
|
||
[
|
||
[
|
||
[128.0, 96.0, 256.0, 192.0], # cx=0.30,cy=0.30,w=0.20,h=0.20
|
||
[352.0, 192.0, 544.0, 384.0], # cx=0.70,cy=0.60,w=0.30,h=0.40
|
||
]
|
||
]
|
||
)
|
||
|
||
assert torch.allclose(boxes, expected, atol=1e-4), (
|
||
f"In-bounds boxes were altered.\nExpected:\n{expected}\nGot:\n{boxes}"
|
||
)
|
||
|
||
def test_multiple_images_in_batch(self):
|
||
"""Clamping works correctly across a batch of mixed image sizes."""
|
||
out_bbox = torch.tensor(
|
||
[
|
||
[
|
||
[0.01, 0.50, 0.10, 0.20], # image 0: negative x1
|
||
],
|
||
[
|
||
[0.99, 0.50, 0.10, 0.20], # image 1: x2 overflow
|
||
],
|
||
]
|
||
)
|
||
|
||
topk_boxes = torch.tensor([[0], [0]])
|
||
target_sizes = torch.tensor(
|
||
[
|
||
[300, 400], # image 0: 400×300
|
||
[600, 800], # image 1: 800×600
|
||
]
|
||
)
|
||
|
||
boxes = PostProcess._gather_and_scale_boxes(out_bbox, topk_boxes, target_sizes)
|
||
|
||
# Image 0: all coords must be in [0, 400]×[0, 300]
|
||
assert (boxes[0, :, 0] >= 0).all(), "img0 x1: expected >= 0"
|
||
assert (boxes[0, :, 0] <= 400).all(), "img0 x1: expected <= img_w (400)"
|
||
assert (boxes[0, :, 1] >= 0).all(), "img0 y1: expected >= 0"
|
||
assert (boxes[0, :, 1] <= 300).all(), "img0 y1: expected <= img_h (300)"
|
||
assert (boxes[0, :, 2] >= 0).all(), "img0 x2: expected >= 0"
|
||
assert (boxes[0, :, 2] <= 400).all(), "img0 x2: expected <= img_w (400)"
|
||
assert (boxes[0, :, 3] >= 0).all(), "img0 y2: expected >= 0"
|
||
assert (boxes[0, :, 3] <= 300).all(), "img0 y2: expected <= img_h (300)"
|
||
|
||
# Image 1: all coords must be in [0, 800]×[0, 600]
|
||
assert (boxes[1, :, 0] >= 0).all(), "img1 x1: expected >= 0"
|
||
assert (boxes[1, :, 0] <= 800).all(), "img1 x1: expected <= img_w (800)"
|
||
assert (boxes[1, :, 1] >= 0).all(), "img1 y1: expected >= 0"
|
||
assert (boxes[1, :, 1] <= 600).all(), "img1 y1: expected <= img_h (600)"
|
||
assert (boxes[1, :, 2] >= 0).all(), "img1 x2: expected >= 0"
|
||
assert (boxes[1, :, 2] <= 800).all(), "img1 x2: expected <= img_w (800)"
|
||
assert (boxes[1, :, 3] >= 0).all(), "img1 y2: expected >= 0"
|
||
assert (boxes[1, :, 3] <= 600).all(), "img1 y2: expected <= img_h (600)"
|
||
|
||
|
||
class TestPostProcessForward:
|
||
"""Integration tests for :meth:`PostProcess.forward`."""
|
||
|
||
def test_forward_clamps_edge_boxes_to_bounds(self):
|
||
"""PostProcess.forward returns non-negative in-bounds boxes for edge-hugging predictions."""
|
||
postprocess = PostProcess(num_select=2)
|
||
outputs = {
|
||
"pred_logits": torch.tensor([[[10.0, -10.0], [9.0, -10.0]]]),
|
||
"pred_boxes": torch.tensor([[[0.01, 0.01, 0.10, 0.10], [0.99, 0.99, 0.10, 0.10]]]),
|
||
}
|
||
target_sizes = torch.tensor([[480, 640]])
|
||
results = postprocess(outputs, target_sizes)
|
||
boxes = results[0]["boxes"]
|
||
assert (boxes >= 0).all(), f"Negative coords present: {boxes[boxes < 0]}"
|
||
assert (boxes[..., 0] <= 640).all(), "x1 exceeds img_w (640)"
|
||
assert (boxes[..., 2] <= 640).all(), "x2 exceeds img_w (640)"
|
||
assert (boxes[..., 1] <= 480).all(), "y1 exceeds img_h (480)"
|
||
assert (boxes[..., 3] <= 480).all(), "y2 exceeds img_h (480)"
|
||
|
||
|
||
class TestPostProcessMasks:
|
||
"""Tests for :meth:`PostProcess._postprocess_masks` mask resizing."""
|
||
|
||
def test_chunked_upsample_preserves_shape_for_large_k(self):
|
||
"""Chunked upsampling of K=64 masks returns full-resolution boolean masks of shape [K, 1, H, W]."""
|
||
batch, num_queries, mask_h, mask_w = 1, 64, 16, 16
|
||
num_select, img_h, img_w = 64, 512, 512
|
||
out_masks = torch.randn(batch, num_queries, mask_h, mask_w)
|
||
scores = torch.rand(batch, num_select)
|
||
labels = torch.zeros(batch, num_select, dtype=torch.long)
|
||
boxes = torch.zeros(batch, num_select, 4)
|
||
topk_boxes = torch.arange(num_select).unsqueeze(0)
|
||
target_sizes = torch.tensor([[img_h, img_w]])
|
||
|
||
results = PostProcess._postprocess_masks(out_masks, scores, labels, boxes, topk_boxes, target_sizes)
|
||
|
||
masks = results[0]["masks"]
|
||
assert masks.shape == (num_select, 1, img_h, img_w)
|
||
assert masks.dtype == torch.bool
|