Files
ModelTrainEval/tests/test_tile_dataset.py
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

184 lines
7.2 KiB
Python
Raw Permalink 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.
"""把数据集切成固定边长的方片。"""
import csv
import json
import sys
from pathlib import Path
import pytest
pytest.importorskip("PIL")
import numpy as np # noqa: E402
import yaml # noqa: E402
from PIL import Image # noqa: E402
sys.path.insert(0, "scripts")
import tile_dataset as td # noqa: E402
from engine.detection import parse_label # noqa: E402
from engine.tiling import Tile, to_source # noqa: E402
SIZE = 160
CLASSES = ["blackdot", "grease"]
@pytest.fixture
def dataset(tmp_path):
"""含小图(要填充)、大图(要切片)与真负样本的最小数据集。"""
root = tmp_path / "src"
rng = np.random.default_rng(0)
for split in ("train", "val"):
(root / "images" / split).mkdir(parents=True)
(root / "labels" / split).mkdir(parents=True)
def put(split, name, width, height, lines):
Image.fromarray(rng.integers(0, 255, (height, width, 3), dtype=np.uint8)).save(
root / "images" / split / f"{name}.png"
)
(root / "labels" / split / f"{name}.txt").write_text("".join(lines), encoding="utf-8")
put("train", "blackdot_0001", 128, 128, ["0 0.5 0.5 0.1 0.1\n"]) # 小图
put("train", "grease_0001", 400, 300, ["1 0.25 0.3 0.1 0.1\n"]) # 大图
put("train", "normal_0001", 116, 83, []) # 真负样本
put("val", "blackdot_0002", 320, 160, ["0 0.25 0.5 0.1 0.2\n"])
(root / "data.yaml").write_text(
yaml.safe_dump({"path": str(root), "train": "images/train", "val": "images/val",
"nc": 2, "names": dict(enumerate(CLASSES))}),
encoding="utf-8",
)
return root
def run(dataset, out, *extra):
argv = ["tile_dataset.py", "--data", str(dataset / "data.yaml"), "--out", str(out),
"--size", str(SIZE), *extra]
original, sys.argv = sys.argv, argv
try:
return td.main()
finally:
sys.argv = original
def tiles_of(out):
with (out / "tiles.csv").open(encoding="utf-8-sig", newline="") as handle:
return list(csv.DictReader(handle))
def test_every_tile_is_exactly_the_target_size(dataset, tmp_path):
"""这是整件事的目的:模型输入固定 160x160。"""
out = tmp_path / "out"
assert run(dataset, out) == 0
images = list((out / "images").rglob("*.png"))
assert images
for path in images:
with Image.open(path) as image:
assert image.size == (SIZE, SIZE)
def test_pixels_are_never_resampled(dataset, tmp_path):
"""片上框住的像素必须与原图逐像素相同——有任何缩放都会露馅。"""
out = tmp_path / "out"
assert run(dataset, out) == 0
checked = 0
for row in tiles_of(out):
if int(row["boxes"]) == 0:
continue
tile = Tile(int(row["x"]), int(row["y"]), int(row["crop_width"]),
int(row["crop_height"]), int(row["pad_left"]), int(row["pad_top"]))
patch = np.asarray(Image.open(out / "images" / row["split"] / row["tile"]).convert("RGB"))
source = np.asarray(
Image.open(dataset / "images" / row["split"] / row["source"]).convert("RGB")
)
label = out / "labels" / row["split"] / f"{Path(row['tile']).stem}.txt"
for box in parse_label(label.read_text(encoding="utf-8")):
back = to_source(box, tile, SIZE)
region = patch[
round(box.y1 * SIZE):round(box.y2 * SIZE),
round(box.x1 * SIZE):round(box.x2 * SIZE),
]
original = source[round(back.y1):round(back.y2), round(back.x1):round(back.x2)]
assert np.array_equal(region, original)
checked += 1
assert checked >= 3
def test_small_image_is_mirror_padded_not_stretched(dataset, tmp_path):
out = tmp_path / "out"
assert run(dataset, out) == 0
row = next(r for r in tiles_of(out) if r["source"] == "blackdot_0001.png")
tile = Tile(int(row["x"]), int(row["y"]), int(row["crop_width"]), int(row["crop_height"]),
int(row["pad_left"]), int(row["pad_top"]))
patch = np.asarray(Image.open(out / "images" / row["split"] / row["tile"]).convert("RGB"))
source = np.asarray(Image.open(dataset / "images/train/blackdot_0001.png").convert("RGB"))
inner = patch[tile.pad_top:tile.pad_top + 128, tile.pad_left:tile.pad_left + 128]
assert np.array_equal(inner, source) # 原图原样居中
# 填充是镜像而不是复制边缘:左侧第一个填充列等于原图第 1 列(不是第 0 列)
assert np.array_equal(patch[tile.pad_top, tile.pad_left - 1], source[0, 1])
def test_large_image_is_cut_into_covering_tiles(dataset, tmp_path):
out = tmp_path / "out"
assert run(dataset, out, "--max-empty-ratio", "-1") == 0
rows = [r for r in tiles_of(out) if r["source"] == "grease_0001.png"]
# 400x300 -> x 起点 [0,160,240],y 起点 [0,140]
assert len(rows) == 6
assert {int(r["x"]) for r in rows} == {0, 160, 240}
assert {int(r["y"]) for r in rows} == {0, 140}
assert all(int(r["pad_left"]) == 0 and int(r["pad_top"]) == 0 for r in rows)
def test_true_negative_images_keep_all_their_tiles(dataset, tmp_path):
"""原本就没框的图是真负样本,不受空片配额限制。"""
out = tmp_path / "out"
assert run(dataset, out, "--max-empty-ratio", "0") == 0
assert [r for r in tiles_of(out) if r["source"] == "normal_0001.png"]
def test_empty_tile_quota_limits_background_from_positive_images(dataset, tmp_path):
"""有框图切出的空片会爆量,要能限量。"""
strict = tmp_path / "strict"
assert run(dataset, strict, "--max-empty-ratio", "0") == 0
loose = tmp_path / "loose"
assert run(dataset, loose, "--max-empty-ratio", "-1") == 0
def empties(out):
return [r for r in tiles_of(out) if r["source"] == "grease_0001.png" and r["boxes"] == "0"]
assert empties(strict) == []
assert len(empties(loose)) == 5 # 6 片里 1 片有框
def test_box_split_by_a_cut_line_is_dropped_when_barely_visible(dataset, tmp_path):
"""min_visibility > 0.5 时,一个框最多只会出现在一片里。"""
out = tmp_path / "out"
assert run(dataset, out, "--min-visibility", "0.6") == 0
# val 里的 320x160:框中心在 x=80,完整落在第一片内
rows = [r for r in tiles_of(out) if r["source"] == "blackdot_0002.png"]
assert sum(int(r["boxes"]) for r in rows) == 1
def test_outputs_are_a_valid_yolo_dataset(dataset, tmp_path):
out = tmp_path / "out"
assert run(dataset, out) == 0
meta = yaml.safe_load((out / "data.yaml").read_text(encoding="utf-8"))
assert [meta["names"][i] for i in range(meta["nc"])] == CLASSES
assert (out / "classes.txt").read_text(encoding="utf-8").split() == CLASSES
for row in tiles_of(out):
assert (out / "images" / row["split"] / row["tile"]).exists()
assert (out / "labels" / row["split"] / f"{Path(row['tile']).stem}.txt").exists()
summary = json.loads((out / "summary.json").read_text(encoding="utf-8"))
assert summary["size"] == SIZE
assert summary["counts"]["源图"] == 4
def test_dry_run_writes_nothing(dataset, tmp_path):
out = tmp_path / "out"
assert run(dataset, out, "--dry-run") == 0
assert not out.exists()