3、ONNX模型结构解析:ONNX计算图、节点与张量、使用Netron可视化模型、理解输入输出格式

好,咱们进入第三章。说实话,很多同学在导出ONNX模型后,直接就拿去推理了。模型里面长什么样?输入输出到底对不对?完全是个黑盒。我个人习惯是,拿到一个ONNX模型,第一件事就是打开看看它的结构。这就像你买了一辆车,总得打开发动机盖瞅一眼吧?

3.1 ONNX计算图:一切的基础

ONNX模型的核心,说白了就是一个计算图。你想想看,深度学习模型本质上就是一堆数学运算的组合。卷积、激活、池化、全连接……这些运算按照一定的顺序连接起来,就构成了一个图。

这个图是有向无环图(DAG)。什么意思呢?数据从输入节点流入,经过一个个运算节点,最终从输出节点流出。中间不会形成死循环。我在项目中遇到过,有些框架导出的模型居然出现了环,推理的时候直接卡死。嗯,这里要注意,ONNX标准是不允许有环的。

计算图由三部分组成:

  • 节点(Node):代表一个运算操作,比如Conv、Relu、Add。
  • 张量(Tensor):代表数据,也就是节点之间的输入和输出。
  • 边(Edge):连接节点和张量,表示数据的流向。

说白了,你可以把计算图想象成一个工厂流水线。节点是机器,张量是传送带上的零件,边就是传送带本身。

3.2 节点与张量:模型的骨架和血肉

咱们来细看节点和张量。

节点,每个节点都有一个op_type,比如ConvReluMaxPool。它还包含一些属性(attribute),比如卷积的kernel大小、stride、padding。这些属性是节点的配置参数,在模型训练时就固定下来了。

张量,就是多维数组。在ONNX里,张量有数据类型(float32、int64等)和形状(shape)。形状可能是固定的,比如[1, 3, 640, 640];也可能是动态的,比如[1, 3, -1, -1],这里的-1表示可变维度。

我记得有一次,客户说模型推理结果不对。我打开ONNX一看,发现输入张量的形状写死了[1, 3, 640, 640],但客户传进去的图片是[1, 3, 416, 416]。形状不匹配,推理结果当然一塌糊涂。所以,导出时一定要确认输入输出的形状是否符合你的预期。

下面是一个简单的ONNX节点结构示例:

// 一个卷积节点的伪代码表示
node {
  name: "conv1"
  op_type: "Conv"
  input: "input_tensor"   // 输入张量名
  input: "conv1_weight"   // 权重张量名
  input: "conv1_bias"     // 偏置张量名
  output: "conv1_output"  // 输出张量名
  attribute {
    name: "kernel_shape"
    ints: 3
    ints: 3
  }
  attribute {
    name: "strides"
    ints: 1
    ints: 1
  }
  attribute {
    name: "pads"
    ints: 1
    ints: 1
    ints: 1
    ints: 1
  }
}

3.3 使用Netron可视化模型:让结构一目了然

光看代码太抽象了。我强烈推荐一个工具——Netron。它是一个模型可视化工具,支持ONNX、TensorFlow、PyTorch等多种格式。你只需要把模型文件拖进去,就能看到完整的计算图。

Netron的界面很直观:

  • 左侧是模型结构树,按层级展开。
  • 中间是计算图,节点用方框表示,张量用线条表示。
  • 点击任意节点,右侧会显示该节点的详细信息,包括op_type、属性、输入输出张量的形状和数据类型。

我个人习惯是,导出ONNX后,先用Netron打开,检查以下几点:

  1. 输入节点:名字对不对?形状是不是动态的?数据类型是不是float32?
  2. 输出节点:有几个输出?每个输出的形状是什么?对于Yolo模型,通常会有3个输出(对应3个检测头),或者经过后处理合并成一个输出。
  3. 关键节点:检查Conv、Relu、Reshape等关键操作是否按预期排列。有时候框架会插入一些奇怪的节点,比如Cast(类型转换),这可能会影响推理性能。
小技巧: 在Netron中,你可以按住Ctrl键滚动鼠标滚轮来缩放。对于大型模型,建议先看模型结构树,再点开具体分支查看细节。别一上来就盯着整个图看,会眼花。

3.4 理解输入输出格式:别让模型吃错数据

这是最容易踩坑的地方。我曾经因为输入输出的格式没搞对,调试了整整两天。

对于Yolo模型,典型的输入输出格式如下:

项目 格式 说明
输入 [batch, channels, height, width] NCHW格式,batch通常为1,channels为3(RGB),height和width根据模型而定(如640x640)
输出(原始) [batch, num_boxes, 85] 每个检测框包含:cx, cy, w, h, confidence, class_scores(80个类别)
输出(后处理) [batch, num_detections, 6] 每个检测框包含:x1, y1, x2, y2, confidence, class_id

这里要注意几个关键点:

  • 数据排布:ONNX默认是NCHW,但有些框架导出时可能会变成NHWC。你需要在导出时指定,或者在推理时做转置。我个人建议统一用NCHW,因为ONNX Runtime对NCHW的优化更好。
  • 动态轴:如果输入尺寸不固定,记得在导出时设置动态轴。比如dynamic_axes={'input': {0: 'batch', 2: 'height', 3: 'width'}}。这样模型就能接受不同尺寸的输入。
  • 输出解析:Yolo的原始输出需要经过解码(解算bbox坐标)、NMS(非极大值抑制)才能得到最终结果。有些导出工具会把NMS也集成到ONNX模型里,但我不建议这么做。NMS的逻辑复杂,不同平台实现不一致,容易出问题。我通常只导出原始输出,在应用层做后处理。
避坑指南: 我曾经在导出YoloV5时,发现输出形状是[1, 25200, 85],但实际检测到的目标只有几个。后来一查,原来是输出没有经过NMS,25200个框里大部分都是背景。所以,解析输出时一定要做置信度过滤和NMS,别直接拿所有框去画图。

3.5 动手实践:用Netron查看一个Yolo ONNX模型

光说不练假把式。咱们来实际操作一下。

  1. 下载一个YoloV8的ONNX模型(比如yolov8n.onnx)。
  2. 打开Netron(可以直接用网页版:netron.app)。
  3. 把模型文件拖进去。
  4. 观察输入节点:名字通常是"images",形状是[1, 3, 640, 640],数据类型是float32。
  5. 观察输出节点:YoloV8通常有3个输出,对应不同尺度的检测头。名字可能是"/model.22/Concat_output_0"之类的,形状分别是[1, 84, 8400][1, 84, 2100]等。
  6. 点击一个卷积节点,查看它的属性:kernel_size、stride、padding。
  7. 沿着计算图从输入走到输出,看看数据是怎么流动的。

做完这一步,你对ONNX模型的理解会上一个台阶。以后再遇到模型推理问题,你就能快速定位是输入格式不对,还是输出解析有误。

好了,这一章就到这里。下一章咱们会深入ONNX的算子集,看看那些常用的算子是怎么工作的。到时候我会分享一些我在算子兼容性上踩过的坑,保证让你少走弯路。