feat(detect): 固定 160x160 输入,只用裁剪与镜像填充,禁止缩放

产线要求送进模型的图固定为 N×N 且不许 resize——缺陷的绝对尺度是判别依据,
拉伸会把"5 像素的黑点"变成别的东西。

- engine/tiling.py:切片几何。边长 < N 镜像填充(reflect,真镜像,原图居中);
  边长 >= N 按步长裁剪、末片贴边保证全覆盖。框跟着平移裁剪,露出面积低于
  min_visibility 的丢弃;to_source 反向映射供推理时拼回原图。
- scripts/tile_dataset.py:整套数据集转换。有框图切出的空片会爆量
  (524x959 能切 24 片),用 --max-empty-ratio 限量;原本无框的真负样本不受限。
- 训练配置 imgsz 改 160,并显式关掉 scale 与 translate——ultralytics 的默认值是
  0.5 / 0.1 而不是 0,不写出来等于一直在做仿射缩放,违反约束。

尺寸正好等于 imgsz 时 letterbox 退化为空操作(r=1、dw=dh=0),核对过源码。
合成数据上验证:所有片正好 160x160、框区域与原图逐像素一致、填充是真镜像。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-23 12:27:57 +08:00
co-authored by Claude Opus 5
parent 1684fddd7f
commit 2a92275317
5 changed files with 753 additions and 2 deletions
+147
View File
@@ -0,0 +1,147 @@
"""把任意尺寸的图切成固定边长的方片,只用裁剪与镜像填充,不做任何缩放。
产线要求送进模型的图固定为 N×N(当前 160),且**禁止 resize 这类拉伸**——缺陷的绝对尺度
是判别依据之一,拉伸会把"一个 5 像素的黑点"变成别的东西。所以每个维度只有两种处理:
- 边长 < N:**镜像填充**到 N(`reflect`,真镜像,不重复边缘像素),原图居中;
- 边长 ≥ N:按步长**裁剪**成若干片,最后一片贴右/下边缘,保证整图被覆盖。
两种操作都不改变像素的尺度与形状,框只是平移与裁剪。
切片后坐标系变了,框要跟着重映射:与片相交的部分保留,露出太少的丢弃——半个缺陷
既不像缺陷也不像背景,留着只会教坏模型。
推理时用同一套 `plan_tiles` 切图,把每片的框按 `tile.x/y/pad` 反向平移回原图即可拼回。
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import NamedTuple
import numpy as np
from engine.detection import Box
class Tile(NamedTuple):
"""一个片的取法。
先从原图取 `source[y : y + crop_height, x : x + crop_width]`,
再镜像填充成 `size × size`,原图内容落在 `(pad_left, pad_top)` 处。
维度足够大时 `pad = 0`(纯裁剪);维度不足时 `crop` 就是整条边(纯填充)。
"""
x: int
y: int
crop_width: int
crop_height: int
pad_left: int
pad_top: int
def axis_starts(length: int, size: int, overlap: int) -> list[int]:
"""一个维度上的裁剪起点。
步长 = size - overlap;最后一片强制贴边(起点 = length - size),
这样末尾那条不足一步的区域不会被漏掉,代价是它与前一片重叠更多。
"""
if length <= size:
return [0]
stride = max(1, size - overlap)
starts = list(range(0, length - size + 1, stride))
if starts[-1] != length - size:
starts.append(length - size)
return starts
def plan_tiles(width: int, height: int, size: int, overlap: int = 0) -> list[Tile]:
"""把 `width × height` 切成若干 `size × size` 的片。"""
if size <= 0:
raise ValueError(f"size 必须为正,得到 {size}")
if overlap < 0 or overlap >= size:
raise ValueError(f"overlap 需在 [0, size) 内,得到 {overlap}")
crop_width, crop_height = min(width, size), min(height, size)
# 不足一片时居中放置:缺陷贴边时两侧都有镜像出来的上下文,比靠角落更自然
pad_left = (size - crop_width) // 2
pad_top = (size - crop_height) // 2
return [
Tile(x, y, crop_width, crop_height, pad_left, pad_top)
for y in axis_starts(height, size, overlap)
for x in axis_starts(width, size, overlap)
]
def cut(image: np.ndarray, tile: Tile, size: int) -> np.ndarray:
"""按 `tile` 取出一片并镜像填充到 `size × size`。
`image` 是 HxW 或 HxWxC。填充用 `reflect`:填充量超过边长时 numpy 会反复折返,
小图(最小的只有 83 像素高)也能填到 160。
"""
patch = image[tile.y:tile.y + tile.crop_height, tile.x:tile.x + tile.crop_width]
pad_bottom = size - tile.crop_height - tile.pad_top
pad_right = size - tile.crop_width - tile.pad_left
if not (tile.pad_top or tile.pad_left or pad_bottom or pad_right):
return patch
widths = [(tile.pad_top, pad_bottom), (tile.pad_left, pad_right)]
widths += [(0, 0)] * (image.ndim - 2)
return np.pad(patch, widths, mode="reflect")
def remap_boxes(
boxes: Sequence[Box],
tile: Tile,
width: int,
height: int,
size: int,
min_visibility: float = 0.3,
) -> list[Box]:
"""把原图上的归一化框映射到某一片上,返回该片内的归一化框。
Args:
boxes: 相对原图归一化的 xyxy 框。
width, height: 原图尺寸,用于还原成像素。
min_visibility: 框落在片内的面积占比下限。低于它就丢弃——只露出一角的缺陷
既不像缺陷也不像背景。
"""
kept = []
for box in boxes:
x1, y1 = box.x1 * width, box.y1 * height
x2, y2 = box.x2 * width, box.y2 * height
area = max(x2 - x1, 0.0) * max(y2 - y1, 0.0)
if area <= 0:
continue
# 平移到片的坐标系:先减去裁剪原点,再加上填充偏移
tx1 = x1 - tile.x + tile.pad_left
ty1 = y1 - tile.y + tile.pad_top
tx2 = x2 - tile.x + tile.pad_left
ty2 = y2 - tile.y + tile.pad_top
# 只保留落在片内的部分。镜像出来的区域里也有缺陷的倒影,但那是复制品,
# 不该再标一个框——标了等于告诉模型"这里真有一个缺陷"
cx1, cy1 = max(tx1, 0.0), max(ty1, 0.0)
cx2 = min(tx2, float(tile.pad_left + tile.crop_width))
cy2 = min(ty2, float(tile.pad_top + tile.crop_height))
clipped = max(cx2 - cx1, 0.0) * max(cy2 - cy1, 0.0)
if clipped <= 0 or clipped / area < min_visibility:
continue
kept.append(Box(box.class_id, cx1 / size, cy1 / size, cx2 / size, cy2 / size, box.score))
return kept
def to_source(box: Box, tile: Tile, size: int) -> Box:
"""反向:把片上的归一化框映射回原图的**像素** xyxy。
推理时每片各自出框,靠这个拼回原图坐标再做一次 NMS。
"""
return Box(
box.class_id,
box.x1 * size - tile.pad_left + tile.x,
box.y1 * size - tile.pad_top + tile.y,
box.x2 * size - tile.pad_left + tile.x,
box.y2 * size - tile.pad_top + tile.y,
box.score,
)