产线要求送进模型的图固定为 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>
148 lines
5.6 KiB
Python
148 lines
5.6 KiB
Python
"""把任意尺寸的图切成固定边长的方片,只用裁剪与镜像填充,不做任何缩放。
|
||
|
||
产线要求送进模型的图固定为 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,
|
||
)
|