3. ONNX模型导出与格式转换:从训练框架到推理引擎的桥梁
说实话,ONNX 这个格式,在车载AI座舱里几乎是个绕不开的中间站。你想想看,我们训练模型用 PyTorch 或者 TensorFlow,但最终要跑在 Qualcomm、NVIDIA 或者地平线的芯片上。每个芯片厂商都有自己的推理引擎,总不能为每个平台都重写一遍模型吧?ONNX 就是那个通用的「翻译官」。
我个人习惯把 ONNX 导出看作一道「安检门」——模型能不能顺利通过,直接决定了后续部署的成败。这一章,我就把我在项目里踩过的坑、总结的经验,掰开了跟你聊聊。
3.1 为什么非得转ONNX?
车载座舱的场景很特殊。模型要跑在嵌入式设备上,算力有限,延迟要求苛刻。PyTorch 和 TensorFlow 虽然训练方便,但推理效率不够极致。ONNX 的好处在于:
- 中间表示:它定义了一套统一的算子标准,不依赖任何训练框架。
- 图优化:ONNX Runtime 和一些工具链可以做算子融合、常量折叠,减少计算量。
- 跨平台:从云端服务器到车机芯片,一套 ONNX 模型通吃。
核心观点:ONNX 不是终点,而是起点。它让模型从「训练框架的私有格式」变成了「硬件厂商都能理解的通用语言」。
3.2 PyTorch模型转ONNX:torch.onnx.export 实战
PyTorch 转 ONNX 主要靠 torch.onnx.export。这个函数看起来简单,但参数设置不对,导出的模型可能就废了。我刚开始做的时候,就吃过这个亏。
import torch
import torch.onnx
# 假设我们有一个训练好的座舱语音唤醒模型
model = WakeUpModel()
model.load_state_dict(torch.load('wakeup_model.pth'))
model.eval()
# 构造一个 dummy input,形状要和实际输入一致
dummy_input = torch.randn(1, 1, 16000) # batch=1, channel=1, 16kHz音频
# 导出ONNX
torch.onnx.export(
model, # 模型
dummy_input, # 示例输入
"wakeup_model.onnx", # 输出文件名
export_params=True, # 导出训练好的参数
opset_version=13, # 算子集版本,我建议用13或更高
do_constant_folding=True, # 常量折叠优化
input_names=['input'], # 输入节点名称
output_names=['output'], # 输出节点名称
dynamic_axes={
'input': {0: 'batch_size'}, # 动态batch
'output': {0: 'batch_size'}
}
)
print("ONNX导出成功!")
这里有几个关键点,我得重点说一下:
- model.eval():一定要切换到推理模式。我见过有人忘了这步,导出的模型里还带着 Dropout 和 BatchNorm 的训练行为,推理结果完全不对。
- opset_version:算子集版本越高,支持的算子越新。但车载芯片的推理引擎可能不支持太新的版本。我个人习惯用 opset=13,兼容性最好。
- dynamic_axes:这个后面会细讲,先记住它是用来控制动态轴的。
3.3 TensorFlow模型转ONNX:tf2onnx 工具链
TensorFlow 转 ONNX 稍微麻烦一点。我一般用 tf2onnx 这个工具。它支持从 SavedModel、Keras H5 或者 frozen graph 转换。
# 命令行方式,简单粗暴
python -m tf2onnx.convert \
--saved-model ./saved_model_dir \
--output seat_model.onnx \
--opset 13 \
--inputs "input:0" \
--outputs "output:0"
# 或者在Python代码里调用
import tf2onnx
import tensorflow as tf
model = tf.keras.models.load_model('seat_control_model.h5')
spec = (tf.TensorSpec((None, 10), tf.float32, name="input"),)
output_path = "./seat_model.onnx"
model_proto, _ = tf2onnx.convert.from_keras(
model,
input_signature=spec,
opset=13,
output_path=output_path
)
print("转换完成")
小技巧:TensorFlow 的算子命名有时候很乱。我建议在导出前先用 model.summary() 确认输入输出节点的名字,不然很容易搞错。
3.4 ONNX算子兼容性检查:别让模型卡在「最后一公里」
模型导出成功,不代表万事大吉。我曾经有一个座舱手势识别模型,PyTorch 里跑得好好的,转成 ONNX 后,在目标芯片上死活跑不起来。查了半天,发现是用了 torch.nn.functional.grid_sample,这个算子在 ONNX 的某些版本里不支持。
所以,算子兼容性检查是必须的。我一般用以下几个方法:
- ONNX Runtime 验证:直接用 ONNX Runtime 跑一遍推理,看结果和原始框架是否一致。
- Netron 可视化:用 Netron 打开 ONNX 模型,检查有没有出现
Unknown或者Unsupported的算子。 - polygraphy 工具:NVIDIA 出的工具,可以自动检查算子兼容性,还能做精度对比。
# 用 ONNX Runtime 做精度对比
import onnxruntime as ort
import numpy as np
# 创建推理会话
session = ort.InferenceSession("wakeup_model.onnx")
input_name = session.get_inputs()[0].name
output_name = session.get_outputs()[0].name
# 准备输入数据
test_input = np.random.randn(1, 1, 16000).astype(np.float32)
# 运行推理
result = session.run([output_name], {input_name: test_input})
# 和 PyTorch 结果对比
# pytorch_output = model(torch.from_numpy(test_input))
# 计算误差...
print("ONNX Runtime 推理完成")
避坑指南:我曾经遇到过一个 case,ONNX Runtime 推理结果和 PyTorch 差了 1e-3 的量级。排查后发现是 BatchNorm 层的 epsilon 值在转换时被截断了。解决办法是在 PyTorch 里显式设置 torch.onnx.export 的 training=False 参数。
3.5 动态轴与静态轴设置:灵活性与性能的博弈
这是 ONNX 导出里最容易让人迷糊的地方。说白了,动态轴就是允许输入的形状在推理时变化,静态轴就是固定死。
在车载座舱场景里,动态轴很有用。比如语音唤醒模型,每次输入的音频长度可能不一样;视觉模型,输入图片的分辨率可能不同。但动态轴也有代价——推理引擎需要做更多的内存分配和形状推导,性能会下降。
| 特性 | 静态轴 | 动态轴 |
|---|---|---|
| 推理速度 | 快,内存预分配 | 稍慢,需要动态分配 |
| 灵活性 | 差,输入形状固定 | 好,支持变长输入 |
| 内存占用 | 固定,可能浪费 | 按需分配,更高效 |
| 适用场景 | 固定分辨率图像、固定长度音频 | 变长语音、多分辨率输入 |
我个人的经验是:能静态就静态,需要动态才动态。比如座舱里的视线检测模型,输入图像分辨率是固定的 224x224,那就用静态轴,推理速度能快 10%-20%。但语音唤醒模型,用户说话时长不确定,就必须用动态轴。
设置方法很简单,在 torch.onnx.export 的 dynamic_axes 参数里指定哪些维度是动态的:
dynamic_axes = {
'input': {0: 'batch_size', 2: 'audio_length'}, # batch和音频长度动态
'output': {0: 'batch_size'}
}
注意,动态轴只能设置在 batch 维度或者序列长度维度上。通道数、特征维度这些一般不要动,动了模型结构就变了。
3.6 知识体系总览
为了让你对整个流程有个直观的认识,我画了一张图。它把从训练框架到 ONNX 再到推理引擎的完整链路展示了出来。
3.7 常见问题与避坑指南
最后,我整理了几个我在项目中真实遇到过的问题,希望能帮你少走弯路。
- 算子不支持:PyTorch 里的
torch.einsum、torch.where在某些 opset 里不支持。解决办法是改写模型,用更基础的算子替代。 - 动态轴导致性能下降:我有个项目,用了动态轴后推理时间增加了 30%。后来改成固定 batch size,用 padding 补齐,速度就上来了。
- 精度损失:转换后模型精度下降,通常是因为某些算子的实现有差异。建议用 ONNX Runtime 的
ort.SessionOptions()设置execution_mode=ort.ExecutionMode.ORT_PARALLEL来优化。 - 模型太大:ONNX 模型有时候会比原始模型大很多。可以用
onnx.shape_inference.infer_shapes做形状推断,或者用onnx.optimizer.optimize做图优化。
我的习惯:每次导出 ONNX 后,我都会用 onnx.checker.check_model 做一次完整性检查。这一步虽然简单,但能避免很多低级错误。