恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
YOLO26 ONNX后处理完整指南:解码、NMS与避坑实战
首页
资讯中心
/
YOLO26 ONNX后处理完整指南:解码、NMS与避坑实战
YOLO26 ONNX后处理完整指南:解码、NMS与避坑实战
发布时间:2026/10/19 4:08:21
做模型部署的人都知道模型从训练框架导出成ONNX只算跑完了一半真正决定上线效果好坏的往往是ONNX之后那几百行后处理代码。最近我接到一个YOLO26模型的ONNX部署任务光是后处理就折腾了好几天踩了不少坑也总结出不少经验。这篇就把YOLO26 ONNX后处理的完整思路、代码实现和避坑方法整理出来。无论是刚接触目标检测部署的新手还是已经在用ONNX Runtime做推理但卡在后处理环节的朋友这篇都能给你一个可以直接抄作业的参考方案。1. 先搞清楚一件事ONNX模型到底输出了什么1.1 从训练到导出的输出形态差异很多初学者拿到一个部署任务第一反应是“模型都跑起来了输出框不就出来了吗”实际上完全不是这样。YOLO26这种检测模型在训练阶段内部是有完整后处理链路的包括解码、阈值过滤、非极大值抑制这些都是跟模型权重耦合在一起的逻辑。但导出ONNX时根据导出方的设置不同最终模型文件可能输出完全不同的东西。我习惯把ONNX输出分成三种形态。第一种是原始特征图输出模型只输出多个尺度的特征张量比如常见的三个尺度每个尺度对应一个[B, 4num_classes, H, W]或者[B, num_anchors, 4num_classes]的张量。这时候后处理等于要从零开始解码、阈值、NMS全部自己做。第二种是解码后的预测框输出模型把特征图变成了坐标和类别概率的堆叠比如[B, 8400, 4num_classes]这种形态坐标已经是相对于输入图的数值但还需要做阈值过滤和NMS。第三种是端到端输出模型内部把NMS也做了直接给最终检测结果。这三种形态直接影响你的后处理复杂度。第一、第二种形态都需要外部后处理只是工作量不同第三种形态虽然省事但可调空间很小而且一旦模型导出时集成了某些固定参数线上根本改不动出了问题还难排查。1.2 YOLO26导出配置如何影响后处理我这次拿到的YOLO26权重导出配置是输出原始特征图也就是第一种形态。这其实是最常见也最可控的选择因为部署方可以完全掌握整个后处理逻辑想调置信度阈值就调想换NMS算法就换。不过代价就是后处理代码必须写对否则模型权重再准也是白搭。这里给一个快速判断模型输出形态的方法直接用ONNX Runtime跑一次推理输入一张固定尺寸的图打印每个输出节点的shape和数据范围。如果某个输出shape中有8400、25200这类数字说明已经做了anchor或者网格解码的一部分如果shape中只有8400但数值范围是0到1之间的小数说明大概率需要自己先做sigmoid再解码。还有一个容易被忽略的点是opset版本。导出ONNX时如果opset版本太低某些算子可能被拆成细粒度的小算子推理速度会变慢但opset版本太高部署环境里的ONNX Runtime版本可能不兼容。我一般建议opset设为11到17之间具体看实际运行环境的支持情况。还有一点如果模型导出时打开了动态batch或动态输入尺寸那么后处理代码最好能自适应shape不要写死某个维度值。提示:拿到一个陌生的YOLO26 ONNX模型第一步永远不是写后处理而是先跑一次空白推理把输出张量的shape、dtype、数值分布全部打印出来看清楚。这一步能省掉后面80%的排查时间。2. 后处理管线设计与各环节拆解2.1 预处理对齐整个后处理中最容易出错的一环后处理并不仅仅指“拿到输出张量之后的事情”。预处理和后处理是强耦合的尤其是坐标还原这一步如果没有记住预处理时做了什么变换后处理解出来的框就是歪的。YOLO系列的标准做法是letterbox预处理把原始图像等比缩放然后填充到模型要求的输入尺寸。比如输入要求640x640原始图是1280x720首先计算缩放比例720除以640得到0.5625或者1280除以640得到2取两者中较小的缩放系数这样图像可以完整放入目标尺寸。假设视频横向较长最终缩放到640x360再在上下方向填充黑边直到640x640。这个填充操作就是坐标还原时需要用到的参数。这里有一个关键点letterbox计算出来的缩放系数ratio和填充尺寸pad必须原封不动地传给后处理。解码出来的框坐标都是相对于模型输入尺寸的要还原回原始图像坐标需要先减去pad再除以ratio。很多开发者在这里图省事直接除以输入尺寸和原始尺寸的比值结果框的位置整体偏移越靠近图像边缘偏得越厉害。2.2 解码、阈值筛选与坐标还原YOLO26的后处理解码逻辑和之前的YOLO系列一脉相承。模型输出的特征图上每个网格点对应的数值含义通常是中心点坐标、宽高、类别概率。但具体怎么从特征图数值变成真实坐标不同版本有细微差别。以我处理的这个模型为例输出是三个尺度的特征图每个尺度对应不同的下采样倍率。模型输出的原始值需要先经过sigmoid函数把数值压缩到0到1之间这是为了跟训练时的标签格式对齐。然后按照网格坐标和步长还原。通用的公式是中心点x等于网格横坐标加上预测偏移量再乘以步长中心点y同理宽和高则是预测值经过变换后乘以步长。这个公式在YOLOv5到v8以及后续版本中基本一致只是个别激活函数和缩放系数有调整。解码完成后数千个预测框会同时存在但大部分都是背景或者置信度很低。这时候需要做阈值筛选只保留类别概率高于设定阈值比如0.25的框。筛选完成后再进行坐标还原把相对于模型输入尺寸的坐标转换回原始图像坐标系。2.3 NMS选择适合你的方案NMS的作用一句话说清楚同一只猫被模型框了好几次每框位置稍有偏差NMS把这些重复框合并成最准的那个。NMS的算法本身不复杂按置信度排序选最高的删除所有跟它IoU超过阈值的框然后重复这个过程。但实现方式五花八门性能差异也很大。我在实际项目里用过几种NMS方案。第一种是直接用PyTorch的torchvision.ops.nms优点是代码简洁准确适合调试阶段但依赖PyTorch环境不适合最终轻量化部署。第二种是OpenCV的cv2.dnn.NMSBoxes这个方法C底层实现速度不错且依赖少但有时候返回的索引形态在不同OpenCV版本里不一样需要做好兼容处理。第三种是自己写一个纯Python的NMS逻辑清晰适合理解原理但速度最慢框数量多的时候会比较吃力。如果对性能要求很高比如视频流实时检测可以把NMS下沉到C实现或者直接用TensorRT的NMS插件。这里给一个参考在CPU上处理帧率为1080p的视频一个阈值为0.25、输入框数量约8400的候选集纯Python NMS大约需要几十毫秒到上百毫秒而用C实现可以在几毫秒内完成。这个差距在嵌入式设备上会被放大。3. YOLO26 ONNX后处理实操实现3.1 ONNX Runtime推理与输出形状解析理论讲了不少下面进入实操环节。先说推理环境。我用的是ONNX Runtime的CPU版本版本号不在这里强调因为接口基本稳定。关键在于拿到模型后第一步先解析输出。import onnxruntime as ort import numpy as np session ort.InferenceSession(yolo26.onnx, providers[CPUExecutionProvider]) input_name session.get_inputs()[0].name # 打印输入输出信息 for inp in session.get_inputs(): print(finput: name{inp.name}, shape{inp.shape}, type{inp.type}) for out in session.get_outputs(): print(foutput: name{out.name}, shape{out.shape}, type{out.type})如果输出shape是动态的打印出来的可能是[None, None, None, None]这种这意味着你可以传任意尺寸输入。但注意由于模型本身有下采样倍数限制实际能接受的输入尺寸最好是32的倍数。我这次使用的模型输出是三个名称不同的节点建议按名字取输出而不是按索引因为ONNX图的节点顺序不一定稳定。推理代码也很简单import cv2 import numpy as np def preprocess(image, input_size640): h, w image.shape[:2] ratio min(input_size / h, input_size / w) new_w, new_h int(round(w * ratio)), int(round(h * ratio)) resized cv2.resize(image, (new_w, new_h), interpolationcv2.INTER_LINEAR) canvas np.full((input_size, input_size, 3), 114, dtypenp.float32) dw, dh (input_size - new_w) // 2, (input_size - new_h) // 2 canvas[dh:dh new_h, dw:dw new_w, :] resized # HWC - CHW, BGR - RGB blob canvas[:, :, ::-1].transpose(2, 0, 1).copy() blob blob / 255.0 blob np.expand_dims(blob, axis0).astype(np.float32) return blob, ratio, dw, dh3.2 后处理完整代码实现接下来是核心的后处理实现。这段代码我基于最常见的YOLO系解码公式编写如果你手里的YOLO26导出时做了特殊定制可能需要稍微调整一下缩放系数但整体框架是通用的。代码里我加了详细注释方便理解每一步在干什么。def sigmoid(x): return 1.0 / (1.0 np.exp(-x)) def decode_outputs(predictions, input_size640, strides(8, 16, 32), conf_thres0.25): 将多尺度特征图解码为检测框列表 predictions: list of numpy arrays, 元素shape为 [B, 4num_classes, H, W] boxes [] scores [] class_ids [] for feat, stride in zip(predictions, strides): # 压缩batch维度 feat np.squeeze(feat, axis0) # [4num_classes, H, W] num_classes feat.shape[0] - 4 h, w feat.shape[1], feat.shape[2] # 生成网格坐标 y_grid, x_grid np.meshgrid(np.arange(h), np.arange(w), indexingij) grid np.stack([x_grid, y_grid], axis-1).astype(np.float32) grid grid.transpose(2, 0, 1) # [2, H, W] # 解析 feat sigmoid(feat) # 中心点坐标解码: xy (grid offset) * stride cx (grid[0:1, :, :] feat[0:1, :, :]) * stride cy (grid[1:2, :, :] feat[1:2, :, :]) * stride # 宽高解码: wh (offset * 2)^2 * stride bw (feat[2:3, :, :] * 2) ** 2 * stride bh (feat[3:4, :, :] * 2) ** 2 * stride # 转为 x1y1x2y2 格式 x1 cx - bw / 2.0 y1 cy - bh / 2.0 x2 cx bw / 2.0 y2 cy bh / 2.0 cls_scores feat[4:, :, :] # [num_classes, H, W] cls_max np.max(cls_scores, axis0) cls_id np.argmax(cls_scores, axis0) # 阈值筛选 mask cls_max conf_thres if mask.sum() 0: continue x1 x1[mask].reshape(-1) y1 y1[mask].reshape(-1) x2 x2[mask].reshape(-1) y2 y2[mask].reshape(-1) cls_max cls_max[mask].reshape(-1) cls_id cls_id[mask].reshape(-1) boxes.append(np.stack([x1, y1, x2, y2], axis-1)) scores.append(cls_max) class_ids.append(cls_id) if len(boxes) 0: return np.zeros((0, 4)), np.zeros((0, 1)), np.zeros((0, 1), dtypenp.int32) boxes np.concatenate(boxes, axis0) scores np.concatenate(scores, axis0) class_ids np.concatenate(class_ids, axis0) return boxes, scores, class_ids然后还需要一个简单的NMS实现。这里我写一个不依赖第三方库的版本方便理解。实际项目里如果数据量大可以用torchvision或者C版。def nms(boxes, scores, iou_thres0.5): x1, y1, x2, y2 boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] areas (x2 - x1) * (y2 - y1) order scores.argsort()[::-1] keep [] while order.size 0: i order[0] keep.append(i) xx1 np.maximum(x1[i], x1[order[1:]]) yy1 np.maximum(y1[i], y1[order[1:]]) xx2 np.minimum(x2[i], x2[order[1:]]) yy2 np.minimum(y2[i], y2[order[1:]]) inter np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1) iou inter / (areas[i] areas[order[1:]] - inter) inds np.where(iou iou_thres)[0] order order[inds 1] return keep3.3 代码执行的验证要点写完代码别急着上生产先做两步验证。第一步是拿一张包含多个目标的测试图把模型推理出的原始输出用后处理代码跑一遍然后把检测框画到图上人工检查。如果框的位置大致正确但边缘粗糙说明解码公式里某个缩放系数不对如果框整体偏移说明letterbox参数传错了或坐标还原写反了如果框的尺寸明显偏大或偏小说明宽高解码公式需要调整。第二步是数值层面的校验把后处理得到的坐标与模型训练阶段的解码输出对比。当然很多情况下你拿不到训练时的参考输出那就用可视化验证。这里还有个调试技巧在处理大图之前先用一个只有单目标、背景简单的图片测试。单目标情况下看框的位置和大小是否合理比一堆目标混在一起容易判断得多。调通之后再换复杂图片。4. 常见问题与排查技巧实录4.1 检测框整体偏移或错位这是后处理里最常见的问题我这次也踩了。现象是检测框能框住物体但位置整体偏右下方或者偏左上而且偏移量跟目标位置相关。排查思路其实就三步。第一步检查letterbox参数。预处理填充的dw和dh用于坐标还原时必须减去填充值再除以缩放比例。注意有些代码习惯在预处理时用dw (input_size - new_w) / 2如果图像是奇数尺寸这里可能除不尽产生0.5像素的偏移。对于大部分检测任务0.5像素可以忽略但如果你做的是像素级精细检测最好用round()处理。第二步检查解码时网格坐标生成是否正确。np.meshgrid生成网格时注意默认的indexing参数我见过不少人在xy和yx上搞混导致整个特征图平面被转置。第三步检查输出张量的排列顺序。ONNX Runtime返回的数组可能是[B, C, H, W]也可能是[B, H, W, C]如果模型内部已经做了transpose而你又在后处理里做了一次形状对了但坐标全乱。4.2 后处理性能瓶颈与优化后处理耗时占比往往比很多人想象的高。在一次性能分析中模型推理耗时约20毫秒但我最初写的Python后处理耗时竟然超过了100毫秒其中大部分消耗在解码环节数百个嵌套循环和NMS的大量数组操作上。优化思路主要有几个。第一个是向量化。把所有循环改成numpy矩阵运算比如网格坐标全部用meshgrid生成解码时整体矩阵操作阈值筛选用布尔mask这样能把时间降到十几毫秒。第二个是在解码之后就立刻做阈值筛选而不是先构造数千个候选框再过滤。候选框少了后面的NMS压力会小很多。第三个是合理选择NMS算法。如果输入框数量减少到几百个纯Python NMS也还好如果数量几千上万建议用torchvision.ops.nms在GPU环境下几乎可以忽略不计。第四个是如果你想更极致一点可以在模型导出时把解码逻辑也融合到ONNX里这样前端只需要做NMS和坐标还原后处理耗时能再降一个量级。不过这需要你改导出脚本而且灵活性会降低。4.3 工程化落地经验补充工程化阶段有几个容易忽略的细节。一个是多batch推理。如果后处理代码里写死了batch为1而实际推理时传入了batch为4的输入解析坐标的时候就会错位。我习惯把后处理函数设计成接受batch维度的数组如果模型输出是[B, C, H, W]就先循环batch再解码或者在解码时保留batch维度。另一个是坐标精度问题。后处理过程中拿到的坐标是浮点数而最终画框或者做业务逻辑时可能需要整数坐标。但要注意不要在后处理早期就把坐标取整因为取整误差会一路累积。正确做法是直到最后输出阶段把坐标转换成整数并做边界裁剪限制在原始图像尺寸范围内否则框的坐标可能出现负值或者超出图像宽度。还有一个经验是关于置信度阈值的选择。0.25是一个比较通用的初始值但它不是万能的。如果你的模型在召回率上表现不佳可以往下调到0.15或0.1来观察如果误检太多就往上调到0.4或0.5。在实际项目里我通常会把阈值作为配置文件参数暴露出来而不是写死在代码里。这样调优的时候不用重新编译和重新部署。最后再分享一个我踩得比较深的坑有一次我手里的YOLO26模型是动态输入尺寸的ONNX我按照640的尺寸做完后处理换到1280推理时报错一查原因是预处理时我用固定640算了letterbox参数但模型实际接收的是1280的输入坐标还原时一部分坐标超出了模型输入尺寸范围。后来我把输入尺寸参数也做成了动态获取当前端传入什么尺寸预处理和后处理就用对应的值问题就解决了。所以写后处理代码时一个重要的原则是所有跟尺寸相关的参数都不要硬编码能动态拿就动态拿。这条路走下来虽然踩的坑不少但把这套YOLO26 ONNX后处理流程理通之后后续再迁移到其他模型版本基本就是改个解码公式的事整体的框架完全可以复用。