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:
co-authored by
Claude Opus 5
parent
891f930de2
commit
7dd0f89d18
@@ -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 种,传了会把同一个宽高写进每条标注。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user