3、ONNX模型结构解析:ONNX计算图、节点与张量、使用Netron可视化模型、理解输入输出格式
好,咱们进入第三章。说实话,很多同学在导出ONNX模型后,直接就拿去推理了。模型里面长什么样?输入输出到底对不对?完全是个黑盒。我个人习惯是,拿到一个ONNX模型,第一件事就是打开看看它的结构。这就像你买了一辆车,总得打开发动机盖瞅一眼吧?
3.1 ONNX计算图:一切的基础
ONNX模型的核心,说白了就是一个计算图。你想想看,深度学习模型本质上就是一堆数学运算的组合。卷积、激活、池化、全连接……这些运算按照一定的顺序连接起来,就构成了一个图。
这个图是有向无环图(DAG)。什么意思呢?数据从输入节点流入,经过一个个运算节点,最终从输出节点流出。中间不会形成死循环。我在项目中遇到过,有些框架导出的模型居然出现了环,推理的时候直接卡死。嗯,这里要注意,ONNX标准是不允许有环的。
计算图由三部分组成:
- 节点(Node):代表一个运算操作,比如Conv、Relu、Add。
- 张量(Tensor):代表数据,也就是节点之间的输入和输出。
- 边(Edge):连接节点和张量,表示数据的流向。
说白了,你可以把计算图想象成一个工厂流水线。节点是机器,张量是传送带上的零件,边就是传送带本身。
3.2 节点与张量:模型的骨架和血肉
咱们来细看节点和张量。
节点,每个节点都有一个op_type,比如Conv、Relu、MaxPool。它还包含一些属性(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打开,检查以下几点:
- 输入节点:名字对不对?形状是不是动态的?数据类型是不是float32?
- 输出节点:有几个输出?每个输出的形状是什么?对于Yolo模型,通常会有3个输出(对应3个检测头),或者经过后处理合并成一个输出。
- 关键节点:检查Conv、Relu、Reshape等关键操作是否按预期排列。有时候框架会插入一些奇怪的节点,比如Cast(类型转换),这可能会影响推理性能。
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的逻辑复杂,不同平台实现不一致,容易出问题。我通常只导出原始输出,在应用层做后处理。
[1, 25200, 85],但实际检测到的目标只有几个。后来一查,原来是输出没有经过NMS,25200个框里大部分都是背景。所以,解析输出时一定要做置信度过滤和NMS,别直接拿所有框去画图。
3.5 动手实践:用Netron查看一个Yolo ONNX模型
光说不练假把式。咱们来实际操作一下。
- 下载一个YoloV8的ONNX模型(比如yolov8n.onnx)。
- 打开Netron(可以直接用网页版:netron.app)。
- 把模型文件拖进去。
- 观察输入节点:名字通常是"images",形状是
[1, 3, 640, 640],数据类型是float32。 - 观察输出节点:YoloV8通常有3个输出,对应不同尺度的检测头。名字可能是"/model.22/Concat_output_0"之类的,形状分别是
[1, 84, 8400]、[1, 84, 2100]等。 - 点击一个卷积节点,查看它的属性:kernel_size、stride、padding。
- 沿着计算图从输入走到输出,看看数据是怎么流动的。
做完这一步,你对ONNX模型的理解会上一个台阶。以后再遇到模型推理问题,你就能快速定位是输入格式不对,还是输出解析有误。
好了,这一章就到这里。下一章咱们会深入ONNX的算子集,看看那些常用的算子是怎么工作的。到时候我会分享一些我在算子兼容性上踩过的坑,保证让你少走弯路。