4、TensorFlow 模型导出为 ONNX:tf2onnx 工具使用、Keras 模型转换、SavedModel 转换

好,咱们进入第四章。这一章聊的是 TensorFlow 模型怎么转成 ONNX。

说实话,TensorFlow 的生态有点「大而全」。从最早的 Session 模式,到后来的 Keras 高层 API,再到 SavedModel 标准格式,版本迭代快,坑也不少。我个人在项目里踩过不少 TensorFlow 转 ONNX 的雷,今天把这些经验都抖出来。

4.1 为什么需要 tf2onnx?

你想想看,TensorFlow 模型训练好了,但部署环境不一定支持 TF。比如你要在嵌入式设备上跑,或者用 ONNX Runtime 做推理加速。这时候就需要一个桥梁——tf2onnx。

说白了,tf2onnx 就是一个转换器。它把 TensorFlow 的计算图翻译成 ONNX 的计算图。我刚开始用的时候,总觉得这玩意儿就是个黑盒,后来才发现,理解它的工作原理能省很多调试时间。

核心要点:tf2onnx 支持三种输入格式:Keras 模型(.h5)、SavedModel 目录、以及具体的 GraphDef。我个人最常用的是 SavedModel,因为它最规范。

4.2 环境准备

先装好必要的库。嗯,这里要注意版本兼容性。

pip install tensorflow==2.12.0
pip install onnx==1.14.0
pip install tf2onnx==1.15.1
pip install onnxruntime==1.15.1  # 用于验证转换结果

我曾经因为版本不匹配,卡了整整一个下午。TensorFlow 2.x 的版本迭代很快,tf2onnx 的适配有时候会滞后。我的建议是:先查一下 tf2onnx 的 GitHub Release 页面,看看它支持哪个 TF 版本。

4.3 方法一:Keras 模型转换

这是最直观的方式。你训练完一个 Keras 模型,保存成 .h5 文件,然后一行命令搞定。

import tensorflow as tf
import tf2onnx

# 加载 Keras 模型
model = tf.keras.models.load_model('my_model.h5')

# 指定输入签名(重要!)
spec = (tf.TensorSpec((None, 224, 224, 3), tf.float32, name="input"),)

# 转换
onnx_model, _ = tf2onnx.convert.from_keras(model, input_signature=spec, opset=13)

# 保存
with open("model.onnx", "wb") as f:
    f.write(onnx_model.SerializeToString())

这里有个坑:input_signature 必须指定。如果不写,tf2onnx 会尝试自动推断,但经常推断错。我遇到过 batch size 被固定成 1 的情况,推理时只能一次处理一张图。

小技巧:opset 参数建议用 13 或更高。太低的 opset 不支持一些新算子,比如 Resize、ScatterND 等。我一般用 opset=15,兼容性最好。

4.4 方法二:SavedModel 转换

SavedModel 是 TensorFlow 官方推荐的导出格式。它包含了模型的计算图、权重、以及签名信息。我个人更推荐这种方式,因为它更「干净」。

# 假设你已经有了一个 SavedModel 目录
# 目录结构类似:./saved_model/  ├── assets/  ├── variables/  └── saved_model.pb

import tf2onnx

# 命令行方式(最常用)
!python -m tf2onnx.convert \
    --saved-model ./saved_model \
    --output model.onnx \
    --opset 13 \
    --inputs "input:0" \
    --outputs "output:0"

嗯,这里要注意输入输出张量的名字。你可以用 saved_model_cli show --dir ./saved_model --all 来查看。我刚开始不知道这个命令,每次都要写代码打印张量名,后来发现这个 CLI 工具太好用了。

警告:如果模型里有自定义层(比如自己写的 Lambda 层),转换可能会失败。我曾经有个项目用了自定义的 Attention 层,转换时报错说找不到算子。解决办法是:要么把自定义层用标准算子重写,要么在转换时注册自定义算子。

4.5 方法三:从具体 GraphDef 转换

有些老项目用的是 frozen graph(.pb 文件)。这时候可以用 from_graphdef 方法。

import tf2onnx

# 加载 frozen graph
with tf.io.gfile.GFile("frozen_graph.pb", "rb") as f:
    graph_def = tf.compat.v1.GraphDef()
    graph_def.ParseFromString(f.read())

# 转换
onnx_graph = tf2onnx.tfonnx.process_tf_graph(
    graph_def,
    input_names=["input:0"],
    output_names=["output:0"],
    opset=13
)

# 保存
model_proto = onnx_graph.make_model("my_model")
with open("model.onnx", "wb") as f:
    f.write(model_proto.SerializeToString())

说实话,这种方法我很少用了。因为 frozen graph 里经常有一些 TF 内部算子(比如 Switch、Merge),转换起来很麻烦。如果你还在用这种老格式,我建议先升级到 SavedModel。

4.6 验证转换结果

转换完了,一定要验证。不然部署时才发现问题,那就晚了。

import onnx
import onnxruntime as ort
import numpy as np

# 检查 ONNX 模型结构
onnx_model = onnx.load("model.onnx")
onnx.checker.check_model(onnx_model)
print("ONNX 模型检查通过!")

# 用 ONNX Runtime 跑一次推理
session = ort.InferenceSession("model.onnx")
input_name = session.get_inputs()[0].name
output_name = session.get_outputs()[0].name

# 构造随机输入
dummy_input = np.random.randn(1, 224, 224, 3).astype(np.float32)

# 推理
result = session.run([output_name], {input_name: dummy_input})
print("推理结果 shape:", result[0].shape)

我一般会对比 TensorFlow 和 ONNX Runtime 的输出。如果误差在 1e-5 以内,就算通过。如果误差很大,那就要检查是不是某些算子转换出了问题。

4.7 常见问题与避坑指南

问题 原因 解决办法
转换时报错「Unsupported op」 某些 TF 算子没有对应的 ONNX 算子 升级 opset 版本,或者用自定义算子映射
推理结果不一致 可能是 Batch Normalization 的融合问题 检查 TF 和 ONNX 的 BN 层参数顺序
输入输出名字找不到 SavedModel 的签名信息不完整 用 saved_model_cli 查看具体签名
模型太大,转换超时 tf2onnx 默认有内存限制 设置环境变量 TF2ONNX_MAX_MEMORY=8G

避坑指南:我曾经遇到过一个模型,转换后推理结果全是 NaN。查了两天才发现,是因为模型里用了 tf.cast 把 float32 转成了 int32,ONNX 里 int32 的除法行为不一样。解决办法是在转换前把 cast 去掉,或者用 tf.floor 替代。

4.8 总结

TensorFlow 转 ONNX,说白了就是三步:准备模型、选择转换方法、验证结果。

我个人最推荐 SavedModel 方式,因为它最规范,坑最少。Keras 模型转换虽然方便,但 input_signature 容易出错。至于 frozen graph,能不用就别用了。

下一章我们会聊 PyTorch 模型怎么转 ONNX。嗯,那个坑更多,但我会把经验都告诉你。