Spaces:
Running
Running
File size: 6,691 Bytes
bb59211 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | """训练产物的发现 —— ``settings.train_root`` 下的 ``checkpoints/<step>/``。
模型由官方 ``lerobot-train`` 在平台**之外**训练,产物就是一棵目录树。这一层
只负责如实把它读出来,不判断好坏、不执行任何训练。
"""
from __future__ import annotations
import json
from pathlib import Path
from app.config import settings
def _step_of(name: str) -> int | None:
"""``checkpoints`` 下的目录名 ⇒ 步数。
正常的 checkpoint 目录就是零填充的步数(``005000``)。``last/`` 之类的
非数字目录**跳过** —— 它通常是个空的符号链接残留,列出来只会让人以为
多了一个模型。无法解析一律返回 None,调用方据此过滤。
"""
try:
return int(str(name).strip())
except (TypeError, ValueError):
return None
def _collect_files(root: Path) -> tuple[int, list[dict]]:
"""递归列出一个 checkpoint 的全部文件与总字节数。
用 ``rglob`` 遍历真实磁盘内容而不是写死 ``pretrained_model/`` 与
``training_state/`` 两棵子树 —— 不同 LeRobot 版本落盘的文件集不一样
(我们比参考实现少 ``scheduler_state.json``、多四个 pre/postprocessor),
写死会在版本升级时静默漏列文件。
"""
files: list[dict] = []
total = 0
for path in sorted(root.rglob("*")):
try:
if not path.is_file():
continue
st = path.stat()
except OSError:
continue # 竞态:训练正在写/文件刚被删
files.append({
"name": str(path.relative_to(root)),
"size": st.st_size,
"mtime": int(st.st_mtime),
})
total += st.st_size
return total, files
def _read_train_config(checkpoint: Path) -> dict:
"""读 checkpoint 的 ``train_config.json``;缺失/损坏返回 {}。
这份就是「训练参数」页签的数据源 —— 训练当时实际用的超参,而不是我们
事后猜的默认值。
"""
path = checkpoint / "pretrained_model" / "train_config.json"
try:
doc = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return doc if isinstance(doc, dict) else {}
def _scan_run(run_dir: Path) -> dict | None:
"""把一个 ``output_dir`` 扫成一个 run;没有 ``checkpoints/`` 就返回 None。"""
checkpoint_root = run_dir / "checkpoints"
if not checkpoint_root.is_dir():
return None
checkpoints: list[dict] = []
for entry in sorted(checkpoint_root.iterdir()):
if not entry.is_dir():
continue
step = _step_of(entry.name)
if step is None:
continue
total_bytes, files = _collect_files(entry)
if not files:
continue # 空目录(如 last/ 的残留)不成其为模型
checkpoints.append({
"step": step,
"name": entry.name,
"file_count": len(files),
"total_bytes": total_bytes,
"files": files,
# 参数按**该 checkpoint**自己的配置读,不跨 checkpoint 复用 ——
# 同一个 output_dir 下不同步数的超参可以不同(改过参数续训)。
"train_config": _read_train_config(entry),
})
if not checkpoints:
return None
# 最新的排最前 —— 页面按这个顺序展示,「训练参数」页签也取第一份。
checkpoints.sort(key=lambda item: item["step"], reverse=True)
newest = checkpoints[0]
policy = newest["train_config"].get("policy") or {}
return {
"name": run_dir.name,
"path": str(run_dir),
"model_type": str(policy.get("type") or ""),
"checkpoint_count": len(checkpoints),
"total_bytes": sum(c["total_bytes"] for c in checkpoints),
"checkpoints": checkpoints,
}
def resolve_checkpoint_file(run_name: str, step: str, relative_name: str) -> Path | None:
"""把 (run 名, checkpoint 名, 相对文件名) 解析成一个可下载的**真实路径**。
找不到返回 None,由路由层转成 404。
★ **安全:不做路径拼接。** 直觉写法是 `checkpoint_dir / relative_name`,但那样
``../../../../etc/passwd`` 就能读到任意文件 —— 这个端点是要对外开下载的,
必须挡住。这里改成:**先扫一遍拿到磁盘上的真实文件清单,再按名字精确匹配**,
所以返回的路径必然来自真实条目,构造不出清单外的路径。
(不用 `Path.resolve()` + `is_relative_to` 那种断言式校验 —— 清单匹配是从
源头上就没有越界的可能,而不是"先拼出来再检查拼对了没"。)
"""
wanted = str(relative_name or "").strip()
if not wanted:
return None
data = list_runs()
for run in data.get("runs", []):
if run.get("name") != run_name:
continue
for checkpoint in run.get("checkpoints", []):
if checkpoint.get("name") != step:
continue
for entry in checkpoint.get("files", []):
if entry.get("name") == wanted:
root = Path(run["path"]) / "checkpoints" / step
return root / wanted
return None
def list_runs() -> dict:
"""``{"runs": [...], "root": str, "exists": bool}``。
**两种层级都支持**,因为两种都很常见、也都会被人配进来:
root 本身就是 run <root>/checkpoints/<step>/…
root 是 run 的父目录 <root>/<run>/checkpoints/<step>/…
实测默认路径 ``文件测试/umi_act_model`` 属于前者 —— 把它按后者扫会
一律返回空列表(``umi_act_model/checkpoints/checkpoints`` 当然不存在)。
目录不存在 ⇒ ``{"runs": []}``(不是错误):新机器、或本机从未跑过训练,
都是正常情况,前端据此渲染空态而不是把"没有模型"显示成"出错了"。
"""
root = settings.train_root
if not root.is_dir():
return {"runs": [], "root": str(root), "exists": False}
runs: list[dict] = []
try:
own = _scan_run(root)
if own is not None:
runs.append(own)
else:
for entry in sorted(root.iterdir()):
if not entry.is_dir():
continue
run = _scan_run(entry)
if run is not None:
runs.append(run)
except OSError:
return {"runs": [], "root": str(root), "exists": True}
return {"runs": runs, "root": str(root), "exists": True}
|