Spaces:
Running
Running
File size: 12,222 Bytes
330cf83 0901daa 330cf83 | 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 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 | #!/usr/bin/env python3
"""⚠️ **本脚本尚未跟上 2026-09-21 的 SDK v2.1.0 迁移,当前跑不通也产不出正确产物。**
迁移计划 S7 整体重写。重写时必须满足下面这条**位置契约**:
zip 名 wheels/toolkit/glove_sdk.zip
zip 顶层 裸的 `glove_sdk/`(**不含** tools/ 前缀 —— 产物与位置无关)
解压目标 tools/ ← 由 start.sh / start.bat / start_lite.* 指定
落点 <项目根>/tools/glove_sdk/ (core/glove_sdk_boot.py 的
find_sdk_dir() 找的就是这里)
位置只写在分发壳那一处,zip 本身不含路径假设 —— 再挪地方不用重打包。
裁剪手套工具包(stouch_glove_toolkit*)供随包分发,使普通版一键部署自带骨架解算。
背景: 骨架解算依赖第三方手套工具包(项目根同级的 stouch_glove_toolkit*,
core/glove_keypoint_solver.py 的 find_toolkit_dir() 按目录名找)。本地参考副本
2.1G,其中 2.1G 是 .venv/、7.3M 是 MANO 模型的 .pkl —— 解算路径两者都不需要,
真正的运行时闭包约 6M。
本脚本按白名单裁一份,并在裁剪产物上**实跑一遍解算链**(导入 → 构造 HandSolver
→ 喂帧到 warmup 完成 → 校验 (21,3) 关键点),确认裁掉的东西确实用不上。
用法:
python scripts/pack_toolkit.py # 裁一份 → wheels/toolkit/glove_toolkit.zip
python scripts/pack_toolkit.py --no-verify # 跳过实跑校验(没装依赖时)
python scripts/pack_toolkit.py --src DIR # 指定源工具包目录
python scripts/pack_toolkit.py --keep DIR # 同时留一份展开的目录
python scripts/pack_toolkit.py --out DIR # 自定义输出目录
产物:
<out>/toolkit/glove_toolkit.zip 解压后即为项目根同级的 stouch_glove_toolkit*/
<out>/toolkit/<工具包名>/ --keep 时的展开副本(可直接拷到项目根)
注意: zip 必须放在 wheels/ 的**子目录**里 —— 放在 wheels/ 根会让
start.bat 的 `pip install --find-links wheels` 把它当成源码包尝试解析。
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
TOOLKIT_PREFIX = "stouch_glove_toolkit"
# 解算路径的运行时闭包(白名单)。目录整体拷贝,单文件按名拷贝。
KEEP_DIRS = [
"glove_sdk", # HandSolver / RawImuFrame 等公开接口
"glove_core", # 算法与受保护运行时(含 glove_lite 手模型)
"assets/hand_geometry", # hand_measured_runtime_v1.json(几何)
"assets/hand", # HAND_LEFT/RIGHT_PINKY_PLUS_2MM.npz(手模型)
]
KEEP_FILES = [
"99-stm32-glove.rules", # Linux 免 root 访问 USB CDC 的 udev 规则
"glove_devices.json", # 设备配置(采集侧同款)
"config.json", # 工具包自带标定配置(param_inst_calib)
"config_left.json",
"bimanual_display_config.json",
"requirements.txt",
"README-SDK.md",
"QUICKSTART.md",
]
# 明确不要的(白名单之外的重物,注释写明原因,避免以后有人"顺手加回来")
DROP_NOTE = {
".venv": "工具包自带的虚拟环境 2.1G;依赖由本仓库 requirements.txt 统一装",
"apps": "GUI/标定界面,解算链不导入(已实测)",
"firmware": "固件源码,上位机不需要",
"glove_io": "独立 IO 程序",
"glove_runtime": "独立运行时壳",
"tools": "工具包自带小工具",
"assets/hand/models": "MANO_*.pkl 7.3M;HandNumpy 只读 .npz(已实测)",
"calibration": "只留两个 *_default.json —— 其它是开发机上这只手套的标定,"
"带过去会被 pick_calibration 按时间戳优先选中(见下)",
"imu_log.csv": "1.4M 实测日志",
"hand_kinematics_guide.html": "文档",
"pyarmor.bug.log": "打包日志",
"ENCRYPTION.md": "加密说明(本地明文版用不到)",
}
def find_toolkit_dir(src: Path | None) -> Path:
"""定位源工具包目录(默认扫项目根,与 find_toolkit_dir() 同口径)。"""
if src is not None:
if not src.is_dir():
sys.exit(f"[错误] --src 不是目录: {src}")
return src
cands = sorted(p for p in ROOT.iterdir()
if p.is_dir() and p.name.lower().startswith(TOOLKIT_PREFIX))
if not cands:
sys.exit(f"[错误] 项目根未找到 {TOOLKIT_PREFIX}* 目录(用 --src 指定)")
if len(cands) > 1:
print(f"[警告] 找到多个工具包目录,取第一个: {cands[0].name}")
print(f" 全部: {', '.join(p.name for p in cands)}")
return cands[0]
def copy_trimmed(src: Path, dst: Path) -> None:
"""按白名单把 src 裁进 dst(dst 会被清空重建)。"""
if dst.exists():
shutil.rmtree(dst)
# models/ 是 assets/hand/models/MANO_*.pkl(7.3M),HandNumpy 只读 .npz,
# 拷贝时就排除,省得先拷后删
ignore = shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo", "models")
for rel in KEEP_DIRS:
s = src / rel
if not s.is_dir():
sys.exit(f"[错误] 源工具包缺少必需目录: {rel}")
shutil.copytree(s, dst / rel, ignore=ignore)
for name in KEEP_FILES:
s = src / name
if s.is_file():
shutil.copy2(s, dst / name)
# 标定:只留随包默认标定。带时间戳的标定属于开发机上那只手套,
# 一旦被带到客户机,core/glove_keypoint_solver.py 的 pick_calibration
# 会按 generated_at 优先选中它 —— 骨架会解成别人的手。
cal_src, cal_dst = src / "calibration", dst / "calibration"
cal_dst.mkdir(parents=True, exist_ok=True)
kept = []
for f in sorted(cal_src.glob("*_default.json")):
shutil.copy2(f, cal_dst / f.name)
kept.append(f.name)
if not kept:
sys.exit("[错误] 源工具包 calibration/ 下没有 *_default.json")
print(f" calibration/: 保留 {len(kept)} 个默认标定 → {', '.join(kept)}")
def _python() -> str:
"""校验用的解释器:优先本仓库 venv(依赖齐全)。"""
for rel in ("venv/bin/python", "venv/Scripts/python.exe"):
p = ROOT / rel
if p.exists():
return str(p)
return sys.executable
VERIFY_SNIPPET = r'''
import os, sys, time
import numpy as np
TK = sys.argv[1]
sys.path.insert(0, TK)
from glove_sdk.interfaces.solver import HandSolver
from glove_sdk.types import RawImuFrame
for side in ("left", "right"):
calib = os.path.join(TK, "calibration", f"imu_calibration_{side}_default.json")
geom = os.path.join(TK, "assets", "hand_geometry", "hand_measured_runtime_v1.json")
s = HandSolver(side, calib, geom)
q = np.zeros((16, 4)); q[:, 3] = 1.0 # 16×单位四元数(静止姿)
warm = None
for i in range(1, 601):
kf = s.process(RawImuFrame(
sequence=i, device_timestamp_us=i * 10000,
host_timestamp_us=time.time_ns() // 1000,
quaternions_xyzw=q,
present_mask=np.ones(16, bool), valid_mask=np.ones(16, bool)))
if warm is None and getattr(getattr(kf, "status", None),
"details", {}).get("warmup_completed"):
warm = i
if warm and i == warm + 3:
j = np.asarray(kf.joints_m, np.float64)
assert j.shape == (21, 3), f"{side}: 关键点形状 {j.shape} != (21,3)"
assert np.isfinite(j).all(), f"{side}: 关键点含 NaN/Inf"
span = j.max(0) - j.min(0)
assert 0.02 < float(span.max()) < 0.5, f"{side}: 手尺寸异常 {span}"
print(f" {side}: warmup {warm} 帧, 关键点 (21,3), "
f"包围盒 {np.round(span, 3)} m")
break
else:
raise SystemExit(f"{side}: 600 帧内未完成 warmup")
'''
def verify(toolkit: Path) -> bool:
"""在裁剪产物上实跑解算链(左右手各一遍)。返回是否通过。"""
py = _python()
print(f" 校验解释器: {py}")
# 校验会 import 整条链,默认会往产物里写 __pycache__/*.pyc —— 那是构建垃圾,
# 别让它进交付物(客户机 Python 小版本不同还会被判为陈旧而重编)
env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
proc = subprocess.run([py, "-c", VERIFY_SNIPPET, str(toolkit)],
capture_output=True, text=True, env=env)
ok = proc.returncode == 0
for line in (proc.stdout or "").splitlines():
if line.strip():
print(f" {line}" if line.startswith(" ") else f" {line}")
if not ok:
print(" [失败] 裁剪产物跑不了解算链,stderr 末 20 行:")
for line in (proc.stderr or "").strip().splitlines()[-20:]:
print(f" {line}")
return ok
def make_zip(toolkit: Path, zip_path: Path) -> None:
"""打包成 zip(含工具包目录名一层,解压即落在项目根)。"""
zip_path.parent.mkdir(parents=True, exist_ok=True)
n = 0
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z:
for p in sorted(toolkit.rglob("*")):
# 防御性过滤:任何路径下都不收字节码缓存
if not p.is_file() or p.suffix in (".pyc", ".pyo"):
continue
if "__pycache__" in p.parts:
continue
z.write(p, Path(toolkit.name) / p.relative_to(toolkit))
n += 1
print(f" 已打包: {zip_path} ({n} 个文件, "
f"{zip_path.stat().st_size / 1e6:.1f} MB)")
def dir_size(p: Path) -> tuple[int, int]:
files = [f for f in p.rglob("*") if f.is_file()]
return len(files), sum(f.stat().st_size for f in files)
def main() -> None:
ap = argparse.ArgumentParser(
description="裁剪手套工具包供随包分发(普通版一键部署自带骨架解算)")
ap.add_argument("--src", type=Path, help="源工具包目录(默认扫项目根)")
ap.add_argument("--out", type=Path, default=ROOT / "wheels",
help="输出目录(默认 wheels/,与离线包同处便于一起拷给客户)")
ap.add_argument("--keep", type=Path, metavar="DIR",
help="同时留一份展开的目录(可直接拷到项目根)")
ap.add_argument("--no-verify", action="store_true", help="跳过实跑校验")
ap.add_argument("--no-zip", action="store_true", help="只产出展开目录,不打包")
args = ap.parse_args()
src = find_toolkit_dir(args.src)
n0, s0 = dir_size(src)
print(f"源工具包: {src.name} ({n0} 个文件, {s0 / 1e6:.0f} MB)")
with tempfile.TemporaryDirectory(prefix="pack_toolkit_") as tmp:
staged = Path(tmp) / src.name
print("按白名单裁剪 ...")
copy_trimmed(src, staged)
n1, s1 = dir_size(staged)
print(f"裁剪后: {n1} 个文件, {s1 / 1e6:.1f} MB "
f"(省 {(1 - s1 / max(s0, 1)) * 100:.1f}%)")
print("未随包的部分及原因:")
for name, why in DROP_NOTE.items():
print(f" - {name}: {why}")
if not args.no_verify:
print("实跑解算链校验 ...")
if not verify(staged):
sys.exit("[错误] 校验未通过 —— 白名单漏了东西,请补 KEEP_DIRS/KEEP_FILES")
if args.keep:
dst = args.keep / src.name
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(staged, dst)
print(f" 展开副本: {dst}")
if not args.no_zip:
make_zip(staged, args.out / "toolkit" / "glove_toolkit.zip")
print()
print("完成。随包分发: 把整个 wheels/ 拷到客户机项目根目录,"
"start.bat / start.sh 会自动把工具包展开到项目根。")
print("客户机若已有 stouch_glove_toolkit*/ 目录则原样不动。")
if __name__ == "__main__":
main()
|