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:
Shaun Zhou
2026-09-15 17:06:40 +08:00
co-authored by Claude Opus 5
parent 7adc5b6332
commit 1a43267b55
3 changed files with 201 additions and 1 deletions
+72
View File
@@ -387,3 +387,75 @@ def test_kept_empty_labels_named_after_source_folder(source, tmp_path):
assert run(source, out) == 0
assert output_of(out, "normal_0").stem.startswith("normal_")
assert len([s for s in _stems(out) if s.startswith("normal_")]) == 5
@pytest.fixture
def label_studio_out(source, tmp_path):
pytest.importorskip("label_studio_sdk.converter.imports.yolo")
out = tmp_path / "data" / "boxes"
assert run(source, out, "--empty-policy", "normal=keep", "--label-studio") == 0
return out
def _tasks(out):
return json.loads((out / "label_studio" / "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_tasks_are_predictions_with_urls(label_studio_out):
"""每张图一个任务;框作为 predictions,图片 URL 相对 DOCUMENT_ROOT。"""
tasks = _tasks(label_studio_out)
assert len(tasks) == 13
for task in tasks:
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()
def test_label_studio_boxes_round_trip_to_yolo(label_studio_out):
"""Label Studio 的左上角百分比坐标反算回 YOLO 中心点坐标,必须与标签一致。"""
view = label_studio_out / "label_studio"
checked = 0
for task in _tasks(label_studio_out):
name = Path(task["data"]["image"].rsplit("/", 1)[1]).stem
lines = (view / "labels" / f"{name}.txt").read_text(encoding="utf-8").split()
results = task["predictions"][0]["result"]
if not lines:
assert results == [] # 保留下来的空标签:任务里没有框
continue
value = results[0]["value"]
class_id, cx, cy, w, h = lines
assert value["rectanglelabels"] == [CLASSES[int(class_id)]]
assert value["x"] / 100 + value["width"] / 200 == pytest.approx(float(cx))
assert value["y"] / 100 + value["height"] / 200 == pytest.approx(float(cy))
assert value["width"] / 100 == pytest.approx(float(w))
checked += 1
assert checked == 12
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")
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()
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 json.loads((out / "summary.json").read_text(encoding="utf-8"))["label_studio"] is None