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