refactor(detect): 数据集改为扁平结构、不划分 train/val,Label Studio 文件放到根目录
prepare_yolo_dataset.py 的产出先导入 Label Studio 复核,划分留到复核之后,于是:
- 去掉按类别分层的 train/val 切分以及 --val-ratio / --seed,images/ 与 labels/
下直接是文件;name_map.csv 去掉 split 列,summary.json 的逐类数量改为单列。
- label_studio/ 子目录的内容上移一层:扁平的数据集本身就是 label-studio-converter
要求的 <input>/{images,labels,classes.txt},tasks.json 与 labeling config 直接
生成在根目录,不再需要额外的硬链接视图,DOCUMENT_ROOT 即数据集根目录。
- data.yaml 的 train 与 val 只能指向同一个 images/。ultralytics 能读,但验证指标
算的是训练集本身,mAP 虚高、早停与 best.pt 的选择失真。脚本结束时告警,
detect.py 检测到 train == val 时也告警。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1a43267b55
commit
af0897f8c7
@@ -589,15 +589,15 @@ runs/<name>/
|
||||
|
||||
## 检测模型训练(YOLO)
|
||||
|
||||
上一步的产出按类别分目录,方便人工核对;检测训练要的是 train/val 划分——类别写在标签
|
||||
第一列,不靠目录名。所以先整理再训练:
|
||||
上一步的产出按类别分目录,方便人工核对;YOLO 的类别写在标签第一列,不靠目录名。
|
||||
所以先拍平整理,导入 Label Studio 复核,再训练:
|
||||
|
||||
```bash
|
||||
# 1) 拍平类别目录、分层切分、硬链接到 data/(不占额外磁盘)
|
||||
uv run python scripts/prepare_yolo_dataset.py --source runs/boxes_gradcam
|
||||
# 1) 拍平类别目录、统一 PNG、按类别重命名,硬链接到 data/;同时生成 Label Studio 任务
|
||||
uv run python scripts/prepare_yolo_dataset.py --source runs/boxes_shap --label-studio
|
||||
# 先看计划加 --dry-run
|
||||
|
||||
# 2) 训练
|
||||
# 2) 训练(训练前需要先划分,见下文"不划分 train/val")
|
||||
uv run python detect.py --config configs/detect/film.yaml
|
||||
|
||||
# 只评估 / 续训
|
||||
@@ -609,15 +609,22 @@ uv run python detect.py --config configs/detect/film.yaml --resume
|
||||
产出:
|
||||
|
||||
```
|
||||
data/boxes_gradcam/
|
||||
├── images/{train,val}/<类别>_<序号>.png # 一律 PNG:PNG 原图硬链接,BMP 等无损转换
|
||||
├── labels/{train,val}/<类别>_<序号>.txt
|
||||
├── classes.txt # 每行一个类别名,行号即 class_id
|
||||
├── name_map.csv # 新文件名 ↔ 源文件路径
|
||||
├── data.yaml # path 写绝对路径,ultralytics 从哪跑都能读到
|
||||
└── summary.json # 划分明细、逐类数量、落盘方式
|
||||
data/boxes_shap/
|
||||
├── images/<类别>_<序号>.png # 一律 PNG:PNG 原图硬链接,BMP 等无损转换
|
||||
├── labels/<类别>_<序号>.txt
|
||||
├── classes.txt # 每行一个类别名,行号即 class_id
|
||||
├── name_map.csv # 新文件名 ↔ 源文件路径
|
||||
├── data.yaml # train 与 val 都指向 images/
|
||||
├── summary.json # 逐类数量、空标签处置、落盘方式
|
||||
├── tasks.json # 仅 --label-studio:每张图一个任务,框作为 predictions
|
||||
└── tasks.label_config.xml # 仅 --label-studio:各类别的 RectangleLabels
|
||||
```
|
||||
|
||||
**不划分 train/val。** 这份数据集先导入 Label Studio 复核,划分留到复核之后。`data.yaml`
|
||||
的 `train` 与 `val` 只能指向同一个 `images/`,ultralytics 能读,但验证指标算的是训练集本身,
|
||||
mAP 会虚高,早停与 `best.pt` 的选择也随之失真。脚本结束时会告警,`detect.py` 检测到
|
||||
train 与 val 相同时也会告警。正式训练前要先划分。
|
||||
|
||||
**图片统一成 PNG。** pearlescent / blue / uv 的原始数据是 BMP(共 10,814 张),
|
||||
`generate_boxes.py` 按原后缀链接,于是 YOLO 数据集里会混着两种格式。这一步按**文件头**
|
||||
(不是后缀)判断:真 PNG 照旧硬链接不占磁盘,其余无损转 PNG——不缩放像素,归一化的框坐标
|
||||
@@ -626,8 +633,8 @@ data/boxes_gradcam/
|
||||
|
||||
**按类别重新编号。** 源文件名五花八门(`反面纵黑点_1_550mmX1561.71m_20260416160441_0.png`、
|
||||
`pearlescent_404.bmp`……),输出统一改成 `<类别>_<序号>.png`,如 `blackdot_0042.png`。
|
||||
类别以标签第一列为准,文件名永远不会与标签矛盾;序号在类内按源路径排序、与 train/val 划分
|
||||
无关,换 `--seed` 或 `--val-ratio` 重跑,同一张图拿到的名字不变。原文件名里的正反面、位置、
|
||||
类别以标签第一列为准,文件名永远不会与标签矛盾;序号在类内按源路径排序,同一份来源重跑,
|
||||
同一张图拿到的名字不变。原文件名里的正反面、位置、
|
||||
时间戳等信息都在 `name_map.csv` 里(带 BOM,Excel 直接打开中文不乱码)。想保留原名加 `--keep-names`。
|
||||
|
||||
**classes.txt** 是给 LabelImg / X-AnyLabeling 这类标注工具用的,人工复核、修改框时
|
||||
@@ -635,18 +642,8 @@ data/boxes_gradcam/
|
||||
|
||||
### 导入 Label Studio 人工复核
|
||||
|
||||
弱监督框需要人工把关。加 `--label-studio` 会额外生成一份可以直接导入的视图:
|
||||
|
||||
```bash
|
||||
uv run python scripts/prepare_yolo_dataset.py --source runs/boxes_shap --label-studio
|
||||
```
|
||||
|
||||
```
|
||||
data/boxes_shap/label_studio/
|
||||
├── images/*.png labels/*.txt classes.txt # 两个 split 拍平,硬链接
|
||||
├── tasks.json # 每张图一个任务,框作为 predictions
|
||||
└── tasks.label_config.xml # 9 个类别的 RectangleLabels
|
||||
```
|
||||
弱监督框需要人工把关。加 `--label-studio` 会在数据集根目录额外生成 `tasks.json` 与
|
||||
`tasks.label_config.xml`,数据集根目录本身就是 Label Studio 的 `DOCUMENT_ROOT`。
|
||||
|
||||
脚本结束时会打印带实际路径的导入步骤:设置 `LABEL_STUDIO_LOCAL_FILES_*` 环境变量启动、
|
||||
粘贴 labeling config、添加 Local files 存储(**只保存不要 Sync**,否则每张图多出一个空任务)、
|
||||
@@ -654,8 +651,8 @@ data/boxes_shap/label_studio/
|
||||
|
||||
几个取舍:
|
||||
|
||||
- **为什么要拍平**:`label-studio-converter import yolo` 只认 `<input>/{images,labels,classes.txt}`,
|
||||
且 `os.listdir` 不递归,看不见 `train/`、`val/`。按类别重命名后文件名全局唯一,可以直接合并。
|
||||
- **为什么是扁平结构**:`label-studio-converter import yolo` 只认 `<input>/{images,labels,classes.txt}`,
|
||||
且 `os.listdir` 不递归,看不见子目录。数据集不划分、直接扁平,正好就是它要的结构。
|
||||
- **硬链接而不是软链接**:Label Studio 读本地文件时会检查路径是否在 `DOCUMENT_ROOT` 之下,
|
||||
软链接解析后可能落到外面。
|
||||
- **框作为 predictions 而不是 annotations**:它们是模型生成的。审核员修改提交后才成为 annotation,
|
||||
@@ -667,11 +664,6 @@ data/boxes_shap/label_studio/
|
||||
> **复核重点是 `normal`。** SHAP 在每张图上都框了东西,`normal` 有 4,295 个框、占 38%。
|
||||
> 若 `normal` 表示无缺陷,这些框要删掉,否则检测器会学着"检测正常"。
|
||||
|
||||
### 划分
|
||||
|
||||
按类别分层(`--val-ratio` 默认 0.2,`--seed` 默认 42,两次运行结果一致)。分层是必需的:
|
||||
`black` 只有 53 张、`others` 69 张,随机切很容易让某个小类在验证集里挂零,指标就成了空的。
|
||||
|
||||
### 没有框的图怎么处理
|
||||
|
||||
Grad-CAM 没框出东西的有 339 张(占 3%)。它们**默认保留为负样本**,对压低误检有用。
|
||||
|
||||
@@ -69,6 +69,13 @@ def main() -> int:
|
||||
return 1
|
||||
meta = yaml.safe_load(data_yaml.read_text(encoding="utf-8"))
|
||||
logger.info("数据集: %s(%d 类)", data_yaml, meta["nc"])
|
||||
if meta.get("train") == meta.get("val"):
|
||||
# prepare_yolo_dataset.py / generate_boxes.py 的产出都不划分,两者指向同一目录
|
||||
logger.warning(
|
||||
"data.yaml 的 train 与 val 相同(%s):验证集就是训练集,mAP 会虚高,"
|
||||
"早停与 best.pt 的选择也因此失真。正式训练前请先划分数据集",
|
||||
meta.get("train"),
|
||||
)
|
||||
|
||||
train_cfg = dict(cfg.get("train", {}))
|
||||
swanlab_cfg = dict(train_cfg.pop("swanlab", {}))
|
||||
|
||||
+78
-110
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python
|
||||
"""把 generate_boxes.py 的产出整理成 YOLO 训练所需的数据集,放到 data/ 下。
|
||||
"""把 generate_boxes.py 的产出整理成扁平的 YOLO 数据集,放到 data/ 下。
|
||||
|
||||
uv run python scripts/prepare_yolo_dataset.py --source runs/boxes_gradcam
|
||||
|
||||
@@ -11,33 +11,33 @@ runs/boxes_gradcam/
|
||||
└── labels/<类别>/<图名>.txt
|
||||
```
|
||||
|
||||
而 YOLO 检测训练要的是 **train/val 划分**——类别信息在标签文件的第一列里,
|
||||
不靠目录名。这一步做三件事:
|
||||
而 YOLO 的类别信息在标签文件的第一列里,不靠目录名。这一步做三件事:
|
||||
|
||||
1. 把按类别分的目录拍平,按 train/val 重新组织;
|
||||
2. **按类别分层**切分,小类不至于在验证集里挂零;
|
||||
3. 图片**统一成 PNG**:本来就是 PNG 的硬链接过去,不占额外磁盘;BMP 等其他格式
|
||||
1. 把按类别分的目录**拍平**,不划分 train/val——这份数据集先导入 Label Studio 复核,
|
||||
划分留到复核之后;
|
||||
2. 图片**统一成 PNG**:本来就是 PNG 的硬链接过去,不占额外磁盘;BMP 等其他格式
|
||||
(pearlescent / blue / uv 原始数据都是 BMP)无损转成 PNG。不缩放像素,
|
||||
归一化的框坐标原样有效。
|
||||
|
||||
4. 文件**按类别重新编号**:`<类别>_<序号>.png`,序号在类内按源路径排序,重跑结果一致。
|
||||
3. 文件**按类别重新编号**:`<类别>_<序号>.png`,序号在类内按源路径排序,重跑结果一致。
|
||||
原文件名里的正反面、位置、时间戳等信息记在 `name_map.csv` 里,不会丢。
|
||||
想保留原名加 `--keep-names`。
|
||||
|
||||
```
|
||||
data/<name>/
|
||||
├── images/{train,val}/<类别>_<序号>.png
|
||||
├── labels/{train,val}/<类别>_<序号>.txt
|
||||
├── classes.txt # 每行一个类别名,行号即 class_id,给标注工具复核用
|
||||
├── name_map.csv # 新名 ↔ 源文件对照
|
||||
├── data.yaml
|
||||
├── images/<类别>_<序号>.png
|
||||
├── labels/<类别>_<序号>.txt
|
||||
├── classes.txt # 每行一个类别名,行号即 class_id
|
||||
├── name_map.csv # 新名 ↔ 源文件对照
|
||||
├── data.yaml # train 与 val 都指向 images/,不能直接用于评估
|
||||
├── summary.json
|
||||
└── label_studio/ # 仅 --label-studio:导入 Label Studio 人工复核用
|
||||
├── images/*.png labels/*.txt classes.txt # 两个 split 拍平,硬链接
|
||||
├── tasks.json # 框作为 predictions
|
||||
└── tasks.label_config.xml
|
||||
├── tasks.json # 仅 --label-studio:框作为 predictions
|
||||
└── tasks.label_config.xml # 仅 --label-studio
|
||||
```
|
||||
|
||||
这个结构正是 label-studio-converter 的 YOLO 导入要求的 `<input>/{images,labels,classes.txt}`,
|
||||
所以 `--label-studio` 直接在数据集根目录上转换,DOCUMENT_ROOT 就是数据集根目录。
|
||||
|
||||
没有框的图(标签为空)默认保留:YOLO 训练里它们是负样本,有助于压低误检。但**空标签的
|
||||
语义是"这张图什么都没有"**,不是"有缺陷但没定位出来"。来自缺陷类别的空标签如果照搬,
|
||||
等于在教模型抑制真实缺陷,所以可以用 `--empty-policy` 按来源类别分别处置:
|
||||
@@ -56,7 +56,6 @@ import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
@@ -196,8 +195,8 @@ def class_of(sample: Sample, names: Sequence[str]) -> str:
|
||||
def assign_names(samples: Sequence[Sample], names: Sequence[str]) -> dict[Path, str]:
|
||||
"""按类别重新编号,返回 {源图片: 新文件名主干},如 `blackdot_0042`。
|
||||
|
||||
序号在类内按源路径排序、与 train/val 划分无关:换 --val-ratio 或 --seed 重跑,
|
||||
同一张图拿到的名字不变,已经做过的人工复核记录还能对得上。
|
||||
序号在类内按源路径排序:同一份来源重跑,同一张图拿到的名字不变,
|
||||
已经做过的人工复核记录还能对得上。
|
||||
"""
|
||||
by_class: dict[str, list[Sample]] = defaultdict(list)
|
||||
for sample in samples:
|
||||
@@ -210,16 +209,12 @@ def assign_names(samples: Sequence[Sample], names: Sequence[str]) -> dict[Path,
|
||||
return mapping
|
||||
|
||||
|
||||
LABEL_STUDIO_DIR = "label_studio"
|
||||
def build_label_studio_tasks(out_dir: Path, num_images: int) -> dict:
|
||||
"""在数据集根目录上调用 label-studio-converter,生成 tasks.json 与 labeling config。
|
||||
|
||||
|
||||
def build_label_studio_view(out_dir: Path, num_images: int) -> dict:
|
||||
"""在 <out>/label_studio/ 下搭一份 label-studio-converter 能直接吃的扁平视图并转换。
|
||||
|
||||
label-studio-converter 的 YOLO 导入只认 `<input>/{images,labels,classes.txt}`,
|
||||
且 os.listdir 不递归——train/val 子目录它看不见。重命名后文件名全局唯一,
|
||||
两个 split 可以直接拍平;用硬链接而不是软链接,因为 Label Studio 本地存储会检查
|
||||
文件是否在 DOCUMENT_ROOT 之下,软链接解析后可能落到外面。
|
||||
converter 的 YOLO 导入只认 `<input>/{images,labels,classes.txt}`,且 os.listdir 不递归——
|
||||
这正是本脚本产出的扁平结构。图片是硬链接而不是软链接也有讲究:Label Studio 本地存储
|
||||
会检查文件是否在 DOCUMENT_ROOT 之下,软链接解析后可能落到外面。
|
||||
|
||||
框以 predictions 导入:它们是 SHAP / Grad-CAM 生成的,不是人工标注。审核员修改提交
|
||||
之后才成为 annotation,Label Studio 导出时也只导出 annotation,没审过的框不会混进去。
|
||||
@@ -229,14 +224,7 @@ def build_label_studio_view(out_dir: Path, num_images: int) -> dict:
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("--label-studio 需要 label-studio(uv sync 后可用)") from exc
|
||||
|
||||
view = out_dir / LABEL_STUDIO_DIR
|
||||
for kind in ("images", "labels"):
|
||||
for split in ("train", "val"):
|
||||
for path in sorted((out_dir / kind / split).iterdir()):
|
||||
link_or_copy(path, view / kind / path.name)
|
||||
link_or_copy(out_dir / "classes.txt", view / "classes.txt")
|
||||
|
||||
tasks_file = view / "tasks.json"
|
||||
tasks_file = out_dir / "tasks.json"
|
||||
# converter 会往 stdout 打一份缺了环境变量的导入说明,还会刷一串 root logger 的 INFO;
|
||||
# 静音它们,由 main() 打印带实际路径的完整步骤
|
||||
root_logger = logging.getLogger()
|
||||
@@ -246,7 +234,7 @@ def build_label_studio_view(out_dir: Path, num_images: int) -> dict:
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
# 不传 image_dims:图片尺寸不统一,传了会把同一个宽高写进每一条标注
|
||||
convert_yolo_to_ls(
|
||||
input_dir=str(view),
|
||||
input_dir=str(out_dir),
|
||||
out_file=str(tasks_file),
|
||||
out_type="predictions",
|
||||
image_ext=".png",
|
||||
@@ -257,13 +245,13 @@ def build_label_studio_view(out_dir: Path, num_images: int) -> dict:
|
||||
if len(tasks) != num_images:
|
||||
raise RuntimeError(
|
||||
f"label-studio-converter 只生成了 {len(tasks)} 个任务,应为 {num_images};"
|
||||
f"检查 {view / 'images'}"
|
||||
f"检查 {out_dir / 'images'}"
|
||||
)
|
||||
return {
|
||||
"tasks": len(tasks),
|
||||
"document_root": str(view.resolve()),
|
||||
"document_root": str(out_dir.resolve()),
|
||||
"tasks_file": str(tasks_file.resolve()),
|
||||
"label_config": str((view / "tasks.label_config.xml").resolve()),
|
||||
"label_config": str((out_dir / "tasks.label_config.xml").resolve()),
|
||||
}
|
||||
|
||||
|
||||
@@ -337,8 +325,6 @@ def main() -> int:
|
||||
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=(
|
||||
@@ -354,7 +340,7 @@ def main() -> int:
|
||||
)
|
||||
parser.add_argument(
|
||||
"--label-studio", action="store_true",
|
||||
help="额外生成 <out>/label_studio/:拍平的硬链接视图 + tasks.json,可直接导入 Label Studio 复核",
|
||||
help="额外在数据集根目录生成 tasks.json 与 labeling config,可直接导入 Label Studio 复核",
|
||||
)
|
||||
parser.add_argument("--overwrite", action="store_true", help="输出目录已存在时先清空")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只打印计划")
|
||||
@@ -403,35 +389,16 @@ def main() -> int:
|
||||
"/".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")
|
||||
counts = 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 samples
|
||||
)
|
||||
logger.info("%-14s %8s", "类别", "数量")
|
||||
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 counts[name]:
|
||||
logger.info("%-14s %8d", name, counts[name])
|
||||
|
||||
to_convert = sum(not is_png(sample.image) for sample in samples)
|
||||
if to_convert:
|
||||
@@ -442,7 +409,7 @@ def main() -> int:
|
||||
|
||||
if args.dry_run:
|
||||
if args.label_studio:
|
||||
logger.info("--label-studio:会在 %s 下生成导入视图", out_dir / LABEL_STUDIO_DIR)
|
||||
logger.info("--label-studio:会在 %s 下生成 tasks.json", out_dir)
|
||||
logger.info("--dry-run:未写入任何文件")
|
||||
return 0
|
||||
|
||||
@@ -456,52 +423,51 @@ def main() -> int:
|
||||
modes: Counter[str] = Counter()
|
||||
seen: dict[str, Path] = {}
|
||||
name_rows = []
|
||||
for split, items in splits.items():
|
||||
for sample in items:
|
||||
image = sample.image
|
||||
stem = image.stem if renamed is None else renamed[image]
|
||||
output_name = f"{stem}.png"
|
||||
# 保留原名时,拍平并统一后缀后不同目录下的 a.png 与 a.bmp 会撞成同一个 a.png;
|
||||
# 重新编号时只有类别名仅大小写不同才会撞。按小写比较:macOS 默认文件系统
|
||||
# 不区分大小写,A.png 与 a.png 也会互相覆盖。
|
||||
key = output_name.lower()
|
||||
if key in seen:
|
||||
hint = (
|
||||
"源数据集的文件名(去掉后缀后)需要全局唯一,"
|
||||
"去掉 --keep-names 按类别重新编号,"
|
||||
"或用 scripts/export_clusters.py --prefix-parent 重新导出"
|
||||
if renamed is None
|
||||
else "data.yaml 里有只差大小写的类别名,请先改成互不相同"
|
||||
)
|
||||
logger.error("输出文件名冲突:%s 与 %s 都会落成 %s。%s", seen[key], image, output_name, hint)
|
||||
return 1
|
||||
seen[key] = image
|
||||
modes[materialize_png(image, out_dir / "images" / split / output_name)] += 1
|
||||
name_rows.append((split, output_name, class_of(sample, names), str(image.resolve())))
|
||||
destination = out_dir / "labels" / split / f"{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")
|
||||
for sample in samples:
|
||||
image = sample.image
|
||||
stem = image.stem if renamed is None else renamed[image]
|
||||
output_name = f"{stem}.png"
|
||||
# 保留原名时,拍平并统一后缀后不同目录下的 a.png 与 a.bmp 会撞成同一个 a.png;
|
||||
# 重新编号时只有类别名仅大小写不同才会撞。按小写比较:macOS 默认文件系统
|
||||
# 不区分大小写,A.png 与 a.png 也会互相覆盖。
|
||||
key = output_name.lower()
|
||||
if key in seen:
|
||||
hint = (
|
||||
"源数据集的文件名(去掉后缀后)需要全局唯一,"
|
||||
"去掉 --keep-names 按类别重新编号,"
|
||||
"或用 scripts/export_clusters.py --prefix-parent 重新导出"
|
||||
if renamed is None
|
||||
else "data.yaml 里有只差大小写的类别名,请先改成互不相同"
|
||||
)
|
||||
logger.error("输出文件名冲突:%s 与 %s 都会落成 %s。%s", seen[key], image, output_name, hint)
|
||||
return 1
|
||||
seen[key] = image
|
||||
modes[materialize_png(image, out_dir / "images" / output_name)] += 1
|
||||
name_rows.append((output_name, class_of(sample, names), str(image.resolve())))
|
||||
destination = out_dir / "labels" / f"{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")
|
||||
# 不划分时 train 与 val 只能指向同一目录:ultralytics 能读,但验证指标算的是训练集本身
|
||||
write_data_yaml(out_dir / "data.yaml", names, root=out_dir.resolve(), train="images", val="images")
|
||||
write_classes_txt(out_dir / "classes.txt", names)
|
||||
|
||||
label_studio = None
|
||||
if args.label_studio:
|
||||
try:
|
||||
label_studio = build_label_studio_view(out_dir, len(samples))
|
||||
label_studio = build_label_studio_tasks(out_dir, len(samples))
|
||||
except RuntimeError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
# utf-8-sig:带 BOM,Excel / Numbers 直接打开中文源路径不乱码
|
||||
with (out_dir / "name_map.csv").open("w", encoding="utf-8-sig", newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(["split", "name", "class", "source"])
|
||||
writer.writerows(sorted(name_rows, key=lambda row: (row[2], row[1])))
|
||||
writer.writerow(["name", "class", "source"])
|
||||
writer.writerows(sorted(name_rows, key=lambda row: (row[1], row[0])))
|
||||
(out_dir / "summary.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -514,10 +480,8 @@ def main() -> int:
|
||||
"empty_policy": {"default": default_policy, **per_class_policy},
|
||||
"renamed": renamed is not None,
|
||||
"label_studio": label_studio,
|
||||
"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()},
|
||||
"split": False,
|
||||
"per_class": dict(counts),
|
||||
"link_modes": dict(modes),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
@@ -527,6 +491,10 @@ def main() -> int:
|
||||
)
|
||||
logger.info("图片落盘方式: %s(hard=硬链接, copy=跨盘复制, convert=转成 PNG)", dict(modes))
|
||||
logger.info("完成 -> %s", out_dir)
|
||||
logger.warning(
|
||||
"数据集未划分 train/val,data.yaml 的 train 与 val 都指向 images/。"
|
||||
"直接拿去训练的话验证指标算的是训练集本身,会虚高;训练前需要另行划分。"
|
||||
)
|
||||
if label_studio:
|
||||
# label-studio-converter 自带的提示漏了环境变量(它在循环里改写了 image_root_url,
|
||||
# 与默认值比较时就不相等了),这里给出带实际路径的完整步骤
|
||||
|
||||
@@ -50,7 +50,7 @@ def output_of(out, source_stem):
|
||||
with (out / "name_map.csv").open(encoding="utf-8-sig", newline="") as handle:
|
||||
for row in csv.DictReader(handle):
|
||||
if Path(row["source"]).stem == source_stem:
|
||||
return out / "images" / row["split"] / row["name"]
|
||||
return out / "images" / row["name"]
|
||||
return None
|
||||
|
||||
|
||||
@@ -63,19 +63,18 @@ def run(source, out, *extra):
|
||||
sys.argv = original
|
||||
|
||||
|
||||
def test_flattens_classes_and_splits(source, tmp_path):
|
||||
"""类别目录被拍平,train/val 分开,图片与标签一一对应。"""
|
||||
def test_flat_layout_without_split(source, tmp_path):
|
||||
"""类别目录被拍平、不划分 train/val:images/ 与 labels/ 下直接是文件,一一对应。"""
|
||||
out = tmp_path / "data" / "boxes"
|
||||
assert run(source, out) == 0
|
||||
|
||||
for split in ("train", "val"):
|
||||
images = sorted(p.stem for p in (out / "images" / split).iterdir())
|
||||
labels = sorted(p.stem for p in (out / "labels" / split).iterdir())
|
||||
assert images == labels
|
||||
total = sum(len(list((out / "images" / s).iterdir())) for s in ("train", "val"))
|
||||
assert total == 13
|
||||
# 拍平后不应再有 <类别>/ 这一层
|
||||
assert not (out / "images" / "train" / "grease").exists()
|
||||
for kind in ("images", "labels"):
|
||||
assert not [p for p in (out / kind).iterdir() if p.is_dir()]
|
||||
images = sorted(p.stem for p in (out / "images").iterdir())
|
||||
labels = sorted(p.stem for p in (out / "labels").iterdir())
|
||||
assert images == labels
|
||||
assert len(images) == 13
|
||||
assert json.loads((out / "summary.json").read_text(encoding="utf-8"))["split"] is False
|
||||
|
||||
|
||||
def test_hard_links_share_inode(source, tmp_path):
|
||||
@@ -87,13 +86,17 @@ def test_hard_links_share_inode(source, tmp_path):
|
||||
assert linked.stat().st_ino == original.stat().st_ino
|
||||
|
||||
|
||||
def test_small_class_present_in_both_splits(source, tmp_path):
|
||||
"""只有 2 张的小类也要在验证集里留下一张,否则它的指标是空的。"""
|
||||
def test_per_class_counts_in_summary(source, tmp_path):
|
||||
out = tmp_path / "data" / "boxes"
|
||||
assert run(source, out) == 0
|
||||
per_class = json.loads((out / "summary.json").read_text(encoding="utf-8"))["per_class"]
|
||||
assert per_class["train"]["whitedot"] == 1
|
||||
assert per_class["val"]["whitedot"] == 1
|
||||
assert per_class == {"grease": 6, "normal": 4, "(无框)": 1, "whitedot": 2}
|
||||
|
||||
|
||||
def test_warns_that_data_is_not_split(source, tmp_path, capsys):
|
||||
"""train 与 val 指向同一目录,直接训练验证指标会虚高,必须提醒。"""
|
||||
assert run(source, tmp_path / "data" / "boxes") == 0
|
||||
assert "未划分" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_empty_labels_kept_as_negatives_by_default(source, tmp_path):
|
||||
@@ -129,14 +132,14 @@ def test_empty_policy_full_writes_a_whole_image_box(source, tmp_path):
|
||||
assert run(source, out, "--empty-policy", "normal=full") == 0
|
||||
|
||||
image = output_of(out, "normal_0")
|
||||
label = out / "labels" / image.parent.name / f"{image.stem}.txt"
|
||||
label = out / "labels" / f"{image.stem}.txt"
|
||||
assert label.read_text(encoding="utf-8").split() == ["1", "0.5", "0.5", "1.0", "1.0"]
|
||||
# 重写而不是硬链接,源标签必须还是空的
|
||||
assert (source / "labels" / "normal" / "normal_0.txt").read_text(encoding="utf-8") == ""
|
||||
|
||||
# 有了确定类别之后,它要跟着 normal 一起参与分层
|
||||
# 有了确定类别之后,计入 normal 而不是"(无框)"
|
||||
per_class = json.loads((out / "summary.json").read_text(encoding="utf-8"))["per_class"]
|
||||
assert per_class["train"]["normal"] + per_class["val"]["normal"] == 5
|
||||
assert per_class["normal"] == 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -161,19 +164,9 @@ def test_data_yaml_uses_absolute_path_and_source_names(source, tmp_path):
|
||||
assert Path(meta["path"]).is_absolute()
|
||||
assert meta["nc"] == 3
|
||||
assert [meta["names"][i] for i in sorted(meta["names"])] == CLASSES
|
||||
# 不划分:两者都指向扁平的 images/
|
||||
assert meta["train"] == meta["val"] == "images"
|
||||
assert (Path(meta["path"]) / meta["train"]).is_dir()
|
||||
assert (Path(meta["path"]) / meta["val"]).is_dir()
|
||||
|
||||
|
||||
def test_reproducible_split(source, tmp_path):
|
||||
"""同一个种子两次运行,划分必须一致。"""
|
||||
first, second = tmp_path / "a", tmp_path / "b"
|
||||
assert run(source, first) == 0
|
||||
assert run(source, second) == 0
|
||||
for split in ("train", "val"):
|
||||
assert sorted(p.name for p in (first / "images" / split).iterdir()) == sorted(
|
||||
p.name for p in (second / "images" / split).iterdir()
|
||||
)
|
||||
|
||||
|
||||
def test_name_collision_after_flattening_is_reported(source, tmp_path, capsys):
|
||||
@@ -210,7 +203,7 @@ def test_dry_run_writes_nothing(source, tmp_path):
|
||||
|
||||
|
||||
def _all_outputs(out, kind):
|
||||
return [p for split in ("train", "val") for p in (out / kind / split).iterdir()]
|
||||
return list((out / kind).iterdir())
|
||||
|
||||
|
||||
def _add_sample(source, group, stem, suffix, class_id, *, fmt=None, mode="RGB"):
|
||||
@@ -358,16 +351,16 @@ def test_name_map_traces_back_to_source(source, tmp_path):
|
||||
rows = list(csv.DictReader(handle))
|
||||
assert len(rows) == 13
|
||||
for row in rows:
|
||||
output = out / "images" / row["split"] / row["name"]
|
||||
output = out / "images" / row["name"]
|
||||
assert output.read_bytes() == Path(row["source"]).read_bytes()
|
||||
assert row["name"].startswith(row["class"] + "_")
|
||||
|
||||
|
||||
def test_names_stable_across_different_splits(source, tmp_path):
|
||||
"""序号与划分无关:换种子重跑,同一张源图拿到同一个名字。"""
|
||||
def test_names_stable_across_reruns(source, tmp_path):
|
||||
"""重跑同一份来源,同一张源图拿到同一个名字,已做的复核记录才对得上。"""
|
||||
first, second = tmp_path / "a", tmp_path / "b"
|
||||
assert run(source, first, "--seed", "1") == 0
|
||||
assert run(source, second, "--seed", "2") == 0
|
||||
assert run(source, first) == 0
|
||||
assert run(source, second) == 0
|
||||
for stem in ("grease_0", "grease_5", "normal_3", "whitedot_1"):
|
||||
assert output_of(first, stem).name == output_of(second, stem).name
|
||||
|
||||
@@ -398,19 +391,14 @@ def label_studio_out(source, tmp_path):
|
||||
|
||||
|
||||
def _tasks(out):
|
||||
return json.loads((out / "label_studio" / "tasks.json").read_text(encoding="utf-8"))
|
||||
return json.loads((out / "tasks.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_label_studio_view_is_flat_and_hard_linked(label_studio_out):
|
||||
"""converter 的 os.listdir 不递归,必须拍平;且与训练集共用 inode,不占磁盘。"""
|
||||
view = label_studio_out / "label_studio"
|
||||
images = sorted(view.glob("images/*.png"))
|
||||
assert len(images) == 13
|
||||
assert not [p for p in (view / "images").iterdir() if p.is_dir()]
|
||||
for image in images:
|
||||
original = next(label_studio_out.glob(f"images/*/{image.name}"))
|
||||
assert image.stat().st_ino == original.stat().st_ino
|
||||
assert (view / "classes.txt").read_text(encoding="utf-8").split() == CLASSES
|
||||
def test_label_studio_files_at_dataset_root(label_studio_out):
|
||||
"""tasks.json 与 labeling config 直接放在数据集根目录,不再有 label_studio/ 子目录。"""
|
||||
assert (label_studio_out / "tasks.json").is_file()
|
||||
assert (label_studio_out / "tasks.label_config.xml").is_file()
|
||||
assert not (label_studio_out / "label_studio").exists()
|
||||
|
||||
|
||||
def test_label_studio_tasks_are_predictions_with_urls(label_studio_out):
|
||||
@@ -421,12 +409,12 @@ def test_label_studio_tasks_are_predictions_with_urls(label_studio_out):
|
||||
assert "annotations" not in task
|
||||
assert task["data"]["image"].startswith("/data/local-files/?d=images/")
|
||||
name = task["data"]["image"].rsplit("/", 1)[1]
|
||||
assert (label_studio_out / "label_studio" / "images" / name).exists()
|
||||
assert (label_studio_out / "images" / name).exists()
|
||||
|
||||
|
||||
def test_label_studio_boxes_round_trip_to_yolo(label_studio_out):
|
||||
"""Label Studio 的左上角百分比坐标反算回 YOLO 中心点坐标,必须与标签一致。"""
|
||||
view = label_studio_out / "label_studio"
|
||||
view = label_studio_out
|
||||
checked = 0
|
||||
for task in _tasks(label_studio_out):
|
||||
name = Path(task["data"]["image"].rsplit("/", 1)[1]).stem
|
||||
@@ -446,16 +434,16 @@ def test_label_studio_boxes_round_trip_to_yolo(label_studio_out):
|
||||
|
||||
|
||||
def test_label_studio_config_and_summary(label_studio_out):
|
||||
config = (label_studio_out / "label_studio" / "tasks.label_config.xml").read_text(encoding="utf-8")
|
||||
config = (label_studio_out / "tasks.label_config.xml").read_text(encoding="utf-8")
|
||||
for name in CLASSES:
|
||||
assert f'value="{name}"' in config
|
||||
summary = json.loads((label_studio_out / "summary.json").read_text(encoding="utf-8"))
|
||||
assert summary["label_studio"]["tasks"] == 13
|
||||
assert Path(summary["label_studio"]["document_root"]).is_absolute()
|
||||
assert Path(summary["label_studio"]["document_root"]) == label_studio_out.resolve()
|
||||
|
||||
|
||||
def test_label_studio_not_built_by_default(source, tmp_path):
|
||||
out = tmp_path / "data" / "boxes"
|
||||
assert run(source, out) == 0
|
||||
assert not (out / "label_studio").exists()
|
||||
assert not (out / "tasks.json").exists()
|
||||
assert json.loads((out / "summary.json").read_text(encoding="utf-8"))["label_studio"] is None
|
||||
|
||||
Reference in New Issue
Block a user