做推理部署的工程师大概率都遇到过这个场景:模型在 PyTorch 里跑得挺正常,一导出 ONNX、换到 ONNX Runtime 或者端侧推理引擎之后,出来的结果就开始“群魔乱舞”——要么框的位置偏得离谱,要么置信度全是负数,要么直接一个框都出不来。这时候大多数问题都不在模型权重,而在后处理。YOLO26 这代模型的输出头已经和几年前的 YOLOv5 差别很大了,如果还抱着旧代码去解新输出,那基本是给自己找事。
这篇就基于我实际调试 YOLO26 ONNX 推理链路的经验,完整讲一遍后处理从原理到落地的全过程。文里的思路同样适用于 YOLOv8/v9/v11 这一系列 anchor-free + DFL 结构的模型,只是输出分支的排列组合稍有差异,核心逻辑是一致的。
1. 拿到 ONNX 之后的第一件事:读懂输出张量的排布
很多同学导出 ONNX 之后习惯先拿 Netron 看一眼结构,看到输出节点是个三维张量就关掉了,后面写代码全靠猜。这是后处理踩坑的第一大来源。YOLO26 的 ONNX 输出排在绝大多数官方导出配置下是一个[1, 84, 8400]形状的张量(具体数字取决于输入分辨率和模型版本),解读它之前必须先搞清楚每一个维度的业务含义。
1.1 三个维度的真实含义
第一个维度是 batch size,这个基本没有歧义,推理时一般就是 1。第二个维度是4 + num_classes的拼接,在 COCO 80 类模型里就是 84。前 4 个通道是边框回归的“原始预测值”,后面 80 个通道是每个类别的逻辑值,不是经过 sigmoid 的概率值,这一点非常关键。第三个维度是候选框的总数量,由特征图的尺度组合决定。
以输入 640x640 为例,YOLO26 通常会在 8 倍、16 倍、32 倍下采样处各输出一层特征图,对应尺寸是 80x80、40x40、20x20,三层的候选框数量加起来就是 6400 + 1600 + 400 = 8400。你可以把这个三维张量理解为:一次性把三张不同尺度的特征图上所有位置的预测结果全部压扁排列,对应关系是固定的——前 6400 个来自浅层高分辨率特征图,中间 1600 个来自中层,最后 400 个来自深层低分辨率特征图。
1.2 和旧版 YOLO 的关键差异
在 YOLOv3/v5 时代,输出张量是[1, anchor_num * (5 + num_classes), 8400]这种结构,5 代表 x、y、w、h 和目标置信度 objectness,后处理要先做 anchor 解码,再乘上 objectness 和类别概率。从 YOLOv8 开始,anchor 被彻底移除,objectness 也被合并掉了,最终置信度只由类别分支的 sigmoid 结果决定。
YOLO26 延续这个思路,但它最大的变化在回归分支是 DFL(Distribution Focal Loss)结构。所谓 4 个回归通道的“原始预测值”不是真正意义的坐标偏移量,而是 4 组、每组 16 个离散概率分布,ONNX 输出张量的通道顺序上,它们被压缩成了 4 个通道,每个通道内部其实是 16 个 bin 的分布参数。你拿到 ONNX 时看到的 84 通道里,前 4 个通道是原始分布数据(有的导出工具是排成 64 通道的展开形态),后面 80 个通道才是分类 logits。
这里有个很容易踩的坑:很多旧版代码直接对 84 个通道的前 4 个做 sigmoid,然后乘以 stride 当坐标,结果就是框偏移、尺寸完全不对。面对 YOLO26,必须先把 DFL 反变换做掉,具体怎么做下一节细说。
2. 解码阶段的两个核心算法:从模型坐标系到像素坐标系
后处理的第一个阶段叫解码(decode),目标是把张量里的原始预测值变成一组真正可以画在图像上的边界框。YOLO26 的解码主要包含 DFL 反变换、距离偏移还原、坐标缩放三件事,每一步都决定了最终框的准确性。
2.1 DFL 反变换到底在干什么
先给一个直观理解。DFL 的思想是:对于边界框的一条边到对应 anchor 的距离,让模型输出一个离散分布而不是一个直接回归值。在这个设定里,每个距离被离散成 16 个 bin,这 16 个 bin 的权重和就是实际距离值。模型输出的 16 个原始值代表每个 bin 的置信度 logits,把它们先 softmax、再和预设的[0, 1, 2, ..., 15]做加权求和,得到的就是相对距离的分布期望。
公式非常简单:
probs = softmax(pred_dist) // 16个值,归一化 dist = sum(probs * [0, 1, ..., 15]) // 加权期望接下来乘上一个投影比例。原理解释一下:16 个 bin 并不是直接对应 16 个像素,而是用projection = stride / 4这个系数把离散期望缩放到真实的像素距离。也就是说,在 8 倍下采样的特征图上,1 个 bin 代表 2 个像素;在 32 倍下采样的特征图上,1 个 bin 代表 8 个像素。
实际做 DFL 时,4 个回归通道都要单独过一遍这个过程。YOLO26 ONNX 输出的通道排布如果是压缩形态,需要先把前 4 个通道拆分成[4, 16, 8400]的形态,再做 softmax 和加权求和。
2.2 anchor 坐标还原与 sigmoid 逻辑值
DFL 做完后,可以得到每个候选框中心点到四条边的距离 $l, r, t, b$。要注意这个还原必须作用在特征图对应的网格坐标上,所以先要知道当前候选框属于哪个下采样层级。前面提到 8400 个候选框的顺序是 8 倍层 6400 个、16 倍层 1600 个、32 倍层 400 个,每个层级的网格尺寸不同,anchor 点的坐标也不同。
cx = (grid_x + 0.5) * stride cy = (grid_y + 0.5) * stride x1 = cx - l y1 = cy - t x2 = cx + r y2 = cy + b这里的grid_x、grid_y是特征图上的整数网格索引,加 0.5 是因为 YOLO 系列默认把网格中心作为 anchor 点。还原之后得到的是基于输入图像(640x640)的像素坐标,这也是模型输入坐标系。如果原始图像做了 letterbox 缩放,还需要按比例映射回原图。
分类分支相对简单,80 个通道做一次 sigmoid 就能得到各类别的概率。之所以强调“不要提前 sigmoid 再导出”,是因为很多部署框架对 sigmoid 有 Fused 优化,但 ONNX Runtime 的 CPU 实现其实没那么激进,留在后处理里做可以同时控制精度和算子。
下面给一份我调试过程中总结的解码伪代码,去掉业务干扰,只保留核心逻辑:
import numpy as np def decode_output(pred, stride, num_classes=80): # pred shape: [4 + num_classes, grid_h * grid_w] dist = pred[:4].reshape(4, 16, -1) # [4, 16, N] cls = pred[4:] # softmax over 16 bins dist = np.exp(dist - dist.max(axis=1, keepdims=True)) dist /= dist.sum(axis=1, keepdims=True) proj = np.arange(16, dtype=np.float32) * (stride / 4.0) dist = (dist * proj.reshape(1, 16, 1)).sum(axis=1) # [4, N] # grid coordinates grid_h, grid_w = ... # 依据索引计算 cx, cy cx = (grid_x + 0.5) * stride cy = (grid_y + 0.5) * stride x1 = cx - dist[0] y1 = cy - dist[1] x2 = cx + dist[2] y2 = cy + dist[3] scores = 1 / (1 + np.exp(-cls)) # sigmoid return np.stack([x1, y1, x2, y2]), scores这段代码看起来不复杂,但性能上有个大问题:如果循环遍历 8400 个候选框,Python 的循环开销会直接把推理时间拉爆。后面专门用一节讲怎么把这套逻辑写成纯向量化运算。
2.3 不同导出工具的通道排布差异
这一点值得单独提醒。YOLO26 官方导出脚本和第三方导出工具对输出的预处理是不同的。有的导出阶段已经做了部分解码,输出直接是[1, 4+80, 8400]的 xywh;有的保留 DFL 原始分布,输出是[1, 64+80, 8400]的展开形态;还有的会把三个尺度分别输出三个张量。
我的建议是:不要看名头上写“YOLO26”,直接把 ONNX 拿到 Netron 里看输出节点的 shape 和文档说明,按实际排布写代码。上文给的逻辑是从未解码的[84, 8400]形态入手,如果你手里的 ONNX 是已解码版本,会简单很多——直接把分数和坐标用起来就行。
3. 生产级后处理实现:一份可以直接用的 Python 代码
原理讲完,下面给一份我实际调试过、可以直接跑到生产环境里的 Python 实现。这份代码面向的是 ONNX Runtime,做了三件事:解码所有候选框、过滤低分框、NMS 去重。为了保持可读性,先不贴几十行的完整工程代码,而是拆成函数逐个讲。
3.1 向量化解码:告别循环
8400 个候选框如果用 Python for 循环逐个算,在普通 CPU 机器上基本要花掉几百毫秒。向量化思路是把所有候选框的位置信息做成数组,用 NumPy 整体运算。关键点在于生成三张特征图的网格坐标索引。
import numpy as np def make_grids(stride, grid_h, grid_w): y, x = np.meshgrid(np.arange(grid_h), np.arange(grid_w), indexing='ij') cx = (x.reshape(-1) + 0.5) * stride cy = (y.reshape(-1) + 0.5) * stride return cx.astype(np.float32), cy.astype(np.float32)三个尺度分别生成网格后拼接起来,就得到了 8400 个 anchor 的 cx、cy。解码时把四个距离向量和网格坐标做广播相减/相加,整个 8400 个框的坐标一口气算完,耗时从几百毫秒降到几毫秒,这就是向量化的威力。
重点说一下 DFL 反变换的向量化写法。这里的技巧是用np.take或 reshape 把前 4 通道拆成[4, 16, -1],然后对axis=1做 softmax,再用一组长度为 16 的投影系数做点乘。这份代码要尽量用连续内存布局,避免在循环里频繁做np.exp和np.sum,不然 CPU 缓存命中率很差。
3.2 阈值过滤和坐标裁剪
解码得到 8400 个[x1, y1, x2, y2]浮点坐标后,先不要急着 NMS。第一步是按置信度阈值过滤。一张图上绝大多数候选框是背景,超过 90% 的框分数低于 0.1,直接在数组层面做条件索引就能过滤掉,为后面的 NMS 大幅减压。
另一个容易被忽略的操作是坐标裁剪到图像边界。因为 letterbox 填充区域或特征图边界效应,解码出的框可能会超出原图范围,先统一 clip 到[0, width]和[0, height]之间,后面画框、算 IoU 都省心。
坐标归一化也是常见需求。如果模型训练时是在 640x640 输入下做的,但实际部署时想直接输出原图坐标,就必须记录 letterbox 的 scale 和 pad 值。
# letterbox 逆变换 x1 = (x1 - pad_x) / scale y1 = (y1 - pad_y) / scale x2 = (x2 - pad_x) / scale y2 = (y2 - pad_y) / scale这里的pad_x、pad_y是 letterbox 时在原图左边和上边填充的像素宽度,scale是缩放比例。如果直接用 ONNX 固定 640 输入的话,这套换算关系必须做对,很多人框错位就是这里忘了除回来。
3.3 NMS 的朴素实现与正确姿势
后处理最后一步是 NMS。YOLO26 官方后处理里有很多优化版本,但最通用、最容易调试的还是经典 IoU NMS:按分数降序排列,保留最高分框,删除与之重叠度超过阈值的框,重复到结束。
def nms(boxes, scores, iou_thres=0.45): order = np.argsort(scores)[::-1] keep = [] while order.size > 0: i = order[0] keep.append(i) ious = compute_iou(boxes[i], boxes[order[1:]]) mask = ious <= iou_thres order = order[1:][mask] return keep朴素实现理解起来容易,但生产环境问题不少。一是每轮都要算一次 IoU,时间复杂度近似 O(n^2),框数量大的时候非常慢;二是 Python 循环在嵌入式设备上会造成严重的耗时抖动。行业里常见的做法是用 OpenCV 的cv2.dnn.NMSBoxes或者 TorchVision 的 NMS 算子替代自研实现,在 CPU 上要快一个量级。
如果你不想引入 OpenCV,也可以自己写一个基于矩阵运算的 NMS:一次性算出所有框两两之间的 IoU,再通过抑制矩阵做迭代。实测下来比朴素循环快很多,而且不依赖第三方库,适合对依赖管理比较严格的项目。
下面给出完整版解码 + NMS 串联后的调用方式,便于理解整体流程:
输入: ONNX 推理结果 [1, 84, 8400] -> 拆分 DFL 和分类通道 -> 向量化解码得到 8400 个候选框 -> 阈值过滤 + 坐标裁剪 -> NMS 去重 -> letterbox 逆变换还原到原图 -> 输出最终 boxes, scores, class_ids这个过程里最容易出问题的就是 DFL 和 letterbox 还原,我把两个坑单列出来强调一遍。
4. 性能优化:从 300ms 到 30ms 我做了什么
后处理一开始跑得慢,往往会被人误判成“模型推理慢”。我有一次在 CPU 机器上做基准测试,ONNX Runtime 推理只花 45ms,后处理却花了 260ms,整体延迟惨不忍睹。优化之后后处理压到 25ms 左右。这节把优化链路完整梳理一遍,每一步都有明确收益。
4.1 第一步:把 Python 循环彻底赶出热点
最开始时解码部分用了一个遍历 8400 个点的循环,每个点做两次np.exp和几次加减法。听起来不多,但 Python 解释器每次循环都有巨大的开销,8400 次循环在 CPU 上实测就有 150ms 左右。改成向量化之后,同样的计算只有 8ms 左右。
建议:后处理里的热点代码一律不要用 Python 原生循环,包括把解码结果 append 到 list 再转 numpy 数组这种操作也要避免。正确做法是预先分配固定 shape 的 ndarray,用索引一次性赋值,或者纯广播计算。Numpy 的/、+、np.where在底层都是 C 循环,性能差距是两个数量级。
4.2 第二步:用宽卷积的形式重写 DFL
DFL 反变换里最耗时的部分是 16 个 bin 的 softmax 和加权求和。一个高效技巧是把它看成一次1x1卷积:输入通道 16,输出通道 1,权重就是投影系数[0, 1, ..., 15]。用 NumPy 的tensordot或者卷积算子实现,比直接np.exp+np.sum再乘系数的中间过程少一波内存分配。
当然,ONNX Runtime 本身也内置了Softmax、ReduceSum这些算子,如果你不想在 Python 里做 DFL,可以考虑把 DFL 反变换也集成到 ONNX 模型里。不过这会增加模型图复杂度,有些端侧推理引擎对大图支持不好,我自己的习惯是保持模型输出原始分布,后处理里单独优化。
表格式对比一下常规做法和优化做法的耗时(以 640x640 输入,CPU 单线程为例):
| 步骤 | 朴素实现 | 优化实现 | 性能提升 |
|---|---|---|---|
| DFL 反变换 | 120ms | 4ms | 30倍 |
| 坐标还原 | 80ms | 3ms | 26倍 |
| 阈值过滤 | 30ms | 1ms | 30倍 |
| NMS | 40ms | 8ms | 5倍 |
4.3 第三步:NMS 的降维打法
NMS 是后处理里唯一没法完全避免排序和比较的步骤,优化空间主要在减少参与 NMS 的框数量。阈值过滤设置得当,可以让参与 NMS 的框从几千降到几十,这是最直接有效的优化。
另一个容易被忽视的点是类别维度的处理。常见实现会对每个类别分别做 NMS,比如 80 类就要遍历 80 次,其中有大量类别根本没有框。提前算一个类别层面的最大值索引,只对有框的类别做 NMS,能再省掉大部分时间。类别多的时候这个方法收益非常明显。
我做过的另一个优化是把 IoU 计算里的除法去掉。IoU 的公式是交集面积除以并集面积,除法在浮点运算里开销较高。可以改成只算交集面积,然后用查表或者交叉相乘的方式比较阈值。不过这个优化现在收益有限,现代 CPU 的除法延迟已经低了很多,主要是思路可以借鉴。
5. 换了框架和硬件之后:坑位总结
后处理代码看似简单,真正部署到不同环境时各种隐蔽问题会接连冒出来。下面是我在集成测试中踩过的几个典型坑,每一种都真实导致过结果错乱或性能劣化。
5.1 FP16 精度问题:分数小于零的诡异情况
ONNX 模型经常会转成 FP16 来减少显存占用和带宽。YOLO26 的 DFL 分支对数值精度很敏感,FP16 下 softmax 的指数计算容易造成轻微的数值偏移,反映到结果上就是部分框的置信度整体偏低,甚至有些框的坐标偏移几个像素。
解决方案是给后处理增加一个数值校正层:在 softmax 前先减去最大值再求指数,这个操作在 FP32 里是标准写法,但在 FP16 里尤其重要,能显著提升稳定性。如果还是有问题,建议 DFL 分支保持 FP32 计算,只在模型图里把分类分支转成 FP16,两路混合精度通常可以兼顾速度和精度。
5.2 动态 shape 与固定 shape 的取舍
YOLO26 的 ONNX 导出可以选择动态 batch 和动态输入尺寸。动态 shape 好处是灵活,坏处是不同尺寸下候选框数量不一样,后处理代码必须能够处理变长张量。很多 ONNX Runtime 的优化算子在动态 shape 下会失效,特殊处理反而拖慢速度。
我的建议是线上服务尽量用固定输入尺寸。理由很简单:后处理的网格生成、特征图拼接都可以在初始化时预计算一次,推理时只做张量运算,省心又省时。如果一定要支持动态尺寸,网格缓存就必须要做,不然每次推理重新生成网格索引又是一笔不小的开销。
5.3 onnxruntime 版本带来的算子差异
ONNX Runtime 不同版本之间对算子的实现和优化差异很大。同一个模型文件,在 1.16 版本下正常,升级到 1.19 后发现输出多了些 NaN。这通常是新版算子融合导致的数值行为变化,不是模型本身的问题。
遇到这种问题,先用官方检查工具验证模型,再把输出和旧版本逐一比对。如果数值偏差出现在特定算子,可以在导出时通过opset版本选择来规避,或者直接在推理引擎侧把对应算子的优化关掉。这些操作都是常规手段,核心思路是不要盲目追新版本,生产环境里验证过的组合就是好组合。
我个人的习惯是在项目里锁死 ONNX Runtime 大版本,同时在 CI 里加入一个后处理一致性测试:固定输入、固定随机种子,跑一遍全链路,检查输出框数量和关键坐标是否在容忍范围内。这样升级依赖时有没有引入问题一眼就能看出来。
6. 基于实际经验的两个建议
最后分享两个我调 YOLO26 ONNX 后处理时觉得最有用的经验。
第一个是调试时做一个“可视化模式”。不要只盯着数字比对,把解码后的框直接画在图上保存下来,和 PyTorch 原模型输出做视觉对比。框的数量、位置、类别一眼就能发现问题,比打印一堆数组效率高得多。我每次改后处理代码都会跑一遍可视化脚本,稳定之后再关掉。
第二个是给后处理单独写一个单元测试函数,输入用一个固定转换好的 ONNX tensor,输出和已知结果做断言比对。看似简单,但在后续换推理引擎、调精度、升级依赖时能省下大量回归排查时间。没有这套测试,哪次优化改出了回归都很难发现。