Files
blackbird/actpred/onnx_infer.py
T
qing 1cf428f384 feat(actpred): log_range 归一化——spk 两列对数尺度修正精度错配
fixed_range 线性 min-max 下常用值挤在低端(10Hz→0.002),闭环反馈
中小绝对误差=大相对误差(模型 C/D 发散根因)。新增 normalize_mode=
log_range:spk.F/spk.delay 用 log10(1+x) 对数尺度(10Hz→0.27),
舵机列保持线性。全链路接入(dataset/pretrain/predict/onnx/hailo/
calibration/export),与 v3 审计 none 模式/元数据自动识别合并。
2026-08-14 21:38:14 +00:00

246 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""actpred ONNX Runtime 推理封装 —— CPU 后端(Pi 5 实测 5.9ms/次,PyTorch 的 ~5 倍)
接口对齐 predict.ActionPredictorInference:predict(images, action_history, imu=None)。
模型维度从 ONNX session 输入元数据动态推导(images: [1,F,3,H,W],
action_history: [1,H,D];R2 imu_dim>0 的模型另有 imu: [1,imu_dim]),
无需 checkpoint。图像预处理复现训练时的 dataset.py 流水线:
Resize → /255 → ImageNet 归一化。
背景见 docs/reports/actpred_inference_speed_benchmark_20260813.md。
"""
import argparse
import json
import os
import numpy as np
from PIL import Image
from imu_norm import normalize_imu
from action_norm import (normalize_action, denormalize_action,
is_range_mode)
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
from utils import parse_action_history
class OnnxPredictor:
"""ONNX Runtime 动作预测推理器(CPU 后端)
用法:
with OnnxPredictor("model.onnx") as predictor:
action = predictor.predict(images, action_history)
"""
def __init__(self, onnx_path: str, stats_path: str = None,
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);
"none" 表示训练未归一化,输入输出均不处理。
缺省 None 时从 ONNX 元数据自动识别(export_onnx 写入),
旧模型无元数据回退 "zscore"。
predict_delta: 残差模式(输出 Δ̂ 加回 history 末步得绝对动作)。
缺省 None 时同样从 ONNX 元数据识别,无元数据回退 False。
"""
import onnxruntime as ort
if providers is None:
providers = ["CPUExecutionProvider"]
ort.set_default_logger_severity(3) # 抑制 GPU 探测等无害警告
opts = ort.SessionOptions()
opts.log_severity_level = 3
self._session = ort.InferenceSession(
str(onnx_path), sess_options=opts, providers=providers)
inputs = {i.name: [d if isinstance(d, int) else -1
for d in i.shape] for i in self._session.get_inputs()}
img_shape, hist_shape, imu_shape = self._locate_inputs(inputs)
_, self.frame_len, _, self.image_height, self.image_width = img_shape
_, self.history_len, self.action_dim = hist_shape
self._image_name = next(n for n, s in inputs.items() if len(s) == 5)
self._history_name = next(n for n, s in inputs.items() if len(s) == 3)
# R2 方案一:2 维输入 [1, imu_dim] 为姿态(无则 None)。
# pybot 闭环鸭子类型约定:None 时按旧两参调用。
self.imu_dim = imu_shape[-1] if imu_shape is not None else None
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",
"log_range"):
raise ValueError(f"normalize_mode={normalize_mode!r} 非法,"
"应为 zscore/fixed_range/none/log_range")
self._range_mode = (normalize_mode
if is_range_mode(normalize_mode) else None)
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
if stats_path is not None and not (self._range_mode or self._no_norm):
if not os.path.exists(stats_path):
raise FileNotFoundError(f"统计文件不存在: {stats_path}")
stats = np.load(stats_path)
if stats.shape != (2, self.action_dim):
raise ValueError(
f"action_stats.npy 形状应为 (2, {self.action_dim}),"
f"实际为 {stats.shape}: {stats_path}")
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维,可选)输入,返回各自形状"""
img = [s for s in inputs.values() if len(s) == 5]
hist = [s for s in inputs.values() if len(s) == 3]
imu = [s for s in inputs.values() if len(s) == 2]
if len(img) != 1 or len(hist) != 1 or len(imu) > 1:
raise ValueError(
"ONNX 输入应为 1 个 5 维图像 + 1 个 3 维动作历史"
"(R2 可再加 1 个 2 维 imu),"
f"实际: {inputs}")
if img[0][2] != 3:
raise ValueError(
f"图像输入形状应为 [1,F,3,H,W],实际为 {img[0]}")
return img[0], hist[0], (imu[0] if imu else None)
# ---------------- 生命周期 ----------------
def close(self):
self._session = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
self.close()
return False
# ---------------- 推理 ----------------
def predict(self, images, action_history: np.ndarray,
imu: np.ndarray = None) -> np.ndarray:
"""images: 长度为 frame_len 的 RGB 图像列表(旧→新,末帧为当前帧)
imu: 可选原始姿态向量(roll,pitch,ax,ay,az,gx,gy,gz,模型含 imu
输入时生效,内部按物理量程归一化;None 等价零向量)
返回 (action_dim,) float32 动作向量。
"""
if self._session is None:
raise RuntimeError("predictor 已关闭")
if not isinstance(images, (list, tuple)):
images = [images]
if len(images) != self.frame_len:
raise ValueError(
f"images 应为 {self.frame_len} 帧,实际为 {len(images)} 帧。"
"冷启动时可重复传入同一帧。")
action_history = np.asarray(action_history, dtype=np.float32)
expected = (self.history_len, self.action_dim)
if action_history.shape != expected:
raise ValueError(
f"action_history 形状应为 {expected},实际为 {action_history.shape}")
if self._range_mode:
action_history = normalize_action(action_history, self.action_dim,
mode=self._range_mode)
elif self._action_mean is not None:
action_history = (action_history - self._action_mean) / self._action_std
frames = np.stack([self._preprocess(img) for img in images])
feeds = {
self._image_name: frames[np.newaxis], # (1,F,3,S,S)
self._history_name: action_history[np.newaxis], # (1,H,D)
}
if self._imu_name is not None:
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._range_mode:
result = denormalize_action(result, self.action_dim,
mode=self._range_mode)
elif self._action_mean is not None:
result = result * self._action_std + self._action_mean
return result
def _preprocess(self, image) -> np.ndarray:
"""Resize → /255 → ImageNet 归一化 → (3, H, W) float32"""
img = Image.fromarray(image) if isinstance(image, np.ndarray) else image
img = img.convert("RGB").resize(
(self.image_width, self.image_height), Image.BILINEAR)
x = np.asarray(img, dtype=np.float32) / 255.0 # HWC
x = x.transpose(2, 0, 1) # CHW
return (x - IMAGENET_MEAN[:, None, None]) / IMAGENET_STD[:, None, None]
def main():
parser = argparse.ArgumentParser(description="actpred ONNX Runtime inference")
parser.add_argument("--model", required=True, help="ONNX 模型路径")
parser.add_argument("--images", required=True, nargs="+",
help="按时间顺序(旧→新)传入 frame_len 个图像路径")
parser.add_argument("--action-history", required=True,
help="冒号分隔各步,每步 action_dim 个值")
parser.add_argument("--stats", default=None,
help="action_stats.npy 路径(zscore 归一化训练才需要)")
parser.add_argument("--normalize-mode",
choices=["zscore", "fixed_range", "none", "log_range"],
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 输入且缺省时按零向量")
parser.add_argument("--output-format", choices=["json", "raw"],
default="json")
args = parser.parse_args()
with OnnxPredictor(args.model, stats_path=args.stats,
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} 个路径(旧→新),"
f"实际为 {len(args.images)} 个")
images = [np.array(Image.open(p).convert("RGB")) for p in args.images]
history = parse_action_history(args.action_history, predictor.action_dim)
imu = None
if args.imu is not None:
imu = np.array([float(v) for v in args.imu.split(",")],
dtype=np.float32)
predicted = predictor.predict(images, history, imu=imu)
if args.output_format == "json":
print(json.dumps({"predicted_action": predicted.tolist()}, indent=2))
else:
print(" ".join(f"{v:.4f}" for v in predicted))
if __name__ == "__main__":
main()