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:
qing committed 2026-08-14 22:13:06 +08:00
1 parent b73628b2e5
commit 35ee614ca9
8 files changed
+427 -14

No files matched your search

+3 -1
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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} 个路径(旧→新),"
+133
View File
@@ -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: