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
+85 -8
View File
@@ -28,6 +28,16 @@ runs/<source>_label_studio/ # 同时也是 Label Studio 的 DOCUMENT_ROOT
└── 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**:这份材料是给人复核的,不是训练集。
修好标签后,再从 Label Studio 导出、划分到 data/ 下给 YOLO 训练。
复核期间 Label Studio 直接从这个目录读图,**不要删除或 --overwrite 它**。
@@ -52,6 +62,7 @@ import logging
import os
import shutil
import sys
import urllib.parse
from collections import Counter, defaultdict
from collections.abc import Sequence
from pathlib import Path
@@ -203,12 +214,56 @@ def assign_names(samples: Sequence[Sample], names: Sequence[str]) -> dict[Path,
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。
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 生成的,不是人工标注。审核员修改提交
之后才成为 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
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;
# 静音它们,由 main() 打印带实际路径的完整步骤
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),
out_file=str(tasks_file),
out_type="predictions",
image_root_url=image_root_url,
image_ext=".png",
)
finally:
@@ -243,7 +302,9 @@ def build_label_studio_tasks(out_dir: Path, num_images: int) -> dict:
)
return {
"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()),
"label_config": str((out_dir / "tasks.label_config.xml").resolve()),
}
@@ -329,6 +390,13 @@ def main() -> int:
"注意空标签在 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(
"--keep-names", action="store_true",
help="保留源文件名,不按 <类别>_<序号> 重新编号(源文件名需去掉后缀后全局唯一)",
@@ -403,6 +471,9 @@ def main() -> int:
if args.dry_run:
logger.info("输出: %s", out_dir)
logger.info(
"DOCUMENT_ROOT: %s", args.document_root or f"未设置 {DOCUMENT_ROOT_ENV},用输出目录本身"
)
logger.info("--dry-run:未写入任何文件")
return 0
@@ -447,7 +518,12 @@ def main() -> int:
write_classes_txt(out_dir / "classes.txt", names)
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:
logger.error("%s", exc)
return 1
@@ -487,11 +563,12 @@ def main() -> int:
" 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"
" 绝对路径填 %s。只保存,不要点 Sync(会给每张图再建一个空任务)\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["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