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
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python
"""把 YOLO 数据集切成固定边长的方片,只用裁剪与镜像填充,不做任何缩放。
uv run python scripts/tile_dataset.py \\
--data data/film_yolo/data.yaml --out data/film_yolo_160 --size 160
产线要求送进模型的图固定为 N×N 且禁止 resize。这一步把训练数据转成同样的形态:
- 边长 < N 的图**镜像填充**到 N,原图居中;
- 边长 ≥ N 的图按步长**裁剪**成多片,末片贴边,整图都被覆盖;
- 框跟着平移/裁剪,露出面积低于 `--min-visibility` 的丢弃。
尺寸改了(比如将来 160 → 192)重跑一次即可,训练配置里的 `imgsz` 要同步改。
**空片会爆量**。一张 524×959 的图能切出 24 片,其中大部分没有缺陷。全留下会让负样本
淹没正样本,所以用 `--max-empty-ratio` 限制:空片数最多是有框片数的这个倍数,超出的
按固定种子随机丢弃。原本就没有框的图(normal 这类真负样本)不受这个限制,它们本来
就是要留的。
"""
from __future__ import annotations
import argparse
import csv
import json
import random
import shutil
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np # noqa: E402
import yaml # noqa: E402
from datasets.yolo import IMAGE_SUFFIXES, write_classes_txt, write_data_yaml # noqa: E402
from engine.detection import parse_label # noqa: E402
from engine.tiling import cut, plan_tiles, remap_boxes # noqa: E402
from utils import setup_logging # noqa: E402
logger = None
def to_yolo_lines(boxes) -> str:
"""归一化 xyxy -> YOLO 的 `class cx cy w h`。"""
lines = []
for box in boxes:
cx, cy = (box.x1 + box.x2) / 2, (box.y1 + box.y2) / 2
width, height = box.x2 - box.x1, box.y2 - box.y1
lines.append(f"{box.class_id} {cx:.6f} {cy:.6f} {width:.6f} {height:.6f}")
return "".join(f"{line}\n" for line in lines)
def label_path_for(image: Path) -> Path:
"""YOLO 约定:路径里的 images/ 换成 labels/,后缀换成 .txt。"""
parts = list(image.parts)
for index in range(len(parts) - 1, -1, -1):
if parts[index] == "images":
parts[index] = "labels"
break
return Path(*parts).with_suffix(".txt")
def main() -> int:
global logger
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--data", required=True, help="源数据集的 data.yaml")
parser.add_argument("--out", required=True, help="输出目录")
parser.add_argument("--size", type=int, default=160, help="片的边长,默认 160")
parser.add_argument(
"--overlap", type=int, default=0,
help="相邻片的重叠像素。大于 0 可减少缺陷被切断的概率,代价是片数变多",
)
parser.add_argument(
"--min-visibility", type=float, default=0.3,
help="框落在片内的面积占比下限,低于它就丢弃这个框",
)
parser.add_argument(
"--max-empty-ratio", type=float, default=1.0,
help="有框图切出的空片,最多保留到有框片数的这个倍数;<0 表示全留",
)
parser.add_argument("--seed", type=int, default=42, help="丢弃空片时的随机种子")
parser.add_argument("--overwrite", action="store_true")
parser.add_argument("--dry-run", action="store_true", help="只统计,不写文件")
args = parser.parse_args()
logger = setup_logging()
data_yaml = Path(args.data)
meta = yaml.safe_load(data_yaml.read_text(encoding="utf-8"))
names = meta["names"]
classes = [names[i] for i in sorted(names)] if isinstance(names, dict) else list(names)
root = Path(meta.get("path", data_yaml.parent))
if not root.is_absolute():
root = (data_yaml.parent / root).resolve()
out_dir = Path(args.out)
from PIL import Image
splits = {
split: sorted(
p for p in (root / meta[split]).iterdir() if p.suffix.lower() in IMAGE_SUFFIXES
)
for split in ("train", "val")
if meta.get(split) and (root / meta[split]).is_dir()
}
if not splits:
logger.error("%s 里没有可用的 train / val", data_yaml)
return 1
logger.info(
"源数据集 %s:%s;目标边长 %d,重叠 %d",
data_yaml, ",".join(f"{k} {len(v)} 张" for k, v in splits.items()), args.size, args.overlap,
)
if not args.dry_run:
if out_dir.exists() and any(out_dir.iterdir()):
if not args.overwrite:
logger.error("%s 已存在且非空;确认要覆盖请加 --overwrite", out_dir)
return 1
shutil.rmtree(out_dir)
rng = random.Random(args.seed)
rows = []
stats: Counter[str] = Counter()
per_class: Counter[str] = Counter()
sizes_seen: Counter[str] = Counter()
for split, images in splits.items():
for source in images:
with Image.open(source) as handle:
image = handle.convert("L") if handle.mode == "L" else handle.convert("RGB")
width, height = image.size
array = np.asarray(image)
label_file = label_path_for(source)
boxes = parse_label(label_file.read_text(encoding="utf-8")) if label_file.exists() else []
tiles = plan_tiles(width, height, args.size, args.overlap)
sizes_seen["填充" if width < args.size or height < args.size else "裁剪"] += 1
stats["源图"] += 1
positives, empties = [], []
for index, tile in enumerate(tiles):
mapped = remap_boxes(boxes, tile, width, height, args.size, args.min_visibility)
(positives if mapped else empties).append((index, tile, mapped))
# 原本就没有框的图是真负样本,整张都要留;有框的图切出的空片才限量
if boxes and args.max_empty_ratio >= 0:
allowed = int(len(positives) * args.max_empty_ratio)
if len(empties) > allowed:
stats["丢弃的空片"] += len(empties) - allowed
empties = rng.sample(empties, allowed)
for index, tile, mapped in sorted(positives + empties):
name = f"{source.stem}_t{index:02d}"
stats["产出片"] += 1
stats["有框片" if mapped else "空片"] += 1
for box in mapped:
per_class[classes[box.class_id]] += 1
rows.append({
"split": split, "tile": f"{name}.png", "source": source.name,
"x": tile.x, "y": tile.y,
"pad_left": tile.pad_left, "pad_top": tile.pad_top,
"crop_width": tile.crop_width, "crop_height": tile.crop_height,
"boxes": len(mapped),
})
if args.dry_run:
continue
patch = cut(array, tile, args.size)
image_path = out_dir / "images" / split / f"{name}.png"
image_path.parent.mkdir(parents=True, exist_ok=True)
Image.fromarray(patch).save(image_path)
label_out = out_dir / "labels" / split / f"{name}.txt"
label_out.parent.mkdir(parents=True, exist_ok=True)
label_out.write_text(to_yolo_lines(mapped), encoding="utf-8")
if stats["源图"] % 2000 == 0:
logger.info("处理 [%d] 已产出 %d 片", stats["源图"], stats["产出片"])
logger.info(
"源图 %d(其中 %d 张靠镜像填充、%d 张需裁剪)-> 片 %d(有框 %d,空 %d,丢弃空片 %d)",
stats["源图"], sizes_seen["填充"], sizes_seen["裁剪"], stats["产出片"],
stats["有框片"], stats["空片"], stats["丢弃的空片"],
)
logger.info("逐类框数: %s", dict(sorted(per_class.items())))
if args.dry_run:
logger.info("--dry-run:未写入任何文件")
return 0
write_data_yaml(out_dir / "data.yaml", classes, root=out_dir.resolve(),
train="images/train", val="images/val")
write_classes_txt(out_dir / "classes.txt", classes)
with (out_dir / "tiles.csv").open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
(out_dir / "summary.json").write_text(
json.dumps(
{
"source": str(data_yaml.resolve()),
"size": args.size, "overlap": args.overlap,
"min_visibility": args.min_visibility,
"max_empty_ratio": args.max_empty_ratio, "seed": args.seed,
"counts": dict(stats), "per_class_boxes": dict(per_class),
"source_images_padded": sizes_seen["填充"],
"source_images_cropped": sizes_seen["裁剪"],
},
ensure_ascii=False, indent=2,
),
encoding="utf-8",
)
logger.info("完成 -> %s(tiles.csv 记录每片对应的原图与偏移)", out_dir)
logger.info("训练时把 configs/detect 里的 imgsz 改成 %d,并确认 scale/translate 为 0", args.size)
return 0
if __name__ == "__main__":
raise SystemExit(main())