真实导出里出现 height=1.0000000000000002:Label Studio 按百分比存框,导出时 /100 留下浮点误差。ultralytics 读标签时对 >1 的坐标直接报错,所以这点溢出必须钳掉, 否则装配出的数据集根本训不起来。 只钳 1e-6 量级的溢出,真正越界的坐标仍然报错。顺带把坐标统一写成 6 位小数。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
319 lines
13 KiB
Python
319 lines
13 KiB
Python
#!/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())
|