Microduck 的 ONNX 管道:从 PyTorch 策略到鸭子大脑的完整部署链路
做过 sim2real 的人都知道,训练出一个能走的策略只是完成了 30% 的工作。剩下 70% 是怎么把这个策略从 GPU 上的 PyTorch 计算图,变成一个能在 RK3566 嵌入式板子上以 50Hz 稳定推理的执行体。Microduck 的方案是一条 ONNX 管道:训练仓库导出 ONNX,运行时加载 ONNX,策略热切换也走 ONNX。今天拆开看这条管道的每个环节。
为什么要用 ONNX 而不是直接部署 PyTorch
PyTorch 是训练框架,不是部署框架。你要在一个 25 厘米、800 克的机器人上跑 PyTorch 运行时?先装 Python 解释器,再装 PyTorch + CUDA 或 CPU backend,一个 import torch 就吃掉几百 MB 内存。RK3566 是四核 Cortex-A55,2GB 内存,跑这套东西不现实。
ONNX Runtime 不一样。它是 C++ 推理引擎,加载 .onnx 文件后直接跑计算图,不需要 Python,不需要 PyTorch。Microduck 的 robotd 用 Rust 写的,ONNX Runtime 有 Rust binding,集成进去就是几行代码的事。推理延迟?一个 14 维输出、61 维输入的 MLP 网络,在 RK3566 上推理一次不到 1 毫秒。50Hz 循环每 tick 有 20 毫秒,绰绰有余。
export.py:从训练 checkpoint 到 ONNX 文件
Microduck RL 仓库的 scripts/export.py 负责把训练好的策略导出成 ONNX。流程不复杂,但有几个细节值得注意。
第一步是加载 checkpoint。export.py 支持三种方式:指定 wandb run path 自动下载最新的 checkpoint、指定具体的 iteration number(比如 model_29999.pt)、或者直接给一个本地文件路径。训练时 rsl_rl 的 OnPolicyRunner 会定期存 checkpoint,文件名格式是 model_{iteration}.pt。
第二步是构建环境。这里有个关键设计:export 时构建的是 play mode 的环境,不是训练环境。区别在于 play mode 关闭了域随机化、关闭了课程调度、用了确定性 seed。你要导出的是一个干净的策略网络,不需要环境里的随机扰动。
第三步是提取策略网络并导出。rsl_rl 的 OnPolicyRunner 内部有一个 actor-critic 网络,导出时只取 actor 部分(critic 在部署时没用)。网络输入是 observation tensor,输出是 action tensor。用 torch.onnx.export() 把它序列化成 .onnx 文件,输入输出维度固定。
导出的 ONNX 文件里还藏了一个关键信息:gait period。infer_policy.py 在加载 ONNX 时会尝试读取模型的 metadata,找 gait_period 这个键。这是步态周期——策略训练时学到的固有步频,部署时用来做相位同步。如果你的策略是 0.4 秒一个步态周期,运行时需要知道这个数字才能正确插值。这个值被写进 ONNX 的 custom_metadata_map,不写也能跑,但步态会不对。
infer_policy.py:sim2real 的最后一道验证
导出完 ONNX,先别急着上真机。scripts/infer_policy.py 是 sim2real 之间的桥梁——它在 CPU MuJoCo 里加载 ONNX 策略,用键盘控制,让你验证导出的策略在仿真里行为是否正确。
这个脚本的设计有几个有意思的地方。首先是 DEFAULT_POSE。策略输出的 14 维动作不是绝对关节角度,而是相对于一个默认姿态的偏移量。DEFAULT_POSE 是 STAND2 姿态:髋关节 pitch 26.24°、踝关节 25.95°、膝关节 0.28°,躯干前倾约 5mm 让重心落在踝关节轴上方。部署时的公式是 motor_target = DEFAULT_POSE + action * action_scale,action_scale 控制动作幅度。
然后是策略热切换。infer_policy.py 不是只加载一个策略——它可以同时加载 walking、standing、sitstand、roulade、kick_left、kick_right 多个 ONNX 文件。运行时根据键盘输入切换:按 G 触发 GroundPick,按 Y 触发 SitStand,按 R 触发 Roulade。切换的依据是一个 threshold 参数:当当前策略的某个观测值(通常是 base height 或 velocity)低于阈值时,自动切到恢复策略,恢复完再切回来。
这就是 Microduck 的”一个大脑多个人格”设计。所有策略共享 61 维观察契约,输入维度一致,输出维度一致,切换时不需要重启或重新校准。你只需要准备多个 ONNX 文件,运行时根据状态机决定加载哪个。
TerminalInput:为什么不用 MuJoCo viewer 的键盘回调
infer_policy.py 里有一个细节很容易被忽略:它用了自己写的 TerminalInput 类来读键盘,而不是 MuJoCo viewer 的 key_callback。原因在注释里写得很清楚——viewer 窗口的按键事件会同时触发 viewer 自带的可视化快捷键(切换 frame 显示、标签、渲染模式),你的业务逻辑和 viewer 的 UI 逻辑会互相干扰。
TerminalInput 的实现很简洁:cbreak 模式(不是 raw 模式,这样 Ctrl+C 还能用),后台线程读 stdin,箭头键解析成 “up”/”down” 等符号名。这种做法的好处是完全解耦——viewer 只负责渲染,输入只负责控制策略。
从 infer_policy 到 robotd:真机部署的差异
infer_policy.py 是验证工具,真机部署走的是 robotd 的 ONNX 加载路径。两者的核心逻辑一样:加载 ONNX、构建 observation、推理、得到 action、映射到关节角度。但有几个关键差异。
延迟模型不同。infer_policy 里可以加 delay_min_lag 和 delay_max_lag 来模拟舵机延迟,真机上延迟是物理固有的——Dynamixel XL330 的响应延迟大概 2-3 个 tick(40-60ms)。robotd 在部署时不需要模拟延迟,但需要在训练时见过延迟,否则策略会对真机的响应滞后不适应。这就是 domain randomization 里 command delay 那一项的作用。
安全层不同。infer_policy 直接把 action 写进 MuJoCo 的 ctrl,真机上 action 要过 safety.apply() 的审核。安全层会检查关节角度限制、速度限制、力矩限制,不合规的目标会被 clamp 或拒绝。这是 borrow checker 在编译时保证的——策略代码无法绕过安全层直接写舵机。
传感器不同。infer_policy 从 MuJoCo 的 data.qpos、data.qvel 直接读关节状态,真机从 Dynamixel 总线的 sync_read 获取。IMU 数据在仿真里是精确的,真机上有噪声和漂移。策略在训练时通过域随机化见过这些差异,所以能泛化。
ONNX 导出时容易踩的坑
第一个坑是动态维度。PyTorch 的网络在训练时 batch size 是动态的,但 ONNX 导出时如果不指定动态维度,默认会固化成导出时的 batch size。Microduck 的做法是导出时用 dummy input 固定 batch size 为 1,因为部署时每次只推理一个样本。
第二个坑是 metadata。gait_period 写进 ONNX metadata 是可选的,不写不会报错,但策略的步态会不对。训练时 wandb 会记录 gait period,导出时应该从训练配置里读出来写进 ONNX。如果忘了写,infer_policy 会打一行 warning 但继续跑,你以为策略没问题,实际步态相位是错的。
第三个坑是 observation 顺序。训练时 observation 的维度顺序由 env_cfg.observations 定义,部署时必须完全一致。Microduck 用 61 维观察契约锁死了这个顺序:48 维本体感知(关节角度、关节速度、IMU、上一步动作)+ 13 维命令块。如果训练时改了 observation 的顺序但忘了同步部署代码,策略会读到错位的输入,行为完全不可预测。
管道的价值
这条管道的核心价值是确定性。从 PyTorch checkpoint 到 ONNX 到 robotd 的推理,每一步都是可验证的。你可以在 infer_policy.py 里跑同样的策略,对比仿真和真机的行为差异。如果 sim2real gap 大,你可以回去调域随机化参数,重新训练,重新导出,重新验证——整个循环不需要碰真机代码。
Microduck 选择 ONNX 不是因为它最好,而是因为它够用且标准。PyTorch 导出 ONNX 是一行代码的事,ONNX Runtime 在嵌入式平台上轻量可靠,Rust binding 成熟。这条管道让一个 800 克的鸭子能背着 14 个策略随时切换大脑,每次切换只需要加载一个几 KB 的 ONNX 文件。
