fix(pybot×actpred): v3 审计 P1 修复 3 项——零依赖承诺 + ONNX 元数据/残差全链路
- robot.py numpy 恢复函数级 lazy import,纯 .mt 脚本执行不再需要 numpy(v3-P1-1) - export_onnx 写入训练配置元数据;onnx_infer 自动识别 normalize_mode/predict_delta 并支持残差加回(v3-P1-2) - pybot --loop 补 .onnx 后端分支,zscore 缺 action_stats.npy 时告警(P2-a 闭环) - 回归测试:actpred TestOnnxMetadata +7、pybot 零依赖导入 +1(88/148 全绿) - 附 v3 审计报告:13 项 v2 修复回归核对 + 7 项新发现(P0-2 遗留 + P2/P3 六项)
This commit is contained in:
1 parent
b73628b2e5
commit
35ee614ca9
8 files changed
+427
-14
No files matched your search
+21
-2
@@ -132,16 +132,35 @@ def _load_predictor(checkpoint):
|
||||
"""进程内构建 actpred 推理器;失败返回 None
|
||||
|
||||
pybot 不硬依赖 actpred:仅 --loop 时才尝试 import,actpred 不在
|
||||
sys.path 时给出可操作提示。checkpoint 目录名决定后端(.hef → Hailo
|
||||
NPU,否则 → PyTorch),推理类由 actpred 提供。
|
||||
sys.path 时给出可操作提示。checkpoint 后缀决定后端(.hef → Hailo NPU,
|
||||
.onnx → ONNX Runtime,否则 → PyTorch),推理类由 actpred 提供。
|
||||
|
||||
HEF 后端会自动查找同目录下的 pred_token.npy 和 companion checkpoint
|
||||
(pretrain_best_inference.pt / best_inference.pt),以恢复 pred_token
|
||||
默认值和 predict_delta 训练配置——否则 Hailo 推理输出与训练语义不一致。
|
||||
ONNX 后端的 normalize_mode/predict_delta 由模型元数据自动识别。
|
||||
"""
|
||||
import os
|
||||
try:
|
||||
lower = checkpoint.lower()
|
||||
if lower.endswith(".onnx"):
|
||||
from actpred.onnx_infer import OnnxPredictor as _P
|
||||
onnx_dir = os.path.dirname(os.path.abspath(checkpoint))
|
||||
# stats_path:zscore 归一化模型的 action_stats.npy
|
||||
stats_path = None
|
||||
sp = os.path.join(onnx_dir, "action_stats.npy")
|
||||
if os.path.exists(sp):
|
||||
stats_path = sp
|
||||
try:
|
||||
pred = _P(checkpoint, stats_path=stats_path)
|
||||
except Exception:
|
||||
logging.exception("加载 ONNX 推理器失败: %s", checkpoint)
|
||||
return None
|
||||
if pred.normalize_mode == "zscore" and stats_path is None:
|
||||
logging.warning("ONNX 元数据为 zscore 归一化但同目录无 "
|
||||
"action_stats.npy,动作历史将不归一化: %s",
|
||||
checkpoint)
|
||||
return pred
|
||||
if lower.endswith(".hef"):
|
||||
from actpred.hailo_infer import HailoPredictor as _P
|
||||
hef_dir = os.path.dirname(os.path.abspath(checkpoint))
|
||||
|
||||
+10
-2
@@ -14,8 +14,9 @@ import logging
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
# 零第三方依赖约定:numpy 仅在 --record/--loop 路径的函数内 lazy import,
|
||||
# 纯 .mt 脚本执行(python3 -m pybot scripts/std.mt)不需要 numpy。
|
||||
# 新增 numpy 使用点时必须遵循同一约定,禁止恢复顶层导入。
|
||||
from . import plugin
|
||||
from .hardvars import (AliasVar, CameraVar, I2CVar, MPU6050Var, ServoVar,
|
||||
SpeakerVar)
|
||||
@@ -240,6 +241,7 @@ class RobotStruct:
|
||||
(会阻塞 ~1/framerate 秒,把 hz 锁死在相机帧率之下)。dry_run
|
||||
或队列空时回退 _grab_frame 合成帧。
|
||||
"""
|
||||
import numpy as np
|
||||
camera_var = self._find_camera()
|
||||
if camera_var is None and not self.dry_run:
|
||||
raise ValueError("闭环需要 @Camera 块(或使用 --dry-run 生成合成帧)")
|
||||
@@ -358,6 +360,7 @@ class RobotStruct:
|
||||
帧源与主循环一致(_loop_recent_frames),避免冷启动与运行期帧源
|
||||
不一致。history 用零向量填满是保守初值,避免随机姿态跳变损坏舵机。
|
||||
"""
|
||||
import numpy as np
|
||||
frames.extend(self._loop_recent_frames(camera_var, frames.maxlen))
|
||||
for _ in range(history.maxlen):
|
||||
history.append(np.zeros(action_dim, dtype=np.float32))
|
||||
@@ -368,6 +371,7 @@ class RobotStruct:
|
||||
SpeakerVar.play 强制要求 delay 键;闭环已剔除 delay 列(避免发声
|
||||
阻塞卡死节拍),此处为其补 delay=0 —— 发声但不阻塞,节拍由 hz 掌控。
|
||||
"""
|
||||
import numpy as np
|
||||
if not np.all(np.isfinite(act)):
|
||||
log.warning("action vector contains NaN/Inf, skipping step")
|
||||
return
|
||||
@@ -439,6 +443,7 @@ class RobotStruct:
|
||||
单个传感器读失败其 8 槽记零并 log。latest() 冷启动返回全零
|
||||
dict,语义安全无需特判。
|
||||
"""
|
||||
import numpy as np
|
||||
if not imu_vars:
|
||||
return None
|
||||
nk = len(IMU_KEYS)
|
||||
@@ -456,6 +461,7 @@ class RobotStruct:
|
||||
@staticmethod
|
||||
def _pad_imu(vec, imu_dim):
|
||||
"""拼接姿态向量 → pad/truncate 到 predictor 的 imu_dim(None → 全零)"""
|
||||
import numpy as np
|
||||
out = np.zeros(imu_dim, dtype=np.float32)
|
||||
if vec is not None:
|
||||
src = np.asarray(vec, dtype=np.float32).ravel()
|
||||
@@ -465,6 +471,7 @@ class RobotStruct:
|
||||
|
||||
def _build_action_vec(self, vals, action_dim):
|
||||
"""从值列表构建动作向量,pad/truncate 到 action_dim"""
|
||||
import numpy as np
|
||||
vec = np.zeros(action_dim, dtype=np.float32)
|
||||
n = min(len(vals), action_dim)
|
||||
vec[:n] = vals[:n]
|
||||
@@ -472,6 +479,7 @@ class RobotStruct:
|
||||
|
||||
def _grab_frame(self, camera_var):
|
||||
"""从摄像头同步抓一帧;dry_run 或无帧时返回随机噪声帧"""
|
||||
import numpy as np
|
||||
if camera_var is not None:
|
||||
frame = camera_var.camera.capture()
|
||||
if frame is not None:
|
||||
|
||||
@@ -458,3 +458,34 @@ def test_loop_imu_multi_recorded(mock_i2c, tmp_path):
|
||||
assert imus.shape == (3, 16)
|
||||
assert np.allclose(imus[:, 0], 1.0) # sensor0 roll
|
||||
assert np.allclose(imus[:, 9], 4.0) # sensor1 pitch
|
||||
|
||||
|
||||
def test_robot_importable_without_numpy():
|
||||
"""零依赖承诺(v3-P1-1):屏蔽 numpy 后 pybot.robot 仍可导入
|
||||
|
||||
纯 .mt 脚本执行路径不得要求 numpy——numpy 仅在 --record/--loop
|
||||
函数内 lazy import。子进程隔离避免污染当前 pytest 会话。
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
child = (
|
||||
"import sys, importlib.util\n"
|
||||
"if importlib.util.find_spec('fcntl') is None:\n" # Windows 开发机
|
||||
" from types import ModuleType\n"
|
||||
" _m = ModuleType('fcntl'); _m.ioctl = lambda *a, **k: 0\n"
|
||||
" sys.modules['fcntl'] = _m\n"
|
||||
"import builtins\n"
|
||||
"_real = builtins.__import__\n"
|
||||
"def _fake(name, *a, **k):\n"
|
||||
" if name == 'numpy' or name.startswith('numpy.'):\n"
|
||||
" raise ImportError('numpy blocked by test')\n"
|
||||
" return _real(name, *a, **k)\n"
|
||||
"builtins.__import__ = _fake\n"
|
||||
"import pybot.robot\n"
|
||||
"print('ok')\n"
|
||||
)
|
||||
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
r = subprocess.run([sys.executable, "-c", child],
|
||||
capture_output=True, text=True, cwd=repo)
|
||||
assert r.returncode == 0 and "ok" in r.stdout, r.stderr
|
||||
Reference in new issue
Block a user