Files
ModelTrainEval/scripts/build_yolo_dataset.py
Shaun ZhouandClaude Opus 5 59d4b8a9c4 fix(detect): 钳掉 Label Studio 导出坐标的浮点溢出
真实导出里出现 height=1.0000000000000002:Label Studio 按百分比存框,导出时 /100
留下浮点误差。ultralytics 读标签时对 >1 的坐标直接报错,所以这点溢出必须钳掉,
否则装配出的数据集根本训不起来。

只钳 1e-6 量级的溢出,真正越界的坐标仍然报错。顺带把坐标统一写成 6 位小数。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:44:42 +08:00

319 lines
13 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.
#!/usr/bin/env python
"""把 Label Studio 的 YOLO 导出装配成可训练的数据集,划分 train/val 放到 data/ 下。
uv run python scripts/build_yolo_dataset.py \\
--export data/film_yolo_shap.zip \\
--images runs/boxes_shap_label_studio/images \\
--out data/film_yolo
Label Studio 的 YOLO 导出**只有标签**:本地存储的图片它不会打包进去(`get_local_path` 对
local-files 直接返回磁盘路径,不复制,`YOLO_WITH_IMAGES` 也一样)。所以图片从我们自己准备
复核材料的那个目录取——文件名是一一对应的。
```
<export>.zip runs/<...>_label_studio/images/
├── classes.txt └── <类别>_<序号>.png
├── labels/<类别>_<序号>.txt
└── images/ ← 空的
```
装配结果:
```
data/<name>/
├── images/{train,val}/<类别>_<序号>.png # 硬链接,不占额外磁盘
├── labels/{train,val}/<类别>_<序号>.txt
├── classes.txt # 原样沿用导出的顺序
├── data.yaml
└── summary.json
```
**类别顺序以导出的 classes.txt 为准。** Label Studio 的转换器把标签收进 set 再排序编号,
与 labeling config 里写的顺序无关,也与我们 `generate_boxes.py` 时的顺序无关。标签里的
class_id 是按导出的顺序写的,所以 data.yaml 必须照抄,不能另排。
**空标签保留为负样本。** 导出后仍为空的图(本例是 normal,预测已在 Label Studio 里批量删掉)
表示"这张图没有缺陷",在检测训练里是压低误检的负样本。`--drop-empty` 可以丢掉它们。
注意:没复核、也没把预测转成标注的任务同样会导成空标签,那种"空"是信息缺失而不是负样本,
装配前应先在 Label Studio 里用 Create Annotations From Predictions 补上。
"""
from __future__ import annotations
import argparse
import json
import random
import shutil
import sys
import zipfile
from collections import Counter, defaultdict
from pathlib import Path
from typing import NamedTuple
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from datasets.yolo import IMAGE_SUFFIXES, write_classes_txt, write_data_yaml # noqa: E402
from utils import link_or_copy, setup_logging # noqa: E402
logger = None
EMPTY = "(无框)"
#: 坐标的容忍量。Label Studio 按百分比存框,导出时 /100 会有浮点误差,
#: 实测出现过 height=1.0000000000000002。ultralytics 对 >1 的坐标直接报错,
#: 所以这点溢出必须钳掉;超出这个量级的才是真的坏标签。
COORDINATE_TOLERANCE = 1e-6
class Sample(NamedTuple):
"""一张图、它的标签文本,以及分层划分用的类别。"""
image: Path
label_text: str
stratum: str
class ExportError(RuntimeError):
"""导出内容不自洽,装配无法继续。"""
def read_export(path: Path) -> tuple[list[str], dict[str, str]]:
"""读 Label Studio 的 YOLO 导出(zip 或已解压目录)。
Returns:
(类别名列表, {文件名主干: 标签文本})
"""
if path.is_dir():
classes_file = path / "classes.txt"
if not classes_file.exists():
raise ExportError(f"{path} 下没有 classes.txt,这不像 Label Studio 的 YOLO 导出")
classes = classes_file.read_text(encoding="utf-8").split()
labels = {
p.stem: p.read_text(encoding="utf-8")
for p in sorted((path / "labels").glob("*.txt"))
}
else:
with zipfile.ZipFile(path) as archive:
names = archive.namelist()
if "classes.txt" not in names:
raise ExportError(f"{path} 里没有 classes.txt,这不像 Label Studio 的 YOLO 导出")
classes = archive.read("classes.txt").decode("utf-8").split()
labels = {
Path(name).stem: archive.read(name).decode("utf-8")
for name in sorted(names)
if name.startswith("labels/") and name.endswith(".txt")
}
if not classes:
raise ExportError("classes.txt 是空的")
if not labels:
raise ExportError("导出里没有任何标签文件")
return classes, labels
def parse_boxes(text: str, stem: str, num_classes: int) -> tuple[list[int], str, int]:
"""校验并规整一个标签文件。
Returns:
(类别下标列表, 规整后的标签文本, 被钳位的坐标个数)
字段数、类别下标、明显越界的坐标都直接报错:这类问题在训练时不会崩,
只会悄悄拉低指标。只有浮点误差量级的溢出才钳到 [0,1]。
"""
class_ids = []
lines = []
clamped = 0
for number, line in enumerate(text.splitlines(), start=1):
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) != 5:
raise ExportError(f"{stem}.txt 第 {number} 行有 {len(parts)} 个字段,YOLO 需要 5 个:{line!r}")
try:
class_id = int(parts[0])
coordinates = [float(v) for v in parts[1:]]
except ValueError as exc:
raise ExportError(f"{stem}.txt 第 {number} 行无法解析:{line!r}") from exc
if not 0 <= class_id < num_classes:
raise ExportError(f"{stem}.txt 第 {number} 行的类别下标 {class_id} 超出 0~{num_classes - 1}")
if not all(-COORDINATE_TOLERANCE <= v <= 1 + COORDINATE_TOLERANCE for v in coordinates):
raise ExportError(f"{stem}.txt 第 {number} 行的坐标不在 [0,1] 内:{coordinates}")
clamped += sum(not 0.0 <= v <= 1.0 for v in coordinates)
coordinates = [min(max(v, 0.0), 1.0) for v in coordinates]
class_ids.append(class_id)
lines.append(" ".join([str(class_id), *(f"{v:.6f}" for v in coordinates)]))
return class_ids, "".join(f"{line}\n" for line in lines), clamped
def collect(
classes: list[str], labels: dict[str, str], images_dir: Path
) -> tuple[list[Sample], list[str], int]:
"""把标签与图片配对,返回 (样本列表, 多余的图片名, 钳位的坐标个数)。"""
samples = []
missing = []
clamped = 0
for stem, text in labels.items():
candidates = [
images_dir / f"{stem}{suffix}" for suffix in (".png", *IMAGE_SUFFIXES)
]
image = next((p for p in candidates if p.exists()), None)
if image is None:
missing.append(stem)
continue
class_ids, cleaned, clamped_here = parse_boxes(text, stem, len(classes))
clamped += clamped_here
# 分层用"框最多的那个类别":一张图可能有多个类别的框,人工复核后尤其常见
stratum = classes[Counter(class_ids).most_common(1)[0][0]] if class_ids else EMPTY
samples.append(Sample(image, cleaned, stratum))
if missing:
raise ExportError(
f"有 {len(missing)} 个标签找不到对应的图片,例如 {missing[0]};"
f"--images 是否指向了准备复核材料时的 images/ 目录(当前 {images_dir})"
)
known = {s.image.stem for s in samples}
extra = sorted(
p.stem for p in images_dir.iterdir()
if p.suffix.lower() in IMAGE_SUFFIXES and p.stem not in known
)
return samples, extra, clamped
def split(samples: list[Sample], val_ratio: float, seed: int) -> dict[str, list[Sample]]:
"""按类别分层切分。小类只有几十张,随机整体切很容易让它在验证集里挂零。"""
grouped: dict[str, list[Sample]] = defaultdict(list)
for sample in samples:
grouped[sample.stratum].append(sample)
rng = random.Random(seed)
splits: dict[str, list[Sample]] = {"train": [], "val": []}
for _stratum, group in sorted(grouped.items()):
order = sorted(group, key=lambda s: s.image.name) # 先定序,结果才可复现
rng.shuffle(order)
n_val = max(1, round(len(order) * val_ratio)) if len(order) > 1 else 0
splits["val"].extend(order[:n_val])
splits["train"].extend(order[n_val:])
return splits
def main() -> int:
global logger
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--export", required=True, help="Label Studio 的 YOLO 导出(zip 或目录)")
parser.add_argument(
"--images", required=True, help="图片目录,即准备复核材料时的 <...>_label_studio/images",
)
parser.add_argument("--out", required=True, help="输出目录,建议放在 data/ 下")
parser.add_argument("--val-ratio", type=float, default=0.2)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument(
"--drop-empty", action="store_true",
help="丢掉空标签的图。默认保留——它们是压低误检的负样本",
)
parser.add_argument("--overwrite", action="store_true", help="输出目录已存在时先清空")
parser.add_argument("--dry-run", action="store_true", help="只打印计划")
args = parser.parse_args()
out_dir = Path(args.out)
logger = setup_logging()
try:
classes, labels = read_export(Path(args.export))
samples, extra, clamped = collect(classes, labels, Path(args.images))
except (ExportError, FileNotFoundError) as exc:
logger.error("%s", exc)
return 1
logger.info("导出: %s(%d 个标签,%d 类)", args.export, len(labels), len(classes))
if clamped:
logger.info(
"%d 个坐标有浮点误差量级的溢出(Label Studio 按百分比存框,/100 会有误差),已钳到 [0,1];"
"不钳的话 ultralytics 读标签时会直接报错", clamped,
)
if extra:
logger.warning(
"%s 里有 %d 张图片没有对应的标签,不会进入数据集(例如 %s)",
args.images, len(extra), extra[0],
)
empty = [s for s in samples if s.stratum == EMPTY]
if args.drop_empty:
samples = [s for s in samples if s.stratum != EMPTY]
logger.info(
"空标签 %d 张(%.1f%%):%s",
len(empty), 100 * len(empty) / max(len(labels), 1),
"已丢弃" if args.drop_empty else "保留为负样本",
)
splits = split(samples, args.val_ratio, args.seed)
counts = {name: Counter(s.stratum for s in items) for name, items in splits.items()}
boxes = Counter()
for sample in samples:
for line in sample.label_text.splitlines():
if line.strip():
boxes[classes[int(line.split()[0])]] += 1
logger.info("划分: train %d / val %d", len(splits["train"]), len(splits["val"]))
logger.info("%-12s %8s %8s %8s", "类别", "train", "val", "框数")
for name in [*classes, EMPTY]:
if counts["train"][name] or counts["val"][name]:
logger.info(
"%-12s %8d %8d %8d",
name, counts["train"][name], counts["val"][name], boxes[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()
for name, items in splits.items():
for sample in items:
modes[link_or_copy(sample.image, out_dir / "images" / name / sample.image.name)] += 1
label_path = out_dir / "labels" / name / f"{sample.image.stem}.txt"
label_path.parent.mkdir(parents=True, exist_ok=True)
label_path.write_text(sample.label_text, encoding="utf-8")
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)
(out_dir / "summary.json").write_text(
json.dumps(
{
"export": str(Path(args.export).resolve()),
"images": str(Path(args.images).resolve()),
"num_images": len(samples),
"num_boxes": sum(boxes.values()),
"empty_labels": len(empty),
"dropped_empty": args.drop_empty,
"images_without_label": len(extra),
"clamped_coordinates": clamped,
"val_ratio": args.val_ratio,
"seed": args.seed,
"split_sizes": {k: len(v) for k, v in splits.items()},
"per_class_images": {k: dict(v) for k, v in counts.items()},
"per_class_boxes": dict(boxes),
"link_modes": dict(modes),
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
logger.info("图片落盘方式: %s", dict(modes))
logger.info("完成 -> %s(%d 个框)", out_dir, sum(boxes.values()))
return 0
if __name__ == "__main__":
raise SystemExit(main())