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()