DataFlowing / collector /scripts /pack_toolkit.py
stouchinglhh
Claude Code
collector: 更新采集工程至 v1.3.11(手套链路整体换厂商 SDK v2.1.0,全栈因此锁 Python 3.10)
0901daa
Raw History Blame Contribute Delete
12.2 kB
#!/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()