2、ONNX基础回顾:ONNX格式、计算图、节点与张量、模型转换的基本流程与工具链

在深入多输入模型转换之前,必须先夯实ONNX(Open Neural Network Exchange)的核心知识体系。ONNX不仅是一种文件格式,更是一种描述机器学习模型计算逻辑的标准化中间表示(Intermediate Representation, IR)。它像一座桥梁,连接着PyTorch、TensorFlow等训练框架与ONNX Runtime、TensorRT、OpenVINO等推理引擎。对于传感器融合或多模态模型而言,理解ONNX的计算图结构、张量流动方式以及转换工具链的底层逻辑,是确保多路输入数据在图中正确路由、拼接和同步的前提。本节将系统梳理ONNX格式的构成要素、计算图的核心概念、标准转换流程,并结合一个多模态输入场景的案例,剖析常见误区。

ONNX 模型表示与转换链路 ONNX 文件内部结构(.onnx) Graph 计算图 Node(算子) Initializer(权重) Input / Output ValueInfo(张量类型) Opset(算子集版本) Metadata 元数据 Producer(框架来源) Model Version Doc String IR Version Metadata Props 模型转换工具链 PyTorch torch.onnx.export ONNX Model TensorFlow tf2onnx.convert ONNX Model 推理引擎(Runtime) ONNX Runtime | TensorRT | OpenVINO | CoreML 多输入模型的关键:Graph Input 定义 输入1: camera_input [1,3,224,224] 输入2: lidar_input [1,64,128,128] 输入3: radar_input [1,32,64]

2.1 ONNX格式的本质:一种可交换的中间表示

ONNX(Open Neural Network Exchange)最初由微软和Facebook于2017年联合推出,其核心目标是为深度学习模型提供一种框架无关的标准化表示。它既不是训练框架,也不是推理引擎,而是一种“通用语言”——训练好的模型被“翻译”成这种语言后,任何支持该语言的推理引擎都能“读懂”并执行它。这种设计解决了深度学习生态中长期存在的“模型孤岛”问题:PyTorch训练的模型无法直接在TensorRT中部署,TensorFlow的模型难以被CoreML调用。

从文件结构来看,一个标准的.onnx文件本质上是使用Protobuf(Protocol Buffers)序列化后的二进制文件。Protobuf是一种高效的结构化数据序列化机制,它定义了清晰的数据模式(Schema),使得不同语言(C++、Python、Java等)都能解析同一份ONNX文件。ONNX格式的核心Schema包含以下关键部分:

Schema组件 作用 多输入模型中的意义
ModelProto ONNX模型的顶层容器,包含图结构、元数据、算子集版本 定义整个多模态模型的入口和出口
GraphProto 描述计算图:节点列表、初始化器、输入/输出张量 多路输入在此声明,决定推理时的数据入口
NodeProto 单个算子实例,包含算子类型、输入/输出张量名、属性 多模态融合算子(如Concat、Add)在此定义
TensorProto 存储权重、偏置等初始化数据 各模态分支的独立权重存储
ValueInfoProto 描述张量的数据类型、形状、名称 明确每个输入张量的维度,如[batch, channel, height, width]
OperatorSetIdProto 声明使用的算子集版本(Opset Version) 不同版本对多输入算子的支持程度不同

理解ONNX的Protobuf结构对于排查转换问题至关重要。例如,当转换后的模型在推理时提示“输入名称不匹配”,往往是因为GraphProto中的input字段与推理代码中提供的张量名称不一致。在多输入场景下,这种名称匹配问题尤为常见,因为每个输入张量都需要在ValueInfoProto中显式声明。

核心要点:ONNX不是一种“文件格式”那么简单,它是一套完整的模型描述协议。对于多输入模型,GraphProto中的input列表定义了模型接受的所有外部输入,每个输入必须包含唯一的name、明确的elem_type(数据类型)和shape(形状)。推理引擎正是依据这些信息来分配内存和调度计算。

2.2 计算图:节点、张量与数据流

ONNX的计算图(Graph)是一种有向无环图(DAG),它描述了张量(Tensor)如何在算子(Operator/Node)之间流动。图中的每个节点代表一个数学操作(如卷积、矩阵乘法、拼接),每条边代表一个张量(即数据)。这种表示方式与具体的硬件无关,因此可以灵活映射到CPU、GPU、NPU等不同计算设备上。

