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,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())
|
||||
Reference in New Issue
Block a user