feat(detect): 推理侧改为切片,与固定 160 输入一致

训练数据已经是固定边长的片,推理还把整图交给 ultralytics 就会被 letterbox 缩放,
与模型见过的形态不符。三个调用方(可视化、批量推送预测、ML backend)统一改成
切片推理再拼回原图。

- engine/tiling.merge_tiles / tiled_predict:映射回原图、裁掉镜像填充区的框、跨片去重。
  推理框架留在调用方,几何部分可单测。
- engine/detection.nms:标准 NMS,另加按包含比例抑制。缺陷被切割线切开时,相邻片
  只看到一角,给出的小残框与完整框 IoU 很小(面积悬殊),光靠 IoU 压不掉。
- engine/tiled_inference:ultralytics 的接线,内部分批,避免片数多时 OOM。
- 三处调用点加 --tile-size / --tile-overlap(后端为 YOLO_TILE_SIZE / YOLO_TILE_OVERLAP),
  默认 160 / 32;设 0 退回整图推理,仅用于和旧模型对比。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-23 15:12:35 +08:00
co-authored by Claude Opus 5
parent 2a92275317
commit 474b7b50a0
9 changed files with 412 additions and 75 deletions
+39
View File
@@ -13,6 +13,7 @@ from engine.detection import ( # noqa: E402
from_yolo,
iou,
match_boxes,
nms,
parse_label,
)
@@ -129,3 +130,41 @@ def test_draw_handles_boxes_at_the_border():
prediction = Box(0, 0.0, 0.0, 0.5, 0.05, 0.9)
draw_boxes(image, [], [prediction], ["blackdot"], match_boxes([prediction], []))
assert COLOR_UNMATCHED in _colors(image)
# ---------------- NMS ----------------
def test_nms_removes_duplicates_of_the_same_defect():
high = Box(0, 0.30, 0.30, 0.50, 0.50, 0.9)
duplicate = Box(0, 0.31, 0.31, 0.51, 0.51, 0.7)
assert nms([duplicate, high]) == [1]
def test_nms_keeps_neighbours_below_the_threshold():
"""真正分开的两个缺陷,轻微相邻也要都留住。"""
a = Box(0, 0.0, 0.0, 0.5, 0.5, 0.9)
b = Box(0, 0.4, 0.4, 0.9, 0.9, 0.8) # IoU 约 0.02
assert nms([a, b]) == [0, 1]
def test_nms_is_per_class_by_default():
"""不同类别在同一位置的分歧是有意义的,不该互相压制。"""
a = Box(0, 0.3, 0.3, 0.6, 0.6, 0.9)
b = Box(4, 0.31, 0.31, 0.61, 0.61, 0.8)
assert nms([a, b]) == [0, 1]
assert nms([a, b], per_class=False) == [0]
def test_nms_handles_empty_input():
assert nms([]) == []
def test_nms_can_suppress_contained_fragments():
"""切片接缝上的残框:与完整框 IoU 很小,却几乎整个落在里面。"""
full = Box(0, 0.30, 0.50, 0.40, 0.60, 0.9)
fragment = Box(0, 0.39, 0.50, 0.40, 0.60, 0.8) # 只剩一条边
assert iou(full, fragment) < 0.15
assert nms([full, fragment]) == [0, 1] # 只看 IoU 压不掉
assert nms([full, fragment], contained_threshold=0.8) == [0] # 按包含比例能压掉