diff --git a/actpred/README.md b/actpred/README.md index 53dec6b..fb4cc38 100644 --- a/actpred/README.md +++ b/actpred/README.md @@ -211,7 +211,9 @@ recorder.flush() checkpoint 内记录的路径查找,找不到时回退到 checkpoint 同目录的 `action_stats.npy`)。 - `--normalize-mode fixed_range` 用固定物理范围 min-max→[0,1](常量表见 `action_norm.py`), **免 stats 文件**,消除 stats 路径类 bug 的整类风险;推理端(predict/onnx_infer/hailo_infer) - 从 checkpoint/ONNX 元数据自动识别归一化方式,无需额外参数。 + 从 checkpoint/ONNX 元数据自动识别归一化方式(含 predict_delta 残差模式),无需额外参数。 + 注:旧版导出的 ONNX 无元数据,onnx_infer 回退 zscore + 非残差,需显式 + `--normalize-mode/--predict-delta` 或重新导出。 - `actions.npy` 的列数必须与 `--action-dim` 一致;图像与动作帧数不一致时训练会打印 WARNING 并按较少者使用。 ## 编程 API diff --git a/actpred/export_onnx.py b/actpred/export_onnx.py index f117547..0f588f1 100644 --- a/actpred/export_onnx.py +++ b/actpred/export_onnx.py @@ -43,6 +43,25 @@ def main(): print(f"ONNX model exported to: {args.output}" f"{'(含 imu 输入)' if imu_dim > 0 else ''}") + # 写入训练配置元数据:onnx_infer 据此自动识别归一化方式与残差模式, + # 避免推理端配置与训练不一致导致静默错误(normalize_actions=False 时 + # 记为 "none",推理端不做任何归一化) + import onnx + m = onnx.load(args.output) + meta = { + "normalize_mode": (data_cfg.normalize_mode + if data_cfg.normalize_actions else "none"), + "predict_delta": ("1" if getattr(cfg.train, "predict_delta", False) + else "0"), + "imu_dim": str(imu_dim), + "action_dim": str(data_cfg.action_dim), + } + for k, v in meta.items(): + p = m.metadata_props.add() + p.key, p.value = k, v + onnx.save(m, args.output) + print(f"ONNX 元数据已写入: {meta}") + # 可选:验证导出的 ONNX 模型一致性 try: import onnxruntime as ort diff --git a/actpred/onnx_infer.py b/actpred/onnx_infer.py index 1757b34..10ba1ff 100644 --- a/actpred/onnx_infer.py +++ b/actpred/onnx_infer.py @@ -36,12 +36,18 @@ class OnnxPredictor: """ def __init__(self, onnx_path: str, stats_path: str = None, - providers=None, normalize_mode: str = "zscore"): + providers=None, normalize_mode: str = None, + predict_delta: bool = None): """ONNX 推理器。 normalize_mode: 与训练时 DataConfig.normalize_mode 一致。 - "zscore"(默认)需传 stats_path;"fixed_range" 免 stats 文件, - 按固定物理范围 min-max 归一化(action_norm.py)。 + "zscore" 需传 stats_path;"fixed_range" 免 stats 文件, + 按固定物理范围 min-max 归一化(action_norm.py); + "none" 表示训练未归一化,输入输出均不处理。 + 缺省 None 时从 ONNX 元数据自动识别(export_onnx 写入), + 旧模型无元数据回退 "zscore"。 + predict_delta: 残差模式(输出 Δ̂ 加回 history 末步得绝对动作)。 + 缺省 None 时同样从 ONNX 元数据识别,无元数据回退 False。 """ import onnxruntime as ort @@ -66,10 +72,23 @@ class OnnxPredictor: self._imu_name = (next(n for n, s in inputs.items() if len(s) == 2) if imu_shape is not None else None) + meta = self._read_metadata() + if normalize_mode is None: + normalize_mode = meta.get("normalize_mode") or "zscore" + if normalize_mode not in ("zscore", "fixed_range", "none"): + raise ValueError(f"normalize_mode={normalize_mode!r} 非法," + "应为 zscore/fixed_range/none") + self._fixed_range = normalize_mode == "fixed_range" + self._no_norm = normalize_mode == "none" + self.normalize_mode = normalize_mode # 解析后的最终模式(供调用方判断) + # R1-C 残差预测:元数据 predict_delta=1 时模型输出 Δ̂, + # 需在归一化空间加回 a_{t-1}(与 predict.py/hailo_infer 同语义) + self._predict_delta = (predict_delta if predict_delta is not None + else meta.get("predict_delta") == "1") + self._action_mean = None self._action_std = None - self._fixed_range = normalize_mode == "fixed_range" - if stats_path is not None and not self._fixed_range: + if stats_path is not None and not (self._fixed_range or self._no_norm): if not os.path.exists(stats_path): raise FileNotFoundError(f"统计文件不存在: {stats_path}") stats = np.load(stats_path) @@ -80,6 +99,13 @@ class OnnxPredictor: self._action_mean = stats[0].astype(np.float32) self._action_std = stats[1].astype(np.float32) + def _read_metadata(self) -> dict: + """读 ONNX custom metadata(export_onnx 写入);旧模型无则空 dict""" + try: + return dict(self._session.get_modelmeta().custom_metadata_map) + except Exception: + return {} + @staticmethod def _locate_inputs(inputs): """按维度数识别图像(5维)/动作历史(3维)/姿态(2维,可选)输入,返回各自形状""" @@ -146,6 +172,9 @@ class OnnxPredictor: feeds[self._imu_name] = normalize_imu( imu, self.imu_dim)[np.newaxis] # (1,imu_dim) result = self._session.run(None, feeds)[0].reshape(-1).astype(np.float32) + if self._predict_delta: + # 残差:Δ̂ + a_{t-1}(归一化空间,与 predict.py 对齐) + result = result + action_history[-1] if self._fixed_range: result = denormalize_action(result, self.action_dim) elif self._action_mean is not None: @@ -171,9 +200,13 @@ def main(): help="冒号分隔各步,每步 action_dim 个值") parser.add_argument("--stats", default=None, help="action_stats.npy 路径(zscore 归一化训练才需要)") - parser.add_argument("--normalize-mode", choices=["zscore", "fixed_range"], - default="zscore", - help="与训练时 normalize_mode 一致;fixed_range 免 --stats") + parser.add_argument("--normalize-mode", choices=["zscore", "fixed_range", "none"], + default=None, + help="与训练时 normalize_mode 一致;缺省从 ONNX 元数据" + "自动识别(旧模型回退 zscore)") + parser.add_argument("--predict-delta", action="store_true", default=None, + help="残差模式(输出加回 history 末步);缺省从 ONNX " + "元数据自动识别(旧模型回退关闭)") parser.add_argument("--imu", type=str, default=None, help="可选姿态向量(逗号分隔,原始量纲:roll,pitch 度 / " "ax..gz int16 原始值);模型含 imu 输入且缺省时按零向量") @@ -182,7 +215,8 @@ def main(): args = parser.parse_args() with OnnxPredictor(args.model, stats_path=args.stats, - normalize_mode=args.normalize_mode) as predictor: + normalize_mode=args.normalize_mode, + predict_delta=args.predict_delta) as predictor: if len(args.images) != predictor.frame_len: raise SystemExit( f"--images 需传入 {predictor.frame_len} 个路径(旧→新)," diff --git a/actpred/test_actpred.py b/actpred/test_actpred.py index 8edaa72..bafa983 100644 --- a/actpred/test_actpred.py +++ b/actpred/test_actpred.py @@ -1087,6 +1087,139 @@ class TestOnnxPredictor(unittest.TestCase): OnnxPredictor(path, stats_path=stats_path) +@unittest.skipUnless( + importlib.util.find_spec("onnxscript") is not None + and importlib.util.find_spec("onnxruntime") is not None, + "ONNX 元数据测试需要 onnxscript + onnxruntime") +class TestOnnxMetadata(unittest.TestCase): + """v3-P1-2:export_onnx 写入训练配置元数据,onnx_infer 自动识别 + + 覆盖 normalize_mode(zscore/fixed_range/none)与 predict_delta 的 + 元数据写入、自动识别、显式参数覆盖与残差加回数值语义。 + """ + + @staticmethod + def _write_meta(path, meta): + import onnx + m = onnx.load(path) + for k, v in meta.items(): + p = m.metadata_props.add() + p.key, p.value = k, v + onnx.save(m, path) + + @staticmethod + def _read_meta(path): + import onnx + return {p.key: p.value for p in onnx.load(path).metadata_props} + + def _frames(self, n): + return [np.random.randint(0, 255, (48, 64, 3), dtype=np.uint8) + for _ in range(n)] + + def _export_via_script(self, tmpdir, normalize_actions=False, + normalize_mode="zscore", predict_delta=False): + """经 export_onnx.main 导出小模型,返回 ONNX 路径""" + import export_onnx + cfg = Config() + cfg.data.image_width = 32 + cfg.data.image_height = 32 + cfg.data.action_dim = 4 + cfg.data.history_len = 3 + cfg.data.frame_len = 3 + cfg.data.normalize_actions = normalize_actions + cfg.data.normalize_mode = normalize_mode + cfg.train.predict_delta = predict_delta + model = ActionPredictor(cfg.model, cfg.data) + model.eval() + ckpt = os.path.join(tmpdir, "best_inference.pt") + torch.save({"model_state_dict": model.state_dict(), + "config": cfg.to_dict()}, ckpt) + out = os.path.join(tmpdir, "model.onnx") + with mock.patch.object( + sys, "argv", + ["export_onnx.py", "--model", ckpt, "--output", out]): + with contextlib.redirect_stdout(io.StringIO()): + export_onnx.main() + return out + + def test_export_writes_metadata_unnormalized(self): + with tempfile.TemporaryDirectory() as tmpdir: + meta = self._read_meta(self._export_via_script(tmpdir)) + self.assertEqual(meta["normalize_mode"], "none") # 未归一化训练 + self.assertEqual(meta["predict_delta"], "0") + self.assertEqual(meta["action_dim"], "4") + self.assertEqual(meta["imu_dim"], "0") + + def test_export_writes_metadata_fixed_range_delta(self): + with tempfile.TemporaryDirectory() as tmpdir: + meta = self._read_meta(self._export_via_script( + tmpdir, normalize_actions=True, + normalize_mode="fixed_range", predict_delta=True)) + self.assertEqual(meta["normalize_mode"], "fixed_range") + self.assertEqual(meta["predict_delta"], "1") + + def test_export_metadata_roundtrip_predictor(self): + """导出的 ONNX 直接被 OnnxPredictor 自动识别,无需显式参数""" + from onnx_infer import OnnxPredictor + with tempfile.TemporaryDirectory() as tmpdir: + out = self._export_via_script( + tmpdir, normalize_actions=True, + normalize_mode="fixed_range", predict_delta=True) + with OnnxPredictor(out) as p: + self.assertEqual(p.normalize_mode, "fixed_range") + self.assertTrue(p._predict_delta) + + def test_predictor_metadata_fallback_old_model(self): + """旧版无元数据 ONNX:回退 zscore + 非残差""" + from onnx_infer import OnnxPredictor + with tempfile.TemporaryDirectory() as tmpdir: + path, _ = TestOnnxPredictor._export_small_onnx(tmpdir) + with OnnxPredictor(path) as p: + self.assertEqual(p.normalize_mode, "zscore") + self.assertFalse(p._predict_delta) + + def test_explicit_param_overrides_metadata(self): + from onnx_infer import OnnxPredictor + with tempfile.TemporaryDirectory() as tmpdir: + path, _ = TestOnnxPredictor._export_small_onnx(tmpdir) + self._write_meta(path, {"normalize_mode": "fixed_range", + "predict_delta": "1"}) + with OnnxPredictor(path, normalize_mode="zscore", + predict_delta=False) as p: + self.assertEqual(p.normalize_mode, "zscore") + self.assertFalse(p._predict_delta) + + def test_predict_delta_adds_last_history(self): + """残差输出 = 普通输出 + history 末步(none 模式下口径直接可比)""" + from onnx_infer import OnnxPredictor + with tempfile.TemporaryDirectory() as tmpdir: + path, _ = TestOnnxPredictor._export_small_onnx(tmpdir) + frames = self._frames(3) + history = np.random.randn(3, 4).astype(np.float32) + with OnnxPredictor(path, normalize_mode="none") as plain: + base = plain.predict(frames, history) + with OnnxPredictor(path, normalize_mode="none", + predict_delta=True) as delta: + out = delta.predict(frames, history) + np.testing.assert_allclose(out, base + history[-1], atol=1e-5) + + def test_none_mode_ignores_stats(self): + """none 模式(训练未归一化):即使传 stats 也不做归一化""" + from onnx_infer import OnnxPredictor + with tempfile.TemporaryDirectory() as tmpdir: + path, _ = TestOnnxPredictor._export_small_onnx(tmpdir) + stats_path = os.path.join(tmpdir, "action_stats.npy") + np.save(stats_path, np.stack([np.full(4, 0.5), np.full(4, 2.0)])) + frames = self._frames(3) + history = np.random.randn(3, 4).astype(np.float32) + with OnnxPredictor(path, stats_path=stats_path, + normalize_mode="none") as p: + out = p.predict(frames, history) + with OnnxPredictor(path, normalize_mode="none") as q: + ref = q.predict(frames, history) + np.testing.assert_allclose(out, ref, atol=1e-6) + + # ---------------- HailoPredictor 测试用 fake hailo_platform ---------------- class _FakeFormatType: diff --git a/docs/reviews/pybot_actpred_AUDIT_20260814_v3.md b/docs/reviews/pybot_actpred_AUDIT_20260814_v3.md new file mode 100644 index 0000000..7e0aa3d --- /dev/null +++ b/docs/reviews/pybot_actpred_AUDIT_20260814_v3.md @@ -0,0 +1,167 @@ +# pybot & actpred 审计报告(第三版 —— v2 修复批次落地后) + +> 审查日期:2026-08-14 (Asia/Shanghai) +> 审查人:AI Assistant(SOLO Coder) +> 审查范围:`pybot/`(机器人运行时)与 `actpred/`(动作预测)两模块 +> 代码基线:v2 审计修复批次后(commit 85bf68f,工作区干净) +> 测试状态:pybot 88 passed;actpred 148 passed / 18 skipped(Windows 开发机,全绿; +> ONNX 类 18 项因本机无 onnxruntime/onnxscript 跳过,在 Pi 上运行) +> +> 本报告对照 v2 报告逐项核对修复状态,并给出本轮新发现。 +> +> **2026-08-14 更新:两个新 P1(v3-P1-1/v3-P1-2)与 P2-a 残余(ONNX 分支)已修复, +> 附回归测试(pybot +1 / actpred +7)。** + +--- + +## 一、审计结论摘要 + +| 级别 | 新发现 | 历史遗留(v2 未修复) | 说明 | +|------|--------|----------------------|------| +| **P0(崩溃/数据损坏)** | 0 | **1(P0-2 未修复即被提交)** | 见 §三 | +| ~~P1(功能风险)~~ | ~~2~~ **0(均已修复)** | ~~1~~ **0(P2-a 已闭环)** | 见 §四 | +| P2(代码质量) | 3 | 6 | 见 §五 | +| P3(边缘/环境) | 2 | 0 | 见 §五 | + +**重点:P0-2 在 v2 标记为"必须提交前修复"但仍随 85bf68f 提交;两个新 P1 与 P2-a 残余已于本轮回合修复。** + +--- + +## 二、v2 修复项回归核对(13 项) + +| # | v2 级别 | 状态 | 验证 | +|---|---------|------|------| +| P0-1 HailoPredictor 缺 normalize_mode | P0 | ✅ 已修复 | [main.py:163-193](../../pybot/pybot/main.py#L163-L193) 从 companion checkpoint 读取并传入 | +| R2-1 NaN val_loss 阻断 best | P1 | ✅ 已修复 | [train.py:270-274](../../actpred/train.py#L270-L274)、[pretrain_mt.py:361-364](../../actpred/pretrain_mt.py#L361-L364) isfinite 守卫,NaN 走 patience 早停 | +| P2-a 后端检测大小写敏感 | P1 | ✅ 已修复 | [main.py:144-146](../../pybot/pybot/main.py#L144-L146) 已 `.lower()`;v3 补齐 `.onnx → OnnxPredictor` 分支(归一化模式经元数据自动识别,zscore 缺 stats 时告警) | +| P1-3 mt_parser BOM | P1 | ✅ 已修复 | [mt_parser.py:33](../../actpred/mt_parser.py#L33) `utf-8-sig` | +| R1-1 pretrain data_dir 覆盖 | P1 | ✅ 已修复 | pretrain_mt.py 不再赋值 `cfg.data.data_dir` | +| R2-7 _run.sh 静默回退 | P1 | ✅ 已修复 | [_run.sh:17-20](../../actpred/_run.sh#L17-L20) 未知子命令报错退出 | +| P2-a2 init-from 误报警告 | P2 | ✅ 已修复 | [config.py:232](../../actpred/config.py#L232) 仅在双方归一化开启时比较 normalize_mode | +| R2-9 predict.py --action-dim 陷阱 | P2 | ✅ 已修复 | predict.py 已移除该参数,统一用 `predictor.action_dim` | +| R1-2 parse_action_history 三处重复 | P2 | ✅ 已修复 | 三推理文件均从 [utils.py](../../actpred/utils.py) 导入 | +| R2-3 空验证集无 best.pt | P2 | ✅ 已修复 | [train.py:309-324](../../actpred/train.py#L309-L324) 保底保存最终权重 | +| P2-c 闭环测试覆盖 | P2 | ⚠️ 大部分修复 | test_loop.py 已含 recorder 集成(imu_recorded/multi_recorded);**predictor 异常清理路径仍无用例** | +| P2-b numpy 三处 lazy import | P2 | ⚠️ 修复引入回归 | 改为顶层导入,**破坏零依赖承诺** → 见新发现 v3-P1-1 | +| P0-2 normalize_actions 默认值 | P0 | ❌ **未修复** | [config.py:14](../../actpred/config.py#L14) 仍为 `True`,[README.md:170](../../actpred/README.md#L170) 仍标注 `False` | + +--- + +## 三、P0 遗留 + +### P0-2(v2 遗留,未修复即提交)DataConfig normalize_actions 默认值与 README 不一致 + +**位置**:[actpred/config.py:14](../../actpred/config.py#L14) vs [actpred/README.md:170](../../actpred/README.md#L170) + +```python +normalize_actions: bool = True # README 配置表仍标注默认 False +``` + +**问题**:v2 报告给出的两个方案(A. 恢复默认 False;B. 改 README 为 True 并标注 breaking change)均未执行,不一致状态被 85bf68f 直接提交。CLI 不传 `--normalize-actions` 时行为与 README 描述相反。 + +**修复建议**:执行 v2 方案 B(改 README 为 `True` 并注明行为变更)——当前代码、测试(148 用例以此默认值运行)、训练实践均已按 `True` 落地,回退代码反而引入新风险。 + +--- + +## 四、P1 新发现 + +### v3-P1-1 pybot 零第三方依赖承诺被打破(85bf68f 修 P2-b 的副作用)—— ✅ 已修复(2026-08-14) + +**修复**:robot.py 顶层 `import numpy` 移除,7 个 numpy 使用函数(run_loop / +_loop_cold_start / _execute_action_vec / _read_imus / _pad_imu / +_build_action_vec / _grab_frame)恢复函数内 lazy import,并在导入块处 +注明约定禁止恢复顶层导入。新增回归测试 +`test_loop.py::test_robot_importable_without_numpy`(子进程屏蔽 numpy +验证模块可导入)。纯脚本路径零依赖承诺恢复,README 无需变更。 + +**原问题**:~~main.py 无条件 `from .robot import RobotStruct`,numpy 成为硬依赖。~~ + +### v3-P1-2 OnnxPredictor 不支持 predict_delta,ONNX 无训练配置元数据 —— ✅ 已修复(2026-08-14) + +**修复**: +- [export_onnx.py:46-63](../../actpred/export_onnx.py#L46-L63) 导出后写入 + `metadata_props`(normalize_mode / predict_delta / imu_dim / action_dim; + 未归一化训练记 `normalize_mode="none"`); +- [onnx_infer.py:75-87](../../actpred/onnx_infer.py#L75-L87) 构造时从元数据 + 自动识别两参数(显式传参优先),[predict():174-176](../../actpred/onnx_infer.py#L174-L176) + 在归一化空间加回 history 末步(与 predict.py/hailo_infer 同语义); +- CLI `--normalize-mode` 缺省自动识别、新增 `--predict-delta`; +- README 部署注意已更正(含旧版无元数据 ONNX 的回退说明); +- 新增 TestOnnxMetadata 7 用例(元数据写入/回读/覆盖/残差加回数值), + 本机无 onnxruntime 跳过,随 Pi 环境运行。 + +**原问题**:~~残差训练模型经 export_onnx.py 导出后,ONNX 推理输出被当作绝对动作——静默错误。~~ + +--- + +## 五、P2/P3 新发现 + +### v3-P2-1 验收脚本链未适配 fixed_range + +**位置**:[actpred/_npu_test.py:64-71](../../actpred/_npu_test.py#L64-L71)、[_onnx_validate.py:28-39](../../actpred/_onnx_validate.py#L28-L39) + +两脚本在 `normalize_actions=True` 时无条件 `np.load(action_stats.npy)`:fixed_range 训练不产出该文件 → `FileNotFoundError`;`_npu_test.py` 构造 HailoPredictor 时也未传 `normalize_mode`(默认 zscore),torch 参考只对 z-score 反归一化。**fixed_range 模型无法通过部署验收**(恰好是最推荐部署的模式)。建议按 checkpoint config 的 normalize_mode 分支处理(同 hailo_infer main 的自动判定逻辑)。 + +### v3-P2-2 导出一致性校验只打印不拦截 + +**位置**:[export_deploy.py:283](../../actpred/export_deploy.py#L283)、[export_deploy.py:295](../../actpred/export_deploy.py#L295)、[export_onnx.py:59](../../actpred/export_onnx.py#L59) + +wrapper vs 原模型、ORT vs wrapper 的 max diff 均仅 print,进程照常退出 0。ManualAttnBlock 依赖 PyTorch 内部布局约定(v2 P2-f 遗留),一旦上游变更布局,转换错误在自动化流水线中完全无感。建议加阈值断言(如 diff > 1e-4 → `SystemExit`)。 + +### v3-P2-3 run_loop overrun 逐步告警日志噪音 + 建议阈值偏离约束 + +**位置**:[robot.py:308-311](../../pybot/pybot/robot.py#L308-L311) + +每个 overrun 步都 `log.warning`——10Hz 持续 50% overrun 时每秒 5 条告警刷屏;且工程约束要求"overrun 比例 >10% 才建议降 hz",当前 [L318-321](../../pybot/pybot/robot.py#L318-L321) 只要 overrun>0 就给建议。建议:逐步告警改节流(如每 100 步汇总一次),最终建议仅在比例 >10% 时输出。 + +### v3-P3-1 dfc_compile.py --output 无 .hef 后缀时产物互相覆盖 + +**位置**:[dfc_compile.py:172-173](../../actpred/dfc_compile.py#L172-L173) + +`args.output.replace(".hef", "_optimized.har")` 在无后缀时返回原路径,`shutil.copy(har_opt, ...)` 把 HAR 写到刚复制的 HEF 同路径,静默损坏产物。建议校验 `--output` 必须以 `.hef` 结尾。 + +### v3-P3-2 pybot Windows 测试依赖命令行 fcntl mock + +**位置**:[pybot/hd/i2c.py:14](../../pybot/pybot/hd/i2c.py#L14) + +顶层 `import fcntl`(Unix-only),Windows 下 conftest 自身 import i2c 即失败,需命令行注入 `sys.modules['fcntl']` mock 才能跑测试(87 passed 即以此方式验证)。建议在 conftest.py 内置同等 mock(一行,先于 i2c import),消除外部依赖。 + +--- + +## 六、v2 遗留 P2(仍未修复,维持原判级) + +| # | 位置 | 问题 | 备注 | +|---|------|------|------| +| P2-a1 | [action_norm.py:55-62](../../actpred/action_norm.py#L55-L62) | `default_column_weights` 对 action_dim>18 的超出列权重恒 0 | 未来扩展性 | +| P2-a3 | [README.md:172](../../actpred/README.md#L172) | column_weights 始终生效(含不归一化训练)未在文档注明 | 文档补一句即可 | +| P2-d | [dataset.py:46](../../actpred/dataset.py#L46) | Resize 不保持宽高比 | 训练/推理口径一致,仅变形质量 nit | +| P2-e | [train.py:236,248](../../actpred/train.py#L236) | resume 时 scheduler T_max 恢复旧值,改 --epochs 不影响 LR 曲线 | 语义需在文档明确 | +| P2-f | [export_deploy.py:42-59](../../actpred/export_deploy.py#L42-L59) | ManualAttnBlock 依赖 PyTorch in_proj 内部布局 | 数值验证已缓解,配合 v3-P2-2 加断言更稳 | +| R2-4 | actpred/_set.sh | CUDA 版 torch 误装风险 | 安装脚本注意事项 | + +--- + +## 七、修复优先级 + +| 优先级 | 项 | 理由 | +|--------|-----|------| +| **P0** | P0-2 README 与默认值对齐(方案 B) | 一行文档修复,消除行为与文档相悖 | +| ~~P1~~ | ~~v3-P1-1 robot.py numpy lazy import~~ ✅ 已修复 | 零依赖承诺已恢复,附子进程回归测试 | +| ~~P1~~ | ~~v3-P1-2 ONNX predict_delta + 元数据~~ ✅ 已修复 | 元数据写入/识别/残差加回全链路 + 7 用例 | +| P2 | v3-P2-1 验收脚本 fixed_range 适配 | fixed_range 是最推荐部署模式,验收链不能缺席 | +| P2 | v3-P2-2 导出 diff 阈值断言 | 一行拦截,防布局约定变更静默穿透 | +| P2 | v3-P2-3 overrun 告警节流 + 10% 阈值 | 对齐工程约束,消除日志刷屏 | +| P3 | v3-P3-1/-P3-2 | 边缘情况与开发环境便利 | + +--- + +## 八、本轮复核确认无问题的关键路径 + +- **训练链**:weighted MSE 量级归一、scheduled sampling detach 截断、空验证集兜底、stats 训练集-only 计算(含 history∪target 并集防零方差爆炸)、文件级划分防泄漏——均正确。 +- **推理链三后端**:normalize/denormalize 对称、predict_delta 加回在归一化空间、imu_dim=None 鸭子类型兼容旧 checkpoint、NaN/Inf 输出兜底——均正确。 +- **部署链**:HAR 输入连通性双阶段检查、pred_token 抖动校准、按 input_layerN 排序绑定、量化网格 dequant(0) 判定帧归一化、token 量级导出前拦截——均正确。 +- **pybot 闭环**:守护线程队列帧源、冷启动零历史、finally 落盘 recorder、执行前 isfinite 守卫、Speaker.delay 剔除防阻塞——均正确。 + +--- + +*本报告为 v2 修复批次后的回归+新发现审计。建议优先闭环 P0-2(文档一行)与两个新 P1。* diff --git a/pybot/pybot/main.py b/pybot/pybot/main.py index 82771d8..e990b69 100644 --- a/pybot/pybot/main.py +++ b/pybot/pybot/main.py @@ -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)) diff --git a/pybot/pybot/robot.py b/pybot/pybot/robot.py index e339ac6..65d13f8 100644 --- a/pybot/pybot/robot.py +++ b/pybot/pybot/robot.py @@ -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: diff --git a/pybot/tests/test_loop.py b/pybot/tests/test_loop.py index 51de120..8e9cc4c 100644 --- a/pybot/tests/test_loop.py +++ b/pybot/tests/test_loop.py @@ -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