在ONNX中,计算图的核心元素包括:

  • 节点(Node):每个节点包含op_type(算子类型,如Conv、Relu、Concat)、input(输入张量名列表)、output(输出张量名列表)以及attribute(算子属性,如卷积的kernel_shape、strides)。
  • 张量(Tensor):数据的基本载体,具有名称、数据类型(float32、int64等)和形状。张量分为三类:图输入(由外部提供)、图输出(最终结果)、中间张量(节点间传递的数据)。
  • 初始化器(Initializer):存储模型权重和偏置的张量,它们不依赖外部输入,在模型加载时直接写入内存。
  • 数据流:张量从输入节点出发,沿着有向边依次经过各节点,最终到达输出节点。这种流动是“静态”的——图结构在转换时已完全确定,推理时只是按照既定路径执行计算。

对于多输入模型(如摄像头+激光雷达融合),计算图通常呈现“多分支-融合-输出”的拓扑结构。以经典的点云与图像融合网络为例:图像分支经过若干卷积层提取2D特征,点云分支经过PointNet或VoxelNet提取3D特征,两个分支的特征图在某个中间层通过Concat或Add算子融合,最后经过全连接层输出检测结果。在ONNX图中,这意味着存在两个独立的输入节点(image_input和lidar_input),它们各自连接一条子图路径,最终汇聚到融合节点。

工程经验:在转换多输入模型时,务必检查ONNX图中每个输入张量的shape是否与训练时一致。特别是动态维度(如batch维度设为None或symbolic),需要在转换时通过dynamic_axes参数显式声明,否则导出的模型会固定输入形状,导致推理时无法适应不同batch size。

2.3 模型转换的基本流程与工具链

模型转换的本质是将训练框架(PyTorch、TensorFlow等)中的动态图或静态图“翻译”为ONNX的静态计算图。这个过程涉及算子映射、张量形状推断、常量折叠等多个步骤。以PyTorch为例,标准转换流程如下:

  1. 准备模型:将PyTorch模型设置为eval()模式,并确保所有参数已加载完毕。
  2. 构造示例输入:创建一个与训练时输入形状一致的张量(可以是随机数),用于“跟踪”模型的计算路径。
  3. 调用导出API:使用torch.onnx.export(),传入模型、示例输入、输出文件路径、输入/输出名称、动态轴定义等参数。
  4. 验证ONNX模型:使用onnx.checker.check_model()检查模型结构合法性,再用onnxruntime进行推理验证,对比输出与PyTorch原始输出的误差。
  5. 优化与部署:根据目标推理引擎(如TensorRT)进行图优化或精度校准。

对于TensorFlow模型,常用工具是tf2onnx,它支持将SavedModel、GraphDef或Keras模型转换为ONNX。转换命令通常为:

python -m tf2onnx.convert \
    --saved-model ./saved_model_dir \
    --output model.onnx \
    --opset 13 \
    --inputs "input_1:0[1,224,224,3],input_2:0[1,128,128,3]" \
    --outputs "output_0:0"

在转换多输入模型时,--inputs参数需要列出所有输入张量的名称和形状,且顺序必须与模型定义一致。此外,还需要注意--opset版本的选择——较新的算子集(如Opset 13+)对多输入算子的支持更完善,但某些旧版推理引擎可能不支持高版本Opset。

工具链组件 适用框架 关键参数/功能 多输入注意事项
torch.onnx.export PyTorch input_names, output_names, dynamic_axes, opset_version 必须为每个输入指定唯一的input_names;dynamic_axes需覆盖所有输入
tf2onnx.convert TensorFlow --inputs, --outputs, --opset, --target --inputs中需包含所有输入张量的完整签名
onnx-simplifier 通用 简化图结构、消除冗余算子 简化后需重新验证多输入分支是否被正确保留
onnxruntime 通用 跨平台推理、性能优化 支持多输入会话,需按input_names传入数据

2.4 案例拆解:双输入传感器融合模型的转换实战

假设我们有一个自动驾驶场景下的摄像头-激光雷达融合检测模型。模型结构为:图像分支(ResNet-18骨干网络)提取2D特征,点云分支(PointPillars)提取3D特征,两分支特征在torch.cat操作后送入检测头。训练框架为PyTorch,目标部署环境为ONNX Runtime。

初始现象与目标:直接使用torch.onnx.export导出模型时,虽然导出成功,但在ONNX Runtime中推理时,输入数据无法正确匹配——模型期望两个输入张量,但推理代码只传入了一个。进一步检查发现,导出的ONNX图中只有一个输入节点,另一个输入被错误地当作了常量(Constant)折叠进了图里。

分析判断过程:问题根源在于示例输入构造不当。在导出时,我们使用了torch.randn(1,3,224,224)作为唯一的示例输入,而点云分支的输入lidar_input没有被传入export函数。PyTorch的JIT tracer在跟踪模型时,只记录了实际参与计算的张量路径,未传入的输入被当作“未定义”处理,导致其被优化掉。

实际操作步骤:

import torch
import torch.onnx

# 定义双输入模型
class FusionModel(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.image_branch = ...  # 图像分支
        self.lidar_branch = ...  # 点云分支
        self.fusion = ...        # 融合层

    def forward(self, image_input, lidar_input):
        img_feat = self.image_branch(image_input)
        lidar_feat = self.lidar_branch(lidar_input)
        fused = torch.cat([img_feat, lidar_feat], dim=1)
        return self.fusion(fused)

model = FusionModel().eval()

# 构造两个示例输入
image_example = torch.randn(1, 3, 224, 224)
lidar_example = torch.randn(1, 64, 128, 128)

# 导出时同时传入两个输入
torch.onnx.export(
    model,
    (image_example, lidar_example),  # 注意:必须是元组
    "fusion_model.onnx",
    input_names=["image_input", "lidar_input"],
    output_names=["detection_output"],
    dynamic_axes={
        "image_input": {0: "batch"},
        "lidar_input": {0: "batch"},
        "detection_output": {0: "batch"}
    },
    opset_version=13
)

结果对比与验证:导出后,使用onnxruntime进行推理验证:

import onnxruntime as ort
import numpy as np

sess = ort.InferenceSession("fusion_model.onnx")
# 准备两个输入
image_data = np.random.randn(1, 3, 224, 224).astype(np.float32)
lidar_data = np.random.randn(1, 64, 128, 128).astype(np.float32)

# 按名称传入
outputs = sess.run(
    ["detection_output"],
    {"image_input": image_data, "lidar_input": lidar_data}
)
print(outputs[0].shape)

验证通过后,模型推理结果与PyTorch原始输出误差小于1e-5,转换成功。

风险与代价:在转换过程中,我们遇到了两个额外问题:一是dynamic_axes设置不当导致batch维度被固定;二是点云分支中的自定义算子(如Scatter)在ONNX中没有直接对应,需要手动实现或使用onnx-simplifier处理。这些问题的排查耗时约2小时,但通过逐步打印ONNX图结构(onnx.helper.printable_graph)最终定位。

复盘经验:多输入模型转换的核心要点是“显式声明”——所有输入必须显式传入导出函数,所有输入名称必须显式定义,所有动态维度必须显式标注。任何“隐式”的假设都可能导致转换后的模型行为异常。

2.5 常见误区与总结复盘

在ONNX转换实践中,尤其是多输入模型场景,以下误区值得警惕:

  • 误区一:忽略eval()模式。PyTorch模型在train()模式下包含Dropout、BatchNorm等训练专用层,这些层在推理时行为不同。未切换到eval()会导致导出的ONNX模型包含错误的计算逻辑。
  • 误区二:示例输入形状与真实数据不一致。ONNX图会记录示例输入的形状信息,如果推理时输入形状不同(且未设置dynamic_axes),会直接报错。
  • 误区三:多输入顺序混乱。ONNX Runtime的run()方法接受字典格式输入,键名必须与input_names完全一致,顺序无关但名称必须匹配。
  • 误区四:忽略Opset版本兼容性。某些算子(如GridSample、NonMaxSuppression)在不同Opset版本中行为有差异,部署时需确认推理引擎支持的Opset范围。
  • 误区五:不进行输出验证。转换后直接部署,未对比ONNX Runtime与原始框架的输出差异,可能导致精度下降问题被掩盖。
风险提示:对于多输入模型,ONNX图的可视化检查至关重要。推荐使用netron工具(在线或本地)查看图结构,确认所有输入节点、融合节点、输出节点的连接关系是否符合预期。如果发现某个输入分支被“剪枝”或“折叠”,应立即检查示例输入的构造和dynamic_axes设置。

总结:ONNX作为模型交换的“通用语言”,其核心价值在于计算图的标准化描述。对于多输入模型,转换工作的重点不在于“导出”本身,而在于输入定义的完整性和图结构的正确性。掌握ONNX的Protobuf结构、计算图的数据流逻辑、转换工具链的参数语义,是后续处理传感器融合、多模态模型的基础。在实际项目中,建议建立一套标准化的转换验证流程:导出前检查模型模式、导出时显式声明所有输入、导出后使用onnx.checker和onnxruntime双重验证、最后用netron人工审查图结构。这套流程能覆盖绝大多数多输入模型的转换需求,并为后续的算子定制和性能优化打下坚实基础。