DataFlowing / collector /scripts /pack_lite.py
stouchinglhh
Claude Code
collector: 更新采集工程至 v1.3.11(手套链路整体换厂商 SDK v2.1.0,全栈因此锁 Python 3.10)
0901daa
Raw History Blame Contribute Delete
24.4 kB
"""极简采集版发布包收集脚本 —— 把 lite 运行时闭包收集到一个目录。
只收集 start_lite.bat 真正需要的最小集合(约 1.6 MB 代码)与夹爪原生
载荷(Linux 包,约 440 MB),不拷贝 venv/venv_lite/data/wheels
(--with-wheels 除外)、tools 下的 demo/其它测试、
stouch_glove_toolkit-*/ 等与 lite 无关的内容。
代码文件以仓库根为源;start_lite.bat/.sh 与 使用说明_lite.md 是分发壳。
2026-09-16 起三个分发壳全部住在**仓库根**(与 main_lite.py、
requirements-lite.txt 同级 —— 这几个齐了根目录本身就是可直接运行的 lite
部署目录,根上已有 venv_lite/),由 _dist_shell_source 优先从那里取,
复制进包目录。
--force 只重建代码部分(main_lite/requirements-lite/config/core/ui/tools),
venv_lite/、wheels/、data/、分发壳与 core/gripper/native(470MB 载荷)
原样保留 —— lite_package 可同时当"发布模板"和"本机测试部署目录"用
(在包内跑过 start_lite.sh 装的环境不会被冲掉)。
收集完成后自动做导入自检:在目标目录内 `import main_lite`,缺任何被闭包
引用的模块都会当场报错(与 start_lite.bat 的错误 E 同口径);Linux 包再
断言夹爪闭包(`core.gripper.bridge`)与 `gripper_resources_available()`。
两个目标平台收的**夹爪 Python 代码是同一份**(core/gripper/**、core/
gripper_codec.py、core/s80m_manager.py),差别只在平台绑定件:Windows 包
不收 core/gripper/native/(ELF 原生栈)与 core/gripper/sightac_sdk/(pyarmor
.so 与 libSonixCamera.so 也是 ELF),并硬断言包内零 ELF。缺这些时
gripper_resources_available() 为假 → 扫描不到夹爪(设备组框还在、里面是
空的,属预期降级)。于是
「Windows 包」≡「Linux 包去掉载荷」,后者在 Linux 开发机上可直接造出来验证。
闭包清单与 tools/tests/lite_import_guard_test.py 的 WHITELIST 一致,
外加 egodata_writer/d435_camera 实际 import 的 core.calibration。
用法:
python scripts/pack_lite.py 收集到 lite_package/(本机平台)
python scripts/pack_lite.py --target windows 收集 Windows 包(代码同源,不含平台绑定件)
python scripts/pack_lite.py --out D:/lite 指定目录
python scripts/pack_lite.py --with-wheels 连同 wheels/ 离线依赖包一起
python scripts/pack_lite.py --force 重建代码部分(保留环境与载荷)
"""
import argparse
import os
import shutil
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 分发壳的兜底目录(三个壳 2026-09-16 都搬到仓库根了,这里只作回落)
DIST_SOURCE = os.path.join(ROOT, "lite_package")
# 代码文件(仓库根为源,收集时拷入包目录)
CODE_FILES = [
"main_lite.py",
"requirements-lite.txt",
]
# 分发壳文件(仓库根优先,找不到再回落 lite_package/;均原样复制)
DIST_FILES = [
"start_lite.bat",
"start_lite.sh",
"使用说明_lite.md",
]
def _dist_shell_source(rel: str):
"""分发壳的源路径:仓库根优先,其次 lite_package/;都没有返回 None。
三个壳 2026-09-16 都搬到了仓库根(与 main_lite.py 同级,使根目录直接
可运行);lite_package/ 只作回落,服务老布局的目录。
"""
for base in (ROOT, DIST_SOURCE):
path = os.path.join(base, rel)
if os.path.isfile(path):
return path
return None
# 夹爪载荷:Linux 包整目录收集,Windows 包只收代码(见 _PLATFORM_BOUND)。
GRIPPER_REL = "core/gripper"
GRIPPER_PAYLOAD_REL = "core/gripper/native" # --force 时原样保留
# 与平台绑定的原生件:Windows 包一律不收。
# native/ —— 整套 ELF x86-64 原生栈(460MB)
# sightac_sdk/ —— pyarmor 运行时 .so 与 libSonixCamera.so 同样是 ELF
# 缺它们时 paths.gripper_resources_available() 为假 → 扫描不到夹爪(设备组
# 框照建、里面空着),
# 且 core/gripper/*.py 在 Windows 上因顶层 `import fcntl` 本就导入不了
# (双重保险)。**Python 代码两个包都收**:一份源码两个包,差异只在载荷,
# 于是「Windows 包」= 「Linux 包去掉载荷」,这种形态在 Linux 开发机上可以
# 直接造出来验证(mv 走 native/ 即可),不必真去 Windows 上撞。
_PLATFORM_BOUND_NAMES = ("native", "sightac_sdk")
# 载荷里可以剪掉的(≈33MB):备份二进制、静态库、缓存。
# ★ 绝不能粗剪:native/ORB-SLAM 必须留着 —— 桥接二进制的 RUNPATH 里有
# $ORIGIN/../../../ORB-SLAM/Thirdparty/{DBoW2,g2o}/lib(实测 readelf),
# 剪掉会让 libDBoW2/libg2o 在客户机上找不到;ORBvoc.txt(139MB)与
# FaysSense_VI_Kit_Release/ 同理全是运行时资产。
_PAYLOAD_SKIP_SUFFIX = (".pyc", ".a")
_PAYLOAD_SKIP_INFIX = (".pre_", ".bak_") # 现场备份:xxx.pre_rebuild_2026...
# 构建源,不是运行时资产:两个包都不收(和 camera_service_src 同理)。
# orb_slam_src/ 是 2026-09-17 从 online/ 搬进 core/ 的 ORB-SLAM 真源,只在开发机
# 上 build.sh 用;运行时一切路径都由 core/gripper/paths.py 指到 native/。
# ★ 必须排除而不是「反正没 ELF」:它下面有 build/、dist/fays_opencv48/bin/、
# ORB-SLAM/{lib,Thirdparty/*/lib}/ 这些树内构建产物(全是 ELF),收进 Windows
# 包会直接撞上下面的「零 ELF」硬断言 sys.exit(1)。
_PAYLOAD_SKIP_NAMES = ("__pycache__", "camera_service_src", "orb_slam_src")
def _gripper_ignore_factory(src_root: str, windows: bool,
keep_existing_native: bool):
"""core/gripper 整目录收集的排除表(见上方注释)。
windows: 连平台绑定件一起排(Windows 包)
keep_existing_native: 目标包已有 native/(--force 重打包)时整棵跳过 ——
copytree 遇到已存在的同名软链会直接抛 File exists(symlinks=True 下
的 os.symlink 不覆盖),实测第二次打包必炸。
"""
def _ignore(dir_path: str, names: list):
ignored = set()
for name in names:
if name in _PAYLOAD_SKIP_NAMES:
ignored.add(name)
elif name.endswith(_PAYLOAD_SKIP_SUFFIX) or any(
s in name for s in _PAYLOAD_SKIP_INFIX):
ignored.add(name)
if os.path.abspath(dir_path) == os.path.abspath(src_root):
if windows:
ignored.update(_PLATFORM_BOUND_NAMES)
if keep_existing_native and not windows:
ignored.add("native")
return ignored
return _ignore
# 整目录拷贝(core.glove_usb 是包,内部有 __init__.py 与互引模块)。
# core.gripper 的排除表按目标平台现算(见 _gripper_ignore_factory),
# 且必须 symlinks=True —— 载荷里有 76 个相对软链,展开会让体积 468MB→800MB。
WHOLE_DIRS = ["core/glove_usb", GRIPPER_REL]
# 单文件拷贝:目录 → 文件列表(只收 lite 闭包,主程序 UI/重依赖模块不带)
MODULE_FILES = {
"ui": ["__init__.py", "lite_window.py"],
"config": ["__init__.py", "settings.py", "i18n.py"],
"core": [
"__init__.py",
"pipeline.py", "egodata_writer.py", "encoder_probe.py",
"depth_codec.py", "d435_manager.py", "stereo_depth.py",
"d435_camera.py", "camera.py", "device_detector.py",
"ble_engine.py", "usb_glove_engine.py", "uploader.py",
"api_client.py", "database.py", "helpers.py",
# egodata_writer.py:41 / d435_camera.py:447 实际 import
"calibration.py",
# 夹爪闭包(2026-09-16 并入 lite):力矩阵编码的家(主程序
# ui.main_window 只是 re-export —— 契约不能存两份)
"gripper_codec.py",
# core/gripper/bridge.py:39 顶层 import 它的双目丢帧窗口常量
"s80m_manager.py",
# core/pipeline.py:19 顶层 import 的帧空洞看门狗(录制侧与
# tools/audit_frame_gaps.py 审计脚本共用同一份口径)
"frame_gap.py",
# core/pipeline.py / core/egodata_writer.py 顶层 import 的收尾
# 分段计时(v1.3.11 L0-1;漏了它 lite 一 import 就 ModuleNotFoundError)
"timing.py",
# main_lite.py 顶层 import 的启动环境守卫(用错解释器时给出启动
# 指引再退出;零第三方依赖,必须在包里)
"startup_guard.py",
],
}
# 自检测试(客户机可选跑:无硬件验证导入隔离与采集链)
SELFTEST_FILES = [
"tools/tests/lite_import_guard_test.py",
"tools/tests/lite_smoke_test.py",
"tools/tests/lite_gripper_smoke_test.py",
]
# 随包工具:只有 Linux 包带 —— 它要驱动 native/ 里的厂商二进制,
# 在 Windows 上无处落脚。夹爪的「重新读取出厂标定」在 lite 里**不做 UI**,
# 命令行就是唯一入口,所以这个工具必须在包里,否则那条路在纯 lite 机器上
# 走不通(它顶层只 import stdlib + core.gripper.*,venv_lite 的依赖足够)。
TOOL_FILES_LINUX = [
"tools/import_gripper_calibration.py",
]
def _force_remove(path: str, keep: str) -> None:
"""删除 path;但 keep 那棵树(连同其本身)一个字节都不动。"""
path, keep = os.path.abspath(path), os.path.abspath(keep)
if path == keep:
return # 保留整棵树(夹爪载荷)
if keep.startswith(path + os.sep):
if os.path.isdir(path): # path 是 keep 的祖先:只删其它子项
for name in os.listdir(path):
_force_remove(os.path.join(path, name), keep)
return
if os.path.isdir(path):
shutil.rmtree(path, ignore_errors=True)
elif os.path.isfile(path):
os.remove(path)
def _tree_size(path: str) -> int:
"""目录的字节数(不跟随软链 —— 与分发后的实际落盘一致)。"""
total = 0
for dir_path, _, files in os.walk(path):
for name in files:
full = os.path.join(dir_path, name)
if not os.path.islink(full):
try:
total += os.path.getsize(full)
except OSError:
pass
return total
def _count_symlinks(path: str) -> int:
count = 0
for dir_path, _, files in os.walk(path):
for name in files:
if os.path.islink(os.path.join(dir_path, name)):
count += 1
return count
def _count_py(path: str) -> int:
"""**.py 个数(不跟随软链、不计 __pycache__)—— Windows 包「同一份源码」
的量化证据:与 Linux 包只差 sightac_sdk/ 里那 7 个包壳文件。"""
count = 0
for dir_path, dirs, files in os.walk(path):
dirs[:] = [d for d in dirs if d != "__pycache__"]
count += sum(1 for n in files
if n.endswith(".py")
and not os.path.islink(os.path.join(dir_path, n)))
return count
def _find_elf(root: str) -> list:
"""root 下所有 ELF 文件(前 4 字节 \\x7fELF)的相对路径。
Windows 包的硬不变量就是「一个 ELF 都没有」—— native/ 与 sightac_sdk/
里的 .so 都是 Linux 二进制,带上去了在客户机上只会以各种奇怪方式失败。
"""
found = []
for dir_path, _, files in os.walk(root):
for name in files:
full = os.path.join(dir_path, name)
if os.path.islink(full):
continue
try:
with open(full, "rb") as fh:
if fh.read(4) == b"\x7fELF":
found.append(os.path.relpath(full, root))
except OSError:
continue
return sorted(found)
def _load_paths_module():
"""独立加载 core/gripper/paths.py —— 故意**不经** core.gripper/__init__。
两个原因:① Windows 上 `import core.gripper` 会因顶层 `import fcntl`
直接失败,而打 Windows 包时本脚本仍要用它核对资源清单;
② __init__ 有导入期猴补丁,不必让打包进程沾上。
paths.py 顶层只 import os,独立加载是安全的。
"""
import importlib.util
path = os.path.join(ROOT, "core", "gripper", "paths.py")
spec = importlib.util.spec_from_file_location("_pack_lite_paths", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _assert_shell_matches_paths() -> None:
"""start_lite.sh 的 [错误 B] 资源清单必须与 paths.required_resources() 一致。
部署脚本那时还没装 venv、不能 import,只能把 7 条路径抄成 shell 字面量;
这个断言把「改了 paths.py 却忘了改脚本」(→ 客户机上缺资源却照常启动、
夹爪静默不可见)变成**打包当场失败**。
"""
shell_path = _dist_shell_source("start_lite.sh")
if shell_path is None:
print("[警告] 找不到 start_lite.sh,跳过 [错误 B] 清单核对")
return
text = open(shell_path, encoding="utf-8").read()
# 脚本里路径写的是变量($GRIPPER_NATIVE 等,为的是认 KSQ_* 环境变量);
# 未设环境变量时它们就取默认值,先把默认值代回去再比字面量
normalized = (text
.replace("$GRIPPER_NATIVE", "core/gripper/native")
.replace("$GRIPPER_SIGHTAC", "core/gripper/sightac_sdk"))
required = _load_paths_module().required_resources()
missing = [os.path.relpath(p, ROOT).replace(os.sep, "/")
for p, _ in required.values()
if os.path.relpath(p, ROOT).replace(os.sep, "/") not in normalized]
n_checks = text.count(" check_res ")
if missing or n_checks != len(required):
print(f"[失败] {os.path.basename(shell_path)} 的 [错误 B] 清单与 "
f"core/gripper/paths.py:required_resources() 不一致:")
for rel in missing:
print(f" 脚本里没有: {rel}")
print(f" check_res 条数 {n_checks} ≠ 资源项数 {len(required)}")
sys.exit(1)
print(f"[核对] start_lite.sh 的 [错误 B] 清单与 paths.py 一致({n_checks} 项)")
def _verify_payload(out_dir: str, target: str) -> None:
"""载荷完整性硬断言。
Linux 包:软链没被展开(R3)+ 7 项必需资源逐项就位。
Windows 包:代码照收、平台绑定件一个不留(含「零 ELF」这条硬不变量)——
于是「Windows 包」在语义上就是「Linux 包去掉载荷」,而这种形态在
Linux 开发机上可以直接造出来验证(mv 走 native/ 即可),不必真去
Windows 上撞。
"""
_assert_shell_matches_paths()
gr = os.path.join(out_dir, GRIPPER_REL)
if target != "linux":
for name in _PLATFORM_BOUND_NAMES:
if os.path.exists(os.path.join(gr, name)):
print(f"[失败] Windows 包不该含 {GRIPPER_REL}/{name}/")
sys.exit(1)
elf = _find_elf(gr)
if elf:
print(f"[失败] Windows 包含 ELF 文件 {len(elf)} 个:{elf[:5]}")
sys.exit(1)
absent = [f"core/gripper/{f}"
for f in ("__init__.py", "bridge.py", "paths.py",
"fays_runtime.py", "fays_single.py")
if not os.path.isfile(os.path.join(gr, f))]
if not os.path.isfile(os.path.join(out_dir, "core",
"gripper_codec.py")):
absent.append("core/gripper_codec.py")
for name in ("s80m_manager.py", "frame_gap.py"):
if not os.path.isfile(os.path.join(out_dir, "core", name)):
absent.append(f"core/{name}")
if absent:
print(f"[失败] Windows 包缺夹爪代码(两个包都收 .py): {absent}")
sys.exit(1)
print(f"[载荷] Windows 包:夹爪代码 {_count_py(gr)} 个 .py、零 ELF、"
f"无 {' / '.join(_PLATFORM_BOUND_NAMES)} —— "
f"那边的夹爪组框在、里面空着,属预期降级")
return
src = os.path.join(ROOT, GRIPPER_PAYLOAD_REL)
dst = os.path.join(out_dir, GRIPPER_PAYLOAD_REL)
if not os.path.isdir(dst):
print(f"[失败] Linux 包缺夹爪载荷 {GRIPPER_PAYLOAD_REL}/")
sys.exit(1)
n_src, n_dst = _count_symlinks(src), _count_symlinks(dst)
if n_src != n_dst:
print(f"[失败] 载荷软链被展开:源 {n_src} 个 → 包内 {n_dst} 个"
f"(copytree 必须传 symlinks=True)")
sys.exit(1)
size_mb = _tree_size(dst) / 1e6
size_src_mb = _tree_size(src) / 1e6
if size_mb > size_src_mb + 20:
print(f"[失败] 载荷体积异常:源 {size_src_mb:.0f}MB → 包内 {size_mb:.0f}MB")
sys.exit(1)
env = dict(os.environ, PYTHONPATH=out_dir, QT_QPA_PLATFORM="offscreen")
probe = ("import core.gripper.bridge, core.s80m_manager\n"
"from core.gripper import paths\n"
"missing = [k for k, (p, x) in paths.required_resources().items()\n"
" if not os.path.exists(p)\n"
" or (x and not os.access(p, os.X_OK))]\n"
"assert not missing, missing\n"
"assert paths.gripper_resources_available()\n"
"print('OK')")
proc = subprocess.run(
[sys.executable, "-c", "import os\n" + probe], cwd=out_dir, env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode != 0:
print("[失败] 包内夹爪资源自检未通过:")
print(proc.stderr.decode("utf-8", "replace"))
sys.exit(1)
print(f"[载荷] {GRIPPER_PAYLOAD_REL}/ {size_mb:.0f}MB、"
f"软链 {n_dst} 个原样保留、7 项必需资源全部可执行")
def collect(out_dir: str, with_wheels: bool, force: bool,
target: str) -> None:
out_dir = os.path.abspath(out_dir)
if force:
# 只重建代码路径;venv_lite/wheels/data 与分发壳原样保留;
# core/gripper/native(470MB)也不动 —— 它不由本脚本生成,
# 白删白拷一轮纯浪费(Windows 包目录里本来就没有它)
keep = os.path.join(out_dir, GRIPPER_PAYLOAD_REL)
for rel in (CODE_FILES + ["config", "core", "ui", "tools"]):
_force_remove(os.path.join(out_dir, rel), keep)
elif os.path.isfile(os.path.join(out_dir, "main_lite.py")):
print(f"[跳过] 目标目录已存在: {out_dir}")
print(" --force 只重建代码部分,venv_lite/wheels/data 与载荷会保留")
sys.exit(0)
copied = []
for rel in CODE_FILES + SELFTEST_FILES + (
TOOL_FILES_LINUX if target == "linux" else []):
src = os.path.join(ROOT, rel)
if not os.path.isfile(src):
print(f"[警告] 缺文件: {rel}")
continue
dst = os.path.join(out_dir, rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
copied.append(rel)
for rel in DIST_FILES:
src = _dist_shell_source(rel)
if src is None:
print(f"[警告] 缺分发文件: {rel}(应在仓库根或 lite_package/ 下)")
continue
dst = os.path.join(out_dir, rel)
if os.path.abspath(src) == os.path.abspath(dst):
copied.append(rel) # 源即目标(在 lite_package/ 内收集)
continue
shutil.copy2(src, dst)
copied.append(rel)
for rel in WHOLE_DIRS:
src = os.path.join(ROOT, rel)
dst = os.path.join(out_dir, rel)
if rel == GRIPPER_REL:
ignore = _gripper_ignore_factory(
src, windows=(target != "linux"),
# 目标包已有载荷(--force 重打包):native/ 整棵跳过,
# 只刷新 Python 代码 —— 也避开已存在软链的 File exists
keep_existing_native=os.path.isdir(
os.path.join(dst, "native")))
else:
ignore = shutil.ignore_patterns("__pycache__")
# symlinks=True 是硬要求(载荷里 76 个相对软链,展开 +330MB)
shutil.copytree(src, dst, symlinks=True, ignore=ignore,
dirs_exist_ok=True)
copied.append(rel + "/")
for sub, files in MODULE_FILES.items():
for name in files:
rel = os.path.join(sub, name)
src = os.path.join(ROOT, rel)
if not os.path.isfile(src):
print(f"[警告] 缺文件: {rel}")
continue
dst = os.path.join(out_dir, rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
copied.append(rel)
if with_wheels:
src = os.path.join(ROOT, "wheels")
if os.path.isdir(src):
shutil.copytree(src, os.path.join(out_dir, "wheels"))
copied.append("wheels/")
else:
print("[提示] --with-wheels 但仓库没有 wheels/ 目录,跳过")
# 自检:在目标目录内 import main_lite(与 start_lite.bat 错误 E 同口径)
env = dict(os.environ, PYTHONPATH=out_dir,
QT_QPA_PLATFORM=os.environ.get("QT_QPA_PLATFORM", "offscreen"))
proc = subprocess.run(
[sys.executable, "-c", "import main_lite"],
cwd=out_dir, env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode != 0:
print("[失败] 目标目录内 import main_lite 自检未通过:")
# 入口守卫(core/startup_guard.py)是往 **stdout** 打印指引再退出的,
# 只看 stderr 会把「缺哪个依赖」整个吞掉,只留一句无信息的失败。
# 自检必须用装了依赖的解释器跑(venv/bin/python),否则必然失败。
print(proc.stderr.decode("utf-8", "replace")
or proc.stdout.decode("utf-8", "replace")
or f"(无输出,退出码 {proc.returncode})")
sys.exit(1)
_verify_payload(out_dir, target)
total = sum(os.path.getsize(os.path.join(out_dir, r))
for r in copied if os.path.isfile(os.path.join(out_dir, r)))
if target == "linux":
payload = _tree_size(os.path.join(out_dir, GRIPPER_REL)) / 1e6
hint = (f" 客户机落盘 = 本目录 + 夹爪载荷 {payload:.0f}MB"
f" + venv_lite(自动安装, ~750MB) ≈ {payload / 1000 + 0.75:.2f}GB"
+ (" + wheels/" if with_wheels else ""))
else:
hint = (" 客户机落盘 = 本目录 + venv_lite(自动安装, ~750MB) ≈ 0.75GB"
"(Windows 包不含夹爪载荷 native/ 与 sightac_sdk/,"
"那边的夹爪组框在、里面空着)"
+ (" + wheels/" if with_wheels else ""))
print(f"[完成] 收集 {len(copied)} 个文件到 {out_dir}/,"
f"代码共 {total / 1024:.0f} KB,import main_lite 自检通过({target})")
print(hint)
def main() -> None:
parser = argparse.ArgumentParser(description="收集极简采集版发布包")
parser.add_argument("--out", default=os.path.join(ROOT, "lite_package"),
help="输出目录(默认仓库根 lite_package/)")
parser.add_argument("--target", default=None,
choices=("linux", "windows"),
help="目标平台(默认本机)。windows 包不含平台绑定件"
"(native/sightac_sdk),夹爪代码两个包同源")
parser.add_argument("--with-wheels", action="store_true",
help="连同 wheels/ 离线依赖包一起收集")
parser.add_argument("--force", action="store_true",
help="重建代码部分(保留 venv_lite/wheels/data/载荷)")
args = parser.parse_args()
target = args.target or ("windows" if os.name == "nt" else "linux")
collect(os.path.abspath(args.out), args.with_wheels, args.force, target)
if __name__ == "__main__":
main()