空标签在 YOLO 里的语义是"这张图什么都没有",是一个明确的负样本断言,而不是 "有缺陷但没能定位出来"。原先一律保留,于是 runs/boxes_gradcam 的 339 张空标签 里有 270 张是人工标成缺陷、Grad-CAM 却失手的图(blackdot 占该类 11%、black 占 24%),它们正以"此图无缺陷"的身份进入训练,等于在教模型抑制真实缺陷。 --empty-policy 接一个全局默认加若干 `类别=策略`: keep 当负样本保留(默认)/ drop 丢弃 / full 退化成整图框 整图框需要重写标签而不是链接源文件(内容不同),且有了确定类别之后要跟着本类 一起参与分层划分。类别名或策略拼错直接报错——静默忽略会让策略看着生效其实没有。 没显式指定、又落到 keep 的类别会打一条告警。 替换掉原来的 --drop-empty:它是一刀切,会把 normal 那 69 张有价值的负样本一起扔掉。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
357 lines
14 KiB
Python
357 lines
14 KiB
Python
#!/usr/bin/env python
|
||
"""把 generate_boxes.py 的产出整理成 YOLO 训练所需的数据集,放到 data/ 下。
|
||
|
||
uv run python scripts/prepare_yolo_dataset.py --source runs/boxes_gradcam
|
||
|
||
generate_boxes.py 的产出是**按类别**分目录的(便于人工核对):
|
||
|
||
```
|
||
runs/boxes_gradcam/
|
||
├── images/<类别>/<图名>.png
|
||
└── labels/<类别>/<图名>.txt
|
||
```
|
||
|
||
而 YOLO 检测训练要的是 **train/val 划分**——类别信息在标签文件的第一列里,
|
||
不靠目录名。这一步做三件事:
|
||
|
||
1. 把按类别分的目录拍平,按 train/val 重新组织;
|
||
2. **按类别分层**切分,小类不至于在验证集里挂零;
|
||
3. 全部用**硬链接**,不占额外磁盘。
|
||
|
||
```
|
||
data/<name>/
|
||
├── images/{train,val}/<图名>.png
|
||
├── labels/{train,val}/<图名>.txt
|
||
└── data.yaml
|
||
```
|
||
|
||
没有框的图(标签为空)默认保留:YOLO 训练里它们是负样本,有助于压低误检。但**空标签的
|
||
语义是"这张图什么都没有"**,不是"有缺陷但没定位出来"。来自缺陷类别的空标签如果照搬,
|
||
等于在教模型抑制真实缺陷,所以可以用 `--empty-policy` 按来源类别分别处置:
|
||
|
||
--empty-policy drop normal=keep black=full
|
||
# 缺陷类没定位出来的直接丢弃;normal 留作负样本;整图属性的类退化成整图框
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import errno
|
||
import json
|
||
import os
|
||
import random
|
||
import shutil
|
||
import sys
|
||
from collections import Counter, defaultdict
|
||
from collections.abc import Sequence
|
||
from pathlib import Path
|
||
from typing import NamedTuple
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
||
import yaml # noqa: E402
|
||
|
||
from datasets.yolo import IMAGE_SUFFIXES, write_data_yaml # noqa: E402
|
||
from utils import setup_logging # noqa: E402
|
||
|
||
logger = None
|
||
|
||
EMPTY_POLICIES = ("keep", "drop", "full")
|
||
EMPTY_POLICY_DESC = {
|
||
"keep": "保留为负样本",
|
||
"drop": "丢弃",
|
||
"full": "退化成整图框",
|
||
}
|
||
|
||
|
||
def link_or_copy(source: Path, destination: Path) -> str:
|
||
"""硬链接;跨文件系统时退回复制。"""
|
||
if destination.exists() or destination.is_symlink():
|
||
destination.unlink()
|
||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
os.link(source, destination)
|
||
return "hard"
|
||
except OSError as exc:
|
||
if exc.errno != errno.EXDEV:
|
||
raise
|
||
shutil.copy2(source, destination)
|
||
return "copy"
|
||
|
||
|
||
class Sample(NamedTuple):
|
||
"""一张图及其标签。
|
||
|
||
Attributes:
|
||
image: 图片路径。
|
||
label: 标签路径。
|
||
class_id: 标签里第一个框的类别下标;无框时为 None。
|
||
group: 来源的类别目录名——也就是人工归类给这张图的类别。
|
||
标签为空时,这是唯一还能知道"这张图本该是什么"的线索。
|
||
full_box: 整图框的类别下标。非 None 时 _emit 会**重写**标签而不是链接原文件。
|
||
"""
|
||
|
||
image: Path
|
||
label: Path
|
||
class_id: int | None
|
||
group: str
|
||
full_box: int | None = None
|
||
|
||
|
||
def first_class_id(label_path: Path) -> int | None:
|
||
"""取标签里第一个框的类别下标;空标签(无框)返回 None。"""
|
||
for line in label_path.read_text(encoding="utf-8").splitlines():
|
||
line = line.strip()
|
||
if line:
|
||
return int(float(line.split()[0]))
|
||
return None
|
||
|
||
|
||
def collect(source: Path) -> list[Sample]:
|
||
"""配对 images/ 与 labels/。"""
|
||
images_root, labels_root = source / "images", source / "labels"
|
||
if not images_root.is_dir() or not labels_root.is_dir():
|
||
raise FileNotFoundError(f"{source} 下缺少 images/ 或 labels/,先跑 scripts/generate_boxes.py")
|
||
|
||
samples = []
|
||
missing = []
|
||
for image in sorted(p for p in images_root.rglob("*") if p.suffix.lower() in IMAGE_SUFFIXES):
|
||
relative = image.relative_to(images_root)
|
||
label = labels_root / relative.with_suffix(".txt")
|
||
if not label.exists():
|
||
missing.append(image)
|
||
continue
|
||
group = relative.parent.name # 顶层就是图片时为 ""
|
||
samples.append(Sample(image, label, first_class_id(label), group))
|
||
if missing:
|
||
raise FileNotFoundError(
|
||
f"有 {len(missing)} 张图没有对应的标签,例如 {missing[0]};"
|
||
"产出目录可能不完整,重新跑一次 generate_boxes.py"
|
||
)
|
||
if not samples:
|
||
raise ValueError(f"{source} 下没有找到任何图片")
|
||
return samples
|
||
|
||
|
||
def parse_empty_policy(tokens: Sequence[str]) -> tuple[str, dict[str, str]]:
|
||
"""解析 --empty-policy:一个可选的全局默认,加若干 `类别=策略`。
|
||
|
||
--empty-policy drop normal=keep black=full
|
||
-> ("drop", {"normal": "keep", "black": "full"})
|
||
"""
|
||
default, per_class = "keep", {}
|
||
for token in tokens:
|
||
if "=" in token:
|
||
name, _, policy = token.partition("=")
|
||
if policy not in EMPTY_POLICIES:
|
||
raise ValueError(f"未知策略 {policy!r}(类别 {name});可选 {EMPTY_POLICIES}")
|
||
per_class[name] = policy
|
||
elif token in EMPTY_POLICIES:
|
||
default = token
|
||
else:
|
||
raise ValueError(
|
||
f"看不懂 --empty-policy 的 {token!r};"
|
||
f"要么是全局默认({'/'.join(EMPTY_POLICIES)}),要么是 `类别=策略`"
|
||
)
|
||
return default, per_class
|
||
|
||
|
||
def apply_empty_policy(
|
||
samples: Sequence[Sample], default: str, per_class: dict[str, str], names: Sequence[str]
|
||
) -> tuple[list[Sample], dict[str, tuple[str, int]]]:
|
||
"""按来源类别处置空标签,返回 (保留下来的样本, {类别: (策略, 数量)})。
|
||
|
||
空标签在 YOLO 里的语义是"这张图什么都没有"——一个明确的负样本断言,而不是
|
||
"有缺陷但没定位出来"。所以来自缺陷类别的空标签如果照搬,等于在教模型抑制真实缺陷。
|
||
"""
|
||
index = {name: i for i, name in enumerate(names)}
|
||
unknown = {
|
||
name for name in per_class if name not in index
|
||
}
|
||
if unknown:
|
||
raise ValueError(
|
||
f"--empty-policy 里的类别 {sorted(unknown)} 不在 data.yaml 中;可选 {list(names)}"
|
||
)
|
||
|
||
kept: list[Sample] = []
|
||
stats: dict[str, tuple[str, int]] = {}
|
||
for sample in samples:
|
||
if sample.class_id is not None:
|
||
kept.append(sample)
|
||
continue
|
||
policy = per_class.get(sample.group, default)
|
||
counted = stats.get(sample.group, (policy, 0))
|
||
stats[sample.group] = (policy, counted[1] + 1)
|
||
if policy == "drop":
|
||
continue
|
||
if policy == "full":
|
||
if sample.group not in index:
|
||
raise ValueError(
|
||
f"{sample.group!r} 要用整图框,但它不在 data.yaml 的类别里;可选 {list(names)}"
|
||
)
|
||
kept.append(sample._replace(full_box=index[sample.group]))
|
||
else:
|
||
kept.append(sample)
|
||
return kept, stats
|
||
|
||
|
||
def main() -> int:
|
||
global logger
|
||
parser = argparse.ArgumentParser(
|
||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||
)
|
||
parser.add_argument("--source", required=True, help="generate_boxes.py 的产出目录")
|
||
parser.add_argument("--out", default=None, help="输出目录;缺省 data/<source 目录名>")
|
||
parser.add_argument("--data-root", default="data", help="自动推导时的根目录,默认 data")
|
||
parser.add_argument("--val-ratio", type=float, default=0.2)
|
||
parser.add_argument("--seed", type=int, default=42)
|
||
parser.add_argument(
|
||
"--empty-policy", nargs="*", default=[], metavar="POLICY|类别=POLICY",
|
||
help=(
|
||
"怎么处置没有框的图(标签为空)。可写一个全局默认,再按类别覆盖,如 "
|
||
"`--empty-policy drop normal=keep black=full`。"
|
||
"keep=当负样本保留(默认)、drop=丢弃、full=退化成整图框。"
|
||
"注意空标签在 YOLO 里的意思是『此图无缺陷』,缺陷类的空标签照搬会教模型抑制真实缺陷"
|
||
),
|
||
)
|
||
parser.add_argument("--overwrite", action="store_true", help="输出目录已存在时先清空")
|
||
parser.add_argument("--dry-run", action="store_true", help="只打印计划")
|
||
args = parser.parse_args()
|
||
|
||
source = Path(args.source)
|
||
out_dir = Path(args.out) if args.out else Path(args.data_root) / source.name
|
||
logger = setup_logging()
|
||
|
||
try:
|
||
samples = collect(source)
|
||
except (FileNotFoundError, ValueError) as exc:
|
||
logger.error("%s", exc)
|
||
return 1
|
||
|
||
meta_path = source / "data.yaml"
|
||
if not meta_path.exists():
|
||
logger.error("找不到 %s", meta_path)
|
||
return 1
|
||
meta = yaml.safe_load(meta_path.read_text(encoding="utf-8"))
|
||
names = [meta["names"][i] for i in sorted(meta["names"])]
|
||
|
||
logger.info("来源: %s(%d 张,%d 类)", source, len(samples), len(names))
|
||
try:
|
||
default_policy, per_class_policy = parse_empty_policy(args.empty_policy)
|
||
samples, empty_stats = apply_empty_policy(samples, default_policy, per_class_policy, names)
|
||
except ValueError as exc:
|
||
logger.error("%s", exc)
|
||
return 1
|
||
|
||
total_empty = sum(count for _, count in empty_stats.values())
|
||
if total_empty:
|
||
logger.info("无框图片 %d 张,按来源类别处置:", total_empty)
|
||
for group, (policy, count) in sorted(empty_stats.items(), key=lambda kv: -kv[1][1]):
|
||
logger.info(" %-14s %5d 张 -> %s", group or "(顶层)", count, EMPTY_POLICY_DESC[policy])
|
||
# 只对"默认落到 keep"的类别告警——显式写了 `类别=keep` 的是用户的判断,不再唠叨
|
||
as_negative = sorted(
|
||
g for g, (policy, _) in empty_stats.items()
|
||
if policy == "keep" and g not in per_class_policy
|
||
)
|
||
if as_negative:
|
||
logger.warning(
|
||
"以上 %s 的空标签会以『此图无缺陷』的身份进入训练。"
|
||
"如果其中某些类其实是有缺陷、只是没能定位出来,它们会教模型抑制真实缺陷——"
|
||
"用 --empty-policy 逐类改成 drop 或 full。",
|
||
"/".join(as_negative),
|
||
)
|
||
|
||
# 按类别分层切分;无框的图单独作为一组分配,避免它们全落进同一侧
|
||
grouped: dict[int | None, list[Sample]] = defaultdict(list)
|
||
for sample in samples:
|
||
# 退化成整图框的那些已经有了确定类别,跟着本类一起分层
|
||
grouped[sample.full_box if sample.class_id is None else sample.class_id].append(sample)
|
||
|
||
rng = random.Random(args.seed)
|
||
splits: dict[str, list] = {"train": [], "val": []}
|
||
for _class_id, group in sorted(grouped.items(), key=lambda kv: (kv[0] is None, kv[0])):
|
||
order = group[:]
|
||
rng.shuffle(order)
|
||
n_val = max(1, round(len(order) * args.val_ratio)) if len(order) > 1 else 0
|
||
splits["val"].extend(order[:n_val])
|
||
splits["train"].extend(order[n_val:])
|
||
|
||
counts = {
|
||
split: Counter(
|
||
names[s.class_id] if s.class_id is not None
|
||
else names[s.full_box] if s.full_box is not None
|
||
else "(无框)"
|
||
for s in items
|
||
)
|
||
for split, items in splits.items()
|
||
}
|
||
logger.info("划分: train %d / val %d", len(splits["train"]), len(splits["val"]))
|
||
logger.info("%-14s %8s %8s", "类别", "train", "val")
|
||
for name in names + ["(无框)"]:
|
||
if counts["train"][name] or counts["val"][name]:
|
||
logger.info("%-14s %8d %8d", name, counts["train"][name], counts["val"][name])
|
||
|
||
if args.dry_run:
|
||
logger.info("--dry-run:未写入任何文件")
|
||
return 0
|
||
|
||
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)
|
||
|
||
modes: Counter[str] = Counter()
|
||
seen: dict[str, Path] = {}
|
||
for split, items in splits.items():
|
||
for sample in items:
|
||
image = sample.image
|
||
# 拍平之后不同类别目录下的同名文件会撞车,显式挡住
|
||
if image.name in seen:
|
||
logger.error(
|
||
"拍平后文件名冲突:%s 与 %s 同名。源数据集的文件名需要全局唯一,"
|
||
"用 scripts/export_clusters.py --prefix-parent 重新导出即可",
|
||
seen[image.name], image,
|
||
)
|
||
return 1
|
||
seen[image.name] = image
|
||
modes[link_or_copy(image, out_dir / "images" / split / image.name)] += 1
|
||
destination = out_dir / "labels" / split / f"{image.stem}.txt"
|
||
if sample.full_box is None:
|
||
link_or_copy(sample.label, destination)
|
||
else:
|
||
# 整图框:内容与源标签不同,只能新写一份,不能链接
|
||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||
destination.write_text(f"{sample.full_box} 0.5 0.5 1.0 1.0\n", encoding="utf-8")
|
||
|
||
write_data_yaml(out_dir / "data.yaml", names, root=out_dir.resolve(),
|
||
train="images/train", val="images/val")
|
||
(out_dir / "summary.json").write_text(
|
||
json.dumps(
|
||
{
|
||
"source": str(source.resolve()),
|
||
"num_images": len(samples),
|
||
"empty_labels": {
|
||
group: {"policy": policy, "count": count}
|
||
for group, (policy, count) in sorted(empty_stats.items())
|
||
},
|
||
"empty_policy": {"default": default_policy, **per_class_policy},
|
||
"val_ratio": args.val_ratio,
|
||
"seed": args.seed,
|
||
"split_sizes": {k: len(v) for k, v in splits.items()},
|
||
"per_class": {k: dict(v) for k, v in counts.items()},
|
||
"link_modes": dict(modes),
|
||
},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
logger.info("链接方式: %s", dict(modes))
|
||
logger.info("完成 -> %s", out_dir)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|