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:
co-authored by
Claude Opus 5
parent
1684fddd7f
commit
2a92275317
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user