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
+3
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+43
-9
@@ -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} 个路径(旧→新),"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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。*
|
||||
+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