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
+59
View File
@@ -132,6 +132,65 @@ def remap_boxes(
return kept
def merge_tiles(
per_tile: Sequence[Sequence[Box]],
tiles: Sequence[Tile],
size: int,
width: int,
height: int,
iou_threshold: float = 0.5,
contained_threshold: float = 0.8,
) -> list[Box]:
"""把每片各自的框拼回整图,返回相对原图归一化的框。
三步:映射回原图像素 → 裁掉落在镜像填充区里的部分(那里是复制品,不是真实区域)
→ 跨片去重。
去重同时看 IoU 与包含比例:缺陷被切割线切开时,相邻片只看到一角,给出的小残框
与完整框的 IoU 很小(面积悬殊),但几乎整个落在完整框里。只看 IoU 会把残框留下来。
残框与完整框互不包含的情况(缺陷正好被切成两半、两片各见一半)只能靠 `overlap`
避免——重叠大于缺陷尺寸时,总有一片能完整看到它。
"""
from engine.detection import nms
collected = []
for tile, boxes in zip(tiles, per_tile, strict=True):
for box in boxes:
mapped = to_source(box, tile, size)
x1, y1 = max(mapped.x1, 0.0), max(mapped.y1, 0.0)
x2, y2 = min(mapped.x2, float(width)), min(mapped.y2, float(height))
if x2 <= x1 or y2 <= y1:
continue
collected.append(
Box(box.class_id, x1 / width, y1 / height, x2 / width, y2 / height, box.score)
)
return [
collected[i]
for i in nms(collected, iou_threshold, contained_threshold=contained_threshold)
]
def tiled_predict(
predict_batch,
image: np.ndarray,
size: int,
overlap: int = 0,
iou_threshold: float = 0.5,
) -> list[Box]:
"""按固定边长切片推理再拼回,返回整图上的归一化框。
Args:
predict_batch: 接收一批 `size × size` 的图、返回每张图的归一化框列表。
推理框架留在调用方,这里只管几何,便于单测。
image: HxW 或 HxWxC 的原图。
"""
height, width = image.shape[:2]
tiles = plan_tiles(width, height, size, overlap)
patches = [cut(image, tile, size) for tile in tiles]
return merge_tiles(predict_batch(patches), tiles, size, width, height, iou_threshold)
def to_source(box: Box, tile: Tile, size: int) -> Box:
"""反向:把片上的归一化框映射回原图的**像素** xyxy。