Files
ModelTrainEval/engine/tiling.py
T
Shaun ZhouandClaude Opus 5 2a92275317 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>
2026-09-23 12:27:57 +08:00

148 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""把任意尺寸的图切成固定边长的方片,只用裁剪与镜像填充,不做任何缩放。
产线要求送进模型的图固定为 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,
)