feat(detect): --label-studio 一步生成可导入 Label Studio 的复核视图
label-studio-converter import yolo 只认 <input>/{images,labels,classes.txt},且
os.listdir 不递归,看不见 train/val 子目录,直接喂数据集会 "No labels converted"。
按类别重命名后文件名全局唯一,于是在 <out>/label_studio/ 下把两个 split 拍平
(硬链接:Label Studio 本地存储会校验路径在 DOCUMENT_ROOT 之下,软链接可能越界),
再调用 converter 生成 tasks.json 与 labeling config。
- 框以 predictions 导入:它们是 SHAP/Grad-CAM 生成的,审核员提交后才成为
annotation,导出时没审过的框不会混进去。
- 不传 image_dims:真实数据有 3223 种尺寸,传了会写错每条标注的宽高。
- 任务数与图片数不符时报错,而不是静默少导。
- converter 自带的导入提示漏掉了 LABEL_STUDIO_LOCAL_FILES_* 环境变量(它在循环里
改写了 image_root_url,之后与默认值比较失败),静音后由脚本打印带实际路径的步骤。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7adc5b6332
commit
1a43267b55
@@ -31,7 +31,11 @@ data/<name>/
|
||||
├── classes.txt # 每行一个类别名,行号即 class_id,给标注工具复核用
|
||||
├── name_map.csv # 新名 ↔ 源文件对照
|
||||
├── data.yaml
|
||||
└── summary.json
|
||||
├── summary.json
|
||||
└── label_studio/ # 仅 --label-studio:导入 Label Studio 人工复核用
|
||||
├── images/*.png labels/*.txt classes.txt # 两个 split 拍平,硬链接
|
||||
├── tasks.json # 框作为 predictions
|
||||
└── tasks.label_config.xml
|
||||
```
|
||||
|
||||
没有框的图(标签为空)默认保留:YOLO 训练里它们是负样本,有助于压低误检。但**空标签的
|
||||
@@ -45,9 +49,12 @@ data/<name>/
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import csv
|
||||
import errno
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
@@ -203,6 +210,63 @@ def assign_names(samples: Sequence[Sample], names: Sequence[str]) -> dict[Path,
|
||||
return mapping
|
||||
|
||||
|
||||
LABEL_STUDIO_DIR = "label_studio"
|
||||
|
||||
|
||||
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 之下,软链接解析后可能落到外面。
|
||||
|
||||
框以 predictions 导入:它们是 SHAP / Grad-CAM 生成的,不是人工标注。审核员修改提交
|
||||
之后才成为 annotation,Label Studio 导出时也只导出 annotation,没审过的框不会混进去。
|
||||
"""
|
||||
try:
|
||||
from label_studio_sdk.converter.imports.yolo import convert_yolo_to_ls
|
||||
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"
|
||||
# converter 会往 stdout 打一份缺了环境变量的导入说明,还会刷一串 root logger 的 INFO;
|
||||
# 静音它们,由 main() 打印带实际路径的完整步骤
|
||||
root_logger = logging.getLogger()
|
||||
previous_level = root_logger.level
|
||||
root_logger.setLevel(logging.WARNING)
|
||||
try:
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
# 不传 image_dims:图片尺寸不统一,传了会把同一个宽高写进每一条标注
|
||||
convert_yolo_to_ls(
|
||||
input_dir=str(view),
|
||||
out_file=str(tasks_file),
|
||||
out_type="predictions",
|
||||
image_ext=".png",
|
||||
)
|
||||
finally:
|
||||
root_logger.setLevel(previous_level)
|
||||
tasks = json.loads(tasks_file.read_text(encoding="utf-8"))
|
||||
if len(tasks) != num_images:
|
||||
raise RuntimeError(
|
||||
f"label-studio-converter 只生成了 {len(tasks)} 个任务,应为 {num_images};"
|
||||
f"检查 {view / 'images'}"
|
||||
)
|
||||
return {
|
||||
"tasks": len(tasks),
|
||||
"document_root": str(view.resolve()),
|
||||
"tasks_file": str(tasks_file.resolve()),
|
||||
"label_config": str((view / "tasks.label_config.xml").resolve()),
|
||||
}
|
||||
|
||||
|
||||
def parse_empty_policy(tokens: Sequence[str]) -> tuple[str, dict[str, str]]:
|
||||
"""解析 --empty-policy:一个可选的全局默认,加若干 `类别=策略`。
|
||||
|
||||
@@ -288,6 +352,10 @@ def main() -> int:
|
||||
"--keep-names", action="store_true",
|
||||
help="保留源文件名,不按 <类别>_<序号> 重新编号(源文件名需去掉后缀后全局唯一)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--label-studio", action="store_true",
|
||||
help="额外生成 <out>/label_studio/:拍平的硬链接视图 + tasks.json,可直接导入 Label Studio 复核",
|
||||
)
|
||||
parser.add_argument("--overwrite", action="store_true", help="输出目录已存在时先清空")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只打印计划")
|
||||
args = parser.parse_args()
|
||||
@@ -373,6 +441,8 @@ def main() -> int:
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
if args.label_studio:
|
||||
logger.info("--label-studio:会在 %s 下生成导入视图", out_dir / LABEL_STUDIO_DIR)
|
||||
logger.info("--dry-run:未写入任何文件")
|
||||
return 0
|
||||
|
||||
@@ -419,6 +489,14 @@ def main() -> int:
|
||||
write_data_yaml(out_dir / "data.yaml", names, root=out_dir.resolve(),
|
||||
train="images/train", val="images/val")
|
||||
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))
|
||||
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)
|
||||
@@ -435,6 +513,7 @@ 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()},
|
||||
@@ -448,6 +527,21 @@ def main() -> int:
|
||||
)
|
||||
logger.info("图片落盘方式: %s(hard=硬链接, copy=跨盘复制, convert=转成 PNG)", dict(modes))
|
||||
logger.info("完成 -> %s", out_dir)
|
||||
if label_studio:
|
||||
# label-studio-converter 自带的提示漏了环境变量(它在循环里改写了 image_root_url,
|
||||
# 与默认值比较时就不相等了),这里给出带实际路径的完整步骤
|
||||
logger.info(
|
||||
"Label Studio 任务 %d 个,导入步骤:\n"
|
||||
" 1) export LABEL_STUDIO_LOCAL_FILES_SERVING_ENABLED=true\n"
|
||||
" export LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT=%s\n"
|
||||
" uv run label-studio start\n"
|
||||
" 2) 新建项目 -> Labeling Setup -> Code,粘贴 %s\n"
|
||||
" 3) Settings -> Cloud Storage -> Add Source Storage -> Local files,\n"
|
||||
" 绝对路径填 %s/images。只保存,不要点 Sync(会给每张图再建一个空任务)\n"
|
||||
" 4) 导入 %s",
|
||||
label_studio["tasks"], label_studio["document_root"], label_studio["label_config"],
|
||||
label_studio["document_root"], label_studio["tasks_file"],
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user