fix(label-studio): 图片 URL 按实际的 DOCUMENT_ROOT 生成(--document-root)

原先任务里的图片 URL 固定是 /data/local-files/?d=images/<图名>,隐含假设输出目录就是
LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT。服务器上 DOCUMENT_ROOT=/home/deep/labeling,
Label Studio 会去找 /home/deep/labeling/images/<图名>,11211 张图全部 404。

- 新增 --document-root,缺省读 LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT;URL 写成相对它的
  路径,Local files 存储路径随之算好并打印。
- 输出目录在 DOCUMENT_ROOT 之外时,在 DOCUMENT_ROOT 里建同名软链接指过去,产出仍留在
  runs/,不用手动复制;DOCUMENT_ROOT 里已有同名真目录或指向别处的软链接时报错,不覆盖。
- 更正此前"软链接解析后会落到 DOCUMENT_ROOT 外面"的说法:核对 Label Studio 1.23.0
  源码,safe_join 用 abspath、存储路径用 normpath 做字符串前缀比较,都不解析软链接。
- 测试照搬 localfiles_data() 的判定逐个任务验证能否被服务,并加入旧 URL 在
  /home/deep/labeling 下全部 404 的回归用例。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-15 20:04:04 +08:00
co-authored by Claude Opus 5
parent 891f930de2
commit 7dd0f89d18
3 changed files with 233 additions and 10 deletions
+24 -2
View File
@@ -644,6 +644,26 @@ runs/boxes_shap_label_studio/ # 同时也是 Label Studio 的 DOCUMENT_ROOT
### 导入 Label Studio ### 导入 Label Studio
**图片 URL 必须按 Label Studio 实际的 `DOCUMENT_ROOT` 生成。** 任务里写的是
`/data/local-files/?d=<相对路径>/images/<图名>.png`,Label Studio 把 `d` 拼到
`LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT` 后面找文件,相对路径错了就是整个项目 404。
脚本缺省读这个环境变量,也可以用 `--document-root` 显式指定:
```bash
# DOCUMENT_ROOT 统一放标注项目,产出仍留在 runs/
uv run python scripts/prepare_label_studio.py --source runs/boxes_shap \
--document-root /home/deep/labeling
```
| 情形 | 做法 | 图片 URL |
|---|---|---|
| 没设 DOCUMENT_ROOT | 输出目录自己当 DOCUMENT_ROOT | `?d=images/…` |
| 输出目录在 DOCUMENT_ROOT 之下 | 直接用 | `?d=<相对路径>/images/…` |
| 输出目录在 DOCUMENT_ROOT 之外 | 在 DOCUMENT_ROOT 里建同名软链接指向它 | `?d=boxes_shap_label_studio/images/…` |
DOCUMENT_ROOT 里已有同名的**真目录**(比如手动复制过去的一份)时脚本会报错,不会覆盖它。
Local files 存储的绝对路径填 `<DOCUMENT_ROOT>/<访问路径>/images`,脚本打印的步骤里已经算好。
脚本结束时会打印带实际路径的导入步骤:设置 `LABEL_STUDIO_LOCAL_FILES_*` 环境变量启动、 脚本结束时会打印带实际路径的导入步骤:设置 `LABEL_STUDIO_LOCAL_FILES_*` 环境变量启动、
粘贴 labeling config、添加 Local files 存储(**只保存不要 Sync**,否则每张图多出一个空任务)、 粘贴 labeling config、添加 Local files 存储(**只保存不要 Sync**,否则每张图多出一个空任务)、
导入 `tasks.json`。 导入 `tasks.json`。
@@ -652,8 +672,10 @@ runs/boxes_shap_label_studio/ # 同时也是 Label Studio 的 DOCUMENT_ROOT
- **为什么是扁平结构**:`label-studio-converter import yolo` 只认 `<input>/{images,labels,classes.txt}`, - **为什么是扁平结构**:`label-studio-converter import yolo` 只认 `<input>/{images,labels,classes.txt}`,
且 `os.listdir` 不递归,看不见子目录。 且 `os.listdir` 不递归,看不见子目录。
- **硬链接而不是软链接**:Label Studio 读本地文件时会检查路径是否在 `DOCUMENT_ROOT` 之下, - **软链接可以用**:Label Studio(1.23.0 核对过源码)拼文件路径用 `safe_join`(`abspath`),
软链接解析后可能落到外面。 存储路径做 `normpath` 后按字符串前缀比较,都**不解析软链接**,所以 DOCUMENT_ROOT 里放一个
指向 `runs/` 的软链接就能访问,不必复制。存储路径也必须是 DOCUMENT_ROOT 的**子目录**,
与 DOCUMENT_ROOT 相同会被拒绝,这正是要填到 `…/images` 的原因。
- **框作为 predictions 而不是 annotations**:它们是模型生成的。审核员修改提交后才成为 annotation, - **框作为 predictions 而不是 annotations**:它们是模型生成的。审核员修改提交后才成为 annotation,
哪些审过一目了然,导出时也只导出 annotation,没审过的框不会混进去。 哪些审过一目了然,导出时也只导出 annotation,没审过的框不会混进去。
- **不传 `--image-dims`**:图片尺寸有 3,223 种,传了会把同一个宽高写进每条标注。 - **不传 `--image-dims`**:图片尺寸有 3,223 种,传了会把同一个宽高写进每条标注。
+85 -8
View File
@@ -28,6 +28,16 @@ runs/<source>_label_studio/ # 同时也是 Label Studio 的 DOCUMENT_ROOT
└── tasks.label_config.xml └── tasks.label_config.xml
``` ```
**图片 URL 相对 Label Studio 的 DOCUMENT_ROOT。** 任务里写的是
`/data/local-files/?d=<相对路径>/images/<图名>.png`,Label Studio 把 `d` 拼到
`LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT` 后面找文件。所以 URL 必须按实际的 DOCUMENT_ROOT 生成:
# 缺省读环境变量 LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT;也可显式指定
--document-root /home/deep/labeling
输出目录不在 DOCUMENT_ROOT 下时,会在 DOCUMENT_ROOT 里建一个同名软链接指过去,
产出本身仍留在 runs/。没有设置 DOCUMENT_ROOT 时,输出目录自己就当作 DOCUMENT_ROOT。
这里**不写 data.yaml、不划分 train/val**:这份材料是给人复核的,不是训练集。 这里**不写 data.yaml、不划分 train/val**:这份材料是给人复核的,不是训练集。
修好标签后,再从 Label Studio 导出、划分到 data/ 下给 YOLO 训练。 修好标签后,再从 Label Studio 导出、划分到 data/ 下给 YOLO 训练。
复核期间 Label Studio 直接从这个目录读图,**不要删除或 --overwrite 它**。 复核期间 Label Studio 直接从这个目录读图,**不要删除或 --overwrite 它**。
@@ -52,6 +62,7 @@ import logging
import os import os
import shutil import shutil
import sys import sys
import urllib.parse
from collections import Counter, defaultdict from collections import Counter, defaultdict
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path from pathlib import Path
@@ -203,12 +214,56 @@ def assign_names(samples: Sequence[Sample], names: Sequence[str]) -> dict[Path,
return mapping return mapping
def build_label_studio_tasks(out_dir: Path, num_images: int) -> dict: DOCUMENT_ROOT_ENV = "LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT"
def expose_under_document_root(out_dir: Path, document_root: Path | None) -> tuple[Path, Path]:
"""确定 Label Studio 从 DOCUMENT_ROOT 下哪个路径访问输出目录,返回 (document_root, 访问路径)。
- 没有 DOCUMENT_ROOT:输出目录自己当 DOCUMENT_ROOT,访问路径就是它;
- 输出目录已在 DOCUMENT_ROOT 之下:直接用;
- 否则在 DOCUMENT_ROOT 下建一个同名软链接指向输出目录。
软链接可用:Label Studio(1.23.0 核对过)拼路径用 abspath/normpath,存储路径校验也是
纯字符串的前缀比较,都不解析软链接。所有比较都要在"未解析"的路径上做,否则软链接
会被解析到 DOCUMENT_ROOT 外面,反倒对不上。
"""
out_dir = Path(os.path.abspath(out_dir))
if document_root is None:
return out_dir, out_dir
root = Path(os.path.abspath(document_root))
if not root.is_dir():
raise RuntimeError(f"DOCUMENT_ROOT {root} 不存在或不是目录")
if root == out_dir:
# Label Studio 拒绝与 DOCUMENT_ROOT 相同的存储路径,但存储填的是 <root>/images,没问题
return root, out_dir
if root in out_dir.parents:
return root, out_dir
link = root / out_dir.name
if link.is_symlink():
if Path(os.path.abspath(os.path.join(link.parent, os.readlink(link)))) != out_dir:
raise RuntimeError(f"{link} 已是指向别处的软链接({os.readlink(link)}),请先确认后删除")
elif link.exists():
raise RuntimeError(
f"{link} 已存在且不是软链接(可能是之前手动复制的一份)。"
f"删除它后重跑,或者用 --out {link} 直接把材料生成到 DOCUMENT_ROOT 里"
)
else:
link.symlink_to(out_dir, target_is_directory=True)
return root, link
def build_label_studio_tasks(
out_dir: Path, num_images: int, document_root: Path, served_dir: Path
) -> dict:
"""在输出目录上调用 label-studio-converter,生成 tasks.json 与 labeling config。 """在输出目录上调用 label-studio-converter,生成 tasks.json 与 labeling config。
converter 的 YOLO 导入只认 `<input>/{images,labels,classes.txt}`,且 os.listdir 不递归—— converter 的 YOLO 导入只认 `<input>/{images,labels,classes.txt}`,且 os.listdir 不递归——
这正是本脚本产出的扁平结构。图片是硬链接而不是软链接也有讲究:Label Studio 本地存储 这正是本脚本产出的扁平结构。
会检查文件是否在 DOCUMENT_ROOT 之下,软链接解析后可能落到外面。
图片 URL 是 `/data/local-files/?d=<served_dir 相对 document_root>/images/<图名>`:
Label Studio 把 d 拼在 DOCUMENT_ROOT 后面找文件,相对路径错了就是满屏 404。
框以 predictions 导入:它们是 SHAP / Grad-CAM 生成的,不是人工标注。审核员修改提交 框以 predictions 导入:它们是 SHAP / Grad-CAM 生成的,不是人工标注。审核员修改提交
之后才成为 annotation,Label Studio 导出时也只导出 annotation,没审过的框不会混进去。 之后才成为 annotation,Label Studio 导出时也只导出 annotation,没审过的框不会混进去。
@@ -219,6 +274,9 @@ def build_label_studio_tasks(out_dir: Path, num_images: int) -> dict:
raise RuntimeError("需要 label-studio(uv sync 后可用)") from exc raise RuntimeError("需要 label-studio(uv sync 后可用)") from exc
tasks_file = out_dir / "tasks.json" tasks_file = out_dir / "tasks.json"
relative = served_dir.relative_to(document_root).as_posix()
image_dir = "images" if relative == "." else f"{relative}/images"
image_root_url = "/data/local-files/?d=" + urllib.parse.quote(image_dir, safe="/")
# converter 会往 stdout 打一份缺了环境变量的导入说明,还会刷一串 root logger 的 INFO; # converter 会往 stdout 打一份缺了环境变量的导入说明,还会刷一串 root logger 的 INFO;
# 静音它们,由 main() 打印带实际路径的完整步骤 # 静音它们,由 main() 打印带实际路径的完整步骤
root_logger = logging.getLogger() root_logger = logging.getLogger()
@@ -231,6 +289,7 @@ def build_label_studio_tasks(out_dir: Path, num_images: int) -> dict:
input_dir=str(out_dir), input_dir=str(out_dir),
out_file=str(tasks_file), out_file=str(tasks_file),
out_type="predictions", out_type="predictions",
image_root_url=image_root_url,
image_ext=".png", image_ext=".png",
) )
finally: finally:
@@ -243,7 +302,9 @@ def build_label_studio_tasks(out_dir: Path, num_images: int) -> dict:
) )
return { return {
"tasks": len(tasks), "tasks": len(tasks),
"document_root": str(out_dir.resolve()), "document_root": str(document_root),
"storage_path": str(served_dir / "images"),
"image_root_url": image_root_url,
"tasks_file": str(tasks_file.resolve()), "tasks_file": str(tasks_file.resolve()),
"label_config": str((out_dir / "tasks.label_config.xml").resolve()), "label_config": str((out_dir / "tasks.label_config.xml").resolve()),
} }
@@ -329,6 +390,13 @@ def main() -> int:
"注意空标签在 YOLO 里的意思是『此图无缺陷』,缺陷类的空标签走到训练会教模型抑制真实缺陷" "注意空标签在 YOLO 里的意思是『此图无缺陷』,缺陷类的空标签走到训练会教模型抑制真实缺陷"
), ),
) )
parser.add_argument(
"--document-root", default=os.environ.get(DOCUMENT_ROOT_ENV),
help=(
f"Label Studio 的 DOCUMENT_ROOT,缺省读环境变量 {DOCUMENT_ROOT_ENV}。"
"图片 URL 按它生成;输出目录不在它下面时会在里面建同名软链接"
),
)
parser.add_argument( parser.add_argument(
"--keep-names", action="store_true", "--keep-names", action="store_true",
help="保留源文件名,不按 <类别>_<序号> 重新编号(源文件名需去掉后缀后全局唯一)", help="保留源文件名,不按 <类别>_<序号> 重新编号(源文件名需去掉后缀后全局唯一)",
@@ -403,6 +471,9 @@ def main() -> int:
if args.dry_run: if args.dry_run:
logger.info("输出: %s", out_dir) logger.info("输出: %s", out_dir)
logger.info(
"DOCUMENT_ROOT: %s", args.document_root or f"未设置 {DOCUMENT_ROOT_ENV},用输出目录本身"
)
logger.info("--dry-run:未写入任何文件") logger.info("--dry-run:未写入任何文件")
return 0 return 0
@@ -447,7 +518,12 @@ def main() -> int:
write_classes_txt(out_dir / "classes.txt", names) write_classes_txt(out_dir / "classes.txt", names)
try: try:
label_studio = build_label_studio_tasks(out_dir, len(samples)) document_root, served_dir = expose_under_document_root(
out_dir, Path(args.document_root) if args.document_root else None
)
if served_dir != Path(os.path.abspath(out_dir)):
logger.info("DOCUMENT_ROOT 下的访问路径: %s -> %s", served_dir, out_dir)
label_studio = build_label_studio_tasks(out_dir, len(samples), document_root, served_dir)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("%s", exc) logger.error("%s", exc)
return 1 return 1
@@ -487,11 +563,12 @@ def main() -> int:
" uv run label-studio start\n" " uv run label-studio start\n"
" 2) 新建项目 -> Labeling Setup -> Code,粘贴 %s\n" " 2) 新建项目 -> Labeling Setup -> Code,粘贴 %s\n"
" 3) Settings -> Cloud Storage -> Add Source Storage -> Local files,\n" " 3) Settings -> Cloud Storage -> Add Source Storage -> Local files,\n"
" 绝对路径填 %s/images。只保存,不要点 Sync(会给每张图再建一个空任务)\n" " 绝对路径填 %s。只保存,不要点 Sync(会给每张图再建一个空任务)\n"
" 4) 导入 %s\n" " 4) 导入 %s\n"
"复核期间 Label Studio 从 %s 读图,不要删除或 --overwrite 这个目录", "图片 URL 形如 %s/<图名>.png;复核期间 Label Studio 从 %s 读图,不要删除或 --overwrite",
label_studio["tasks"], label_studio["document_root"], label_studio["label_config"], label_studio["tasks"], label_studio["document_root"], label_studio["label_config"],
label_studio["document_root"], label_studio["tasks_file"], label_studio["document_root"], label_studio["storage_path"], label_studio["tasks_file"],
label_studio["image_root_url"], out_dir.resolve(),
) )
return 0 return 0
+124
View File
@@ -2,6 +2,7 @@
import csv import csv
import json import json
import os
import sys import sys
from pathlib import Path from pathlib import Path
@@ -19,6 +20,12 @@ import prepare_label_studio as pls # noqa: E402
CLASSES = ["grease", "normal", "whitedot"] CLASSES = ["grease", "normal", "whitedot"]
@pytest.fixture(autouse=True)
def _no_document_root_env(monkeypatch):
"""开发机上若设了 LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT,会改变缺省行为。"""
monkeypatch.delenv("LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT", raising=False)
@pytest.fixture @pytest.fixture
def source(tmp_path): def source(tmp_path):
"""模拟 generate_boxes.py 的产出:images/<类别>/ + labels/<类别>/ + data.yaml。""" """模拟 generate_boxes.py 的产出:images/<类别>/ + labels/<类别>/ + data.yaml。"""
@@ -451,3 +458,120 @@ def test_prints_import_steps_with_real_paths(label_studio_out, capsys):
out = capsys.readouterr().out out = capsys.readouterr().out
assert f"LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT={label_studio_out.resolve()}" in out assert f"LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT={label_studio_out.resolve()}" in out
assert "不要点 Sync" in out assert "不要点 Sync" in out
# ---------------- DOCUMENT_ROOT 与图片 URL ----------------
def _served_by_label_studio(task, document_root, storage_path):
"""照搬 Label Studio 1.23.0 localfiles_data() 的判定:能返回 200 才算 True。
d 拼到 DOCUMENT_ROOT 后(safe_join,不解析软链接),文件所在目录要以存储路径开头,且文件存在。
"""
django_os = pytest.importorskip("django.utils._os")
import posixpath
import urllib.parse as up
query = up.urlparse(task["data"]["image"]).query
d = posixpath.normpath(up.parse_qs(query)["d"][0]).lstrip("/")
full_path = django_os.safe_join(str(document_root), d)
full_dir = os.path.normpath(os.path.dirname(full_path))
return full_dir.startswith(os.path.normpath(str(storage_path))) and os.path.exists(full_path)
@pytest.fixture
def ls_env():
pytest.importorskip("label_studio_sdk.converter.imports.yolo")
def test_without_document_root_urls_are_relative_to_output(source, tmp_path, ls_env):
out = tmp_path / "runs" / "boxes_label_studio"
assert run(source, out) == 0
summary = json.loads((out / "summary.json").read_text(encoding="utf-8"))["label_studio"]
assert summary["image_root_url"] == "/data/local-files/?d=images"
assert all(_served_by_label_studio(t, out, summary["storage_path"]) for t in _tasks(out))
def test_output_outside_document_root_gets_symlinked(source, tmp_path, ls_env):
"""用户实际的配置:DOCUMENT_ROOT=/home/deep/labeling,产出留在 runs/ 下。"""
root = tmp_path / "labeling"
root.mkdir()
out = tmp_path / "runs" / "boxes_label_studio"
assert run(source, out, "--document-root", str(root)) == 0
link = root / "boxes_label_studio"
assert link.is_symlink() and link.resolve() == out.resolve()
summary = json.loads((out / "summary.json").read_text(encoding="utf-8"))["label_studio"]
assert summary["image_root_url"] == "/data/local-files/?d=boxes_label_studio/images"
assert summary["storage_path"] == str(link / "images")
tasks = _tasks(out)
assert len(tasks) == 13
assert all(_served_by_label_studio(t, root, summary["storage_path"]) for t in tasks)
def test_old_urls_would_404_under_a_different_root(source, tmp_path, ls_env):
"""回归:没按 DOCUMENT_ROOT 生成的 ?d=images/... 在 /home/deep/labeling 下全部找不到。"""
root = tmp_path / "labeling"
root.mkdir()
out = tmp_path / "runs" / "boxes_label_studio"
assert run(source, out) == 0 # 不告诉它 DOCUMENT_ROOT
(root / "boxes_label_studio").symlink_to(out) # 即使手动把目录放进去
storage = root / "boxes_label_studio" / "images"
assert not any(_served_by_label_studio(t, root, storage) for t in _tasks(out))
def test_output_inside_document_root_needs_no_symlink(source, tmp_path, ls_env):
root = tmp_path / "labeling"
out = root / "projects" / "boxes"
root.mkdir()
assert run(source, out, "--document-root", str(root)) == 0
assert not [p for p in root.iterdir() if p.is_symlink()]
summary = json.loads((out / "summary.json").read_text(encoding="utf-8"))["label_studio"]
assert summary["image_root_url"] == "/data/local-files/?d=projects/boxes/images"
assert all(_served_by_label_studio(t, root, summary["storage_path"]) for t in _tasks(out))
def test_document_root_defaults_to_environment(source, tmp_path, monkeypatch, ls_env):
root = tmp_path / "labeling"
root.mkdir()
monkeypatch.setenv("LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT", str(root))
out = tmp_path / "runs" / "boxes_label_studio"
assert run(source, out) == 0
assert (root / "boxes_label_studio").is_symlink()
def test_rerun_reuses_existing_symlink(source, tmp_path, ls_env):
root = tmp_path / "labeling"
root.mkdir()
out = tmp_path / "runs" / "boxes_label_studio"
assert run(source, out, "--document-root", str(root)) == 0
assert run(source, out, "--document-root", str(root), "--overwrite") == 0
assert (root / "boxes_label_studio").resolve() == out.resolve()
def test_refuses_to_replace_a_real_directory_in_document_root(source, tmp_path, capsys, ls_env):
"""DOCUMENT_ROOT 里已有同名的真目录(例如手动复制的一份),不能覆盖。"""
root = tmp_path / "labeling"
(root / "boxes_label_studio").mkdir(parents=True)
(root / "boxes_label_studio" / "keep.txt").write_text("x", encoding="utf-8")
out = tmp_path / "runs" / "boxes_label_studio"
assert run(source, out, "--document-root", str(root)) != 0
assert "不是软链接" in capsys.readouterr().out
assert (root / "boxes_label_studio" / "keep.txt").exists()
def test_refuses_symlink_pointing_elsewhere(source, tmp_path, capsys, ls_env):
root = tmp_path / "labeling"
root.mkdir()
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
(root / "boxes_label_studio").symlink_to(elsewhere)
out = tmp_path / "runs" / "boxes_label_studio"
assert run(source, out, "--document-root", str(root)) != 0
assert "指向别处" in capsys.readouterr().out
def test_missing_document_root_is_an_error(source, tmp_path, capsys, ls_env):
out = tmp_path / "runs" / "boxes_label_studio"
assert run(source, out, "--document-root", str(tmp_path / "nope")) != 0
assert "不存在" in capsys.readouterr().out