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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user