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:
co-authored by
Claude Opus 5
parent
2a92275317
commit
474b7b50a0
@@ -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] # 按包含比例能压掉
|
||||
|
||||
@@ -184,3 +184,106 @@ def test_score_survives_both_directions():
|
||||
mapped, = remap_boxes([box], tile, 100, 100, SIZE)
|
||||
assert mapped.score == 0.83
|
||||
assert to_source(mapped, tile, SIZE).score == 0.83
|
||||
|
||||
|
||||
# ---------------- 切片推理的拼回 ----------------
|
||||
|
||||
|
||||
def test_merge_deduplicates_across_the_seam():
|
||||
"""重叠区里同一处缺陷会被两片各检出一次,合并后应只剩一个。"""
|
||||
from engine.tiling import merge_tiles
|
||||
|
||||
width = height = 300
|
||||
tiles = plan_tiles(width, height, SIZE, overlap=64)
|
||||
# 让每片都"检出"同一个真实位置的框(像素 150..170)
|
||||
per_tile = []
|
||||
for tile in tiles:
|
||||
px1, py1, px2, py2 = 150.0, 150.0, 170.0, 170.0
|
||||
bx1 = (px1 - tile.x + tile.pad_left) / SIZE
|
||||
by1 = (py1 - tile.y + tile.pad_top) / SIZE
|
||||
bx2 = (px2 - tile.x + tile.pad_left) / SIZE
|
||||
by2 = (py2 - tile.y + tile.pad_top) / SIZE
|
||||
inside = 0 <= bx1 and bx2 <= 1 and 0 <= by1 and by2 <= 1
|
||||
per_tile.append([Box(0, bx1, by1, bx2, by2, 0.9)] if inside else [])
|
||||
|
||||
assert sum(len(b) for b in per_tile) >= 2 # 确实有多片检出
|
||||
merged = merge_tiles(per_tile, tiles, SIZE, width, height)
|
||||
assert len(merged) == 1
|
||||
assert merged[0].x1 * width == pytest.approx(150, abs=1e-6)
|
||||
assert merged[0].x2 * width == pytest.approx(170, abs=1e-6)
|
||||
|
||||
|
||||
def test_merge_keeps_genuinely_separate_defects():
|
||||
from engine.tiling import merge_tiles
|
||||
|
||||
width = height = 320
|
||||
tiles = plan_tiles(width, height, SIZE, overlap=0)
|
||||
per_tile = [[] for _ in tiles]
|
||||
per_tile[0] = [Box(0, 0.1, 0.1, 0.2, 0.2, 0.9)] # 左上片
|
||||
per_tile[3] = [Box(0, 0.8, 0.8, 0.9, 0.9, 0.8)] # 右下片
|
||||
assert len(merge_tiles(per_tile, tiles, SIZE, width, height)) == 2
|
||||
|
||||
|
||||
def test_merge_clips_boxes_that_reach_into_mirrored_padding():
|
||||
"""小图填充后,模型可能在镜像区出框;那里不是真实区域,要裁掉。"""
|
||||
from engine.tiling import merge_tiles
|
||||
|
||||
width = height = 80
|
||||
tiles = plan_tiles(width, height, SIZE)
|
||||
# 一个横跨整片的框,两端都落在镜像填充里
|
||||
merged = merge_tiles([[Box(0, 0.0, 0.4, 1.0, 0.6, 0.9)]], tiles, SIZE, width, height)
|
||||
|
||||
assert len(merged) == 1
|
||||
assert merged[0].x1 == pytest.approx(0.0)
|
||||
assert merged[0].x2 == pytest.approx(1.0) # 裁到原图边界,不超出
|
||||
assert 0.0 <= merged[0].y1 <= merged[0].y2 <= 1.0
|
||||
|
||||
|
||||
def test_merge_drops_boxes_entirely_inside_the_mirror():
|
||||
from engine.tiling import merge_tiles
|
||||
|
||||
width = height = 80
|
||||
tiles = plan_tiles(width, height, SIZE) # pad 40,原图占 40..120
|
||||
ghost = Box(0, 0.05, 0.05, 0.2, 0.2, 0.9) # 像素 8..32,全在镜像区
|
||||
assert merge_tiles([[ghost]], tiles, SIZE, width, height) == []
|
||||
|
||||
|
||||
def test_tiled_predict_wires_geometry_to_the_model():
|
||||
"""端到端:切片 -> 逐片推理 -> 拼回,位置要落在真实缺陷上。"""
|
||||
from engine.tiling import tiled_predict
|
||||
|
||||
image = np.zeros((300, 400), dtype=np.uint8)
|
||||
image[200:220, 100:130] = 255 # 一块亮斑
|
||||
|
||||
def predict_batch(patches):
|
||||
"""假模型:把片里最亮的连通区域当成检出。"""
|
||||
out = []
|
||||
for patch in patches:
|
||||
ys, xs = np.nonzero(patch == 255)
|
||||
if len(xs) == 0:
|
||||
out.append([])
|
||||
continue
|
||||
out.append([Box(0, xs.min() / SIZE, ys.min() / SIZE,
|
||||
(xs.max() + 1) / SIZE, (ys.max() + 1) / SIZE, 0.9)])
|
||||
return out
|
||||
|
||||
merged = tiled_predict(predict_batch, image, SIZE, overlap=32)
|
||||
assert len(merged) == 1
|
||||
assert merged[0].x1 * 400 == pytest.approx(100, abs=1)
|
||||
assert merged[0].y1 * 300 == pytest.approx(200, abs=1)
|
||||
assert merged[0].x2 * 400 == pytest.approx(130, abs=1)
|
||||
assert merged[0].y2 * 300 == pytest.approx(220, abs=1)
|
||||
|
||||
|
||||
def test_tiled_predict_on_a_small_image_uses_one_tile():
|
||||
from engine.tiling import tiled_predict
|
||||
|
||||
seen = []
|
||||
|
||||
def predict_batch(patches):
|
||||
seen.append(len(patches))
|
||||
assert all(p.shape[:2] == (SIZE, SIZE) for p in patches)
|
||||
return [[] for _ in patches]
|
||||
|
||||
tiled_predict(predict_batch, np.zeros((83, 116), dtype=np.uint8), SIZE)
|
||||
assert seen == [1]
|
||||
|
||||
Reference in New Issue
Block a user