feat(detect): 训练期的验证也约束预测框互不重叠

ultralytics 没有可用的开关:yolo26 是端到端模型,non_max_suppression 对它早退
(utils/nms.py 里 end2end 分支),只按置信度与 max_det 截断,传入的 iou 和 agnostic
都不参与 IoU 抑制;agnostic_nms 只作用于检测头 top-k(每个 anchor 取一个类别),
压不掉不同 anchor 产生的重叠框。

所以给 DetectionValidator.postprocess 包一层 filter_overlapping_outputs。训练期验证、
早停、best.pt 的选择都随之遵守这条约束,指标与最终推理一致。配置项 train.max_overlap
(默认 0,负数关闭),由 detect.py 摘出后自行实现,不传给 ultralytics。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-18 11:30:48 +08:00
co-authored by Claude Opus 5
parent eba533a5b2
commit 9439cbb56a
4 changed files with 133 additions and 2 deletions
+55
View File
@@ -10,6 +10,7 @@ from engine.detection import ( # noqa: E402
COLOR_UNMATCHED,
Box,
draw_boxes,
filter_overlapping_outputs,
from_yolo,
iou,
match_boxes,
@@ -190,3 +191,57 @@ def test_max_iou_allows_slight_overlap():
def test_suppress_handles_empty_and_single():
assert suppress_overlaps([]) == []
assert suppress_overlaps([Box(0, 0.1, 0.1, 0.2, 0.2, 0.5)]) == [0]
# ---------------- 验证器输出的过滤 ----------------
def test_filter_overlapping_outputs_drops_the_lower_score():
"""ultralytics 验证器的输出是张量字典,筛选要对每个"每框一行"的张量同步生效。"""
torch = pytest.importorskip("torch")
output = {
"bboxes": torch.tensor([[0.0, 0.0, 10.0, 10.0], [1.0, 1.0, 11.0, 11.0],
[50.0, 50.0, 60.0, 60.0]]),
"conf": torch.tensor([0.9, 0.5, 0.8]),
"cls": torch.tensor([0.0, 1.0, 2.0]),
"extra": torch.zeros((3, 0)),
}
filtered = filter_overlapping_outputs([output])[0]
assert filtered["conf"].tolist() == pytest.approx([0.9, 0.8]) # 中间那个与第一个重叠
assert filtered["cls"].tolist() == [0.0, 2.0]
assert filtered["bboxes"].shape == (2, 4)
assert filtered["extra"].shape == (2, 0)
def test_filter_keeps_the_object_untouched_when_nothing_overlaps():
torch = pytest.importorskip("torch")
output = {
"bboxes": torch.tensor([[0.0, 0.0, 1.0, 1.0], [5.0, 5.0, 6.0, 6.0]]),
"conf": torch.tensor([0.9, 0.8]),
"cls": torch.tensor([0.0, 0.0]),
}
filtered = filter_overlapping_outputs([output])
assert filtered[0] is output # 没有变化时原样返回,不做无谓的拷贝
def test_filter_handles_images_with_no_detection():
torch = pytest.importorskip("torch")
empty = {"bboxes": torch.zeros((0, 4)), "conf": torch.zeros(0), "cls": torch.zeros(0)}
assert filter_overlapping_outputs([empty])[0]["conf"].numel() == 0
def test_filter_can_empty_an_image_entirely():
"""全部互相重叠时只留一个;这里构造两个几乎重合的框。"""
torch = pytest.importorskip("torch")
output = {
"bboxes": torch.tensor([[0.0, 0.0, 10.0, 10.0], [0.1, 0.1, 10.1, 10.1]]),
"conf": torch.tensor([0.3, 0.9]),
"cls": torch.tensor([1.0, 1.0]),
}
filtered = filter_overlapping_outputs([output])[0]
assert filtered["conf"].tolist() == pytest.approx([0.9])