YOLO 检测算法后处理:ONNX、昇腾与 RKNN 三大平台的差异全解析
从 PyTorch 训练完一个 YOLO 模型到真正在边缘设备上跑起来,后处理(Post-processing) 往往是坑最多的一环。同一个模型,ONNX Runtime 跑得好好的,搬到昇腾 NPU 上置信度全变成 0.0001,再换到 RK3588 上坐标全乱套——这些问题的根因几乎都出在后处理的差异上。
本文从 YOLO 的输出格式演变讲起,逐一拆解 ONNX Runtime、华为昇腾(Ascend)和瑞芯微 RKNN 三大平台在后处理上的核心差异,帮你避开那些"模型能跑但结果不对"的坑。
一、先理解:YOLO 的输出到底是什么?
在讨论平台差异之前,必须先搞清楚 YOLO 模型推理后吐出的是什么东西——这直接决定了你需要写什么样的后处理代码。
1.1 经典格式(YOLOv5 / v8 / v11):原始特征图
输出形状:(1, 4 + num_classes + 1, N_anchors)
典型值: (1, 84, 8400) ← 对 80 类的 COCO 模型
这是一个未解码的原始张量。每一列是一个候选框(anchor),包含:
| 字段 | 位置 | 含义 | 需要做的处理 |
|---|---|---|---|
| cx, cy | 索引 0,1 | 相对于网格的偏移 | grid_x + cx、grid_y + cy |
| w, h | 索引 2,3 | 相对于 anchor 的宽高 | anchor_w × exp(w)、anchor_h × exp(h) |
| bbox_conf | 索引 4 | 有无物体的分数 | Sigmoid |
| class_probs | 索引 5+ | 每个类别的分数 | 乘上 bbox_conf,取 max |
后处理流程(每一步都要你自己写):
模型输出
→ Sigmoid(置信度 / 分类分数)
→ Decode(网格偏移 → 绝对坐标)
→ Confidence Filter(阈值过滤)
→ NMS(非极大值抑制)
→ 最终检测结果
1.2 端到端格式(YOLO26 新一代):已解码的最终结果
YOLO26 引入了一对一头(One-to-One Head),训练时就学习去重,推理时直接输出最终检测框。
输出形状:(1, 300, 6) ← 检测任务
格式: [x1, y1, x2, y2, confidence, class_id]
| 特点 | 说明 |
|---|---|
| 坐标格式 | 已经是 xyxy 绝对坐标(不需要 decode) |
| NMS | 不需要! 模型内部已完成去重 |
| 后处理 | 仅需一步:conf > threshold 过滤 |
⚠️ 重要:并非所有平台都原生支持端到端格式。导出的 ONNX 支持,但 RKNN 和部分边缘格式会自动回退到经典格式,你必须自己写 NMS。
二、ONNX Runtime:最"标准"的基准线
2.1 两种导出方式,两种后处理
| 导出方式 | 输出形状 | 后处理复杂度 |
|---|---|---|
| model.export(format="onnx", end2end=True) | (1, 300, 6) | ⭐ 极简:仅置信度过滤 |
| model.export(format="onnx", end2end=False) | (1, 84, 8400) | ⭐⭐⭐⭐ 全手动:Decode + NMS |
2.2 端到端 ONNX 的后处理(推荐方式)
import onnxruntime as ort
import numpy as np
session = ort.InferenceSession("yolo26.onnx")
output = session.run(None, {"images": input_tensor})
# 输出就是 (1, 300, 6),[x1, y1, x2, y2, conf, cls_id]
dets = output[0][0] # 去掉 batch 维
dets = dets[dets[:, 4] > 0.25] # 仅此一步!
for d in dets:
x1, y1, x2, y2, conf, cls_id = d
print(f"检测到: class={int(cls_id)}, conf={conf:.3f}, box=[{x1:.0f},{y1:.0f},{x2:.0f},{y2:.0f}]")
特点总结:
- ✅ 精度无损:ONNX Runtime 原生支持 FP32 / FP16,不存在量化信息丢失
- ✅ Sigmoid 完整:模型内所有激活函数完整保留,置信度输出即
[0, 1] - ✅ 坐标无偏:输出就是原图坐标,无需 Inverse LetterBox(部分导出选项已嵌入预处理)
- ⚠️ 注意:默认输出是推理输入尺寸的坐标,如果做了 LetterBox,需要手动还原
2.3 ONNX 上最常踩的坑
- LetterBox 坐标还原:如果你预处理做了 padding + resize,输出的坐标是 LetterBox 后的,必须
(x - pad) / scale还原 - BGR vs RGB:ONNX 模型通常吃 RGB,但 OpenCV 默认读 BGR,颜色通道反了会导致置信度整体偏低
- NCHW vs NHWC:ONNX 标准格式是
(1, 3, H, W),注意和 OpenCV 的(H, W, 3)区分
三、华为昇腾(Ascend):ATC 转换后的隐秘变量
昇腾平台的流程是 PyTorch → ONNX → ATC → OM。多了 atc 这一步编译器转换,后处理的隐患也集中在这里。
3.1 ATC 编译做了什么
atc --model=yolo26.onnx \
--framework=5 \ # ONNX 框架
--output=yolo26.om \ # 输出的离线模型
--soc_version=Ascend310B1 \ # 芯片型号
--input_shape="images:1,3,1280,1280" \
--input_format=ND
ATC 会对 ONNX 图进行算子融合、内存优化、精度转换。正是这些优化,可能导致后处理行为发生微妙变化。
3.2 核心差异一:Sigmoid 激活层的"消失"
这是昇腾部署中最隐蔽、影响最大的问题。
原因:当使用 FP16 或 INT8 量化编译 OM 模型时,ATC 的图优化可能将 Sigmoid 与其他算子融合,或因为量化精度问题丢失该层的数值语义。结果是:
| 期望输出 | 实际输出 | 后果 |
|---|---|---|
| conf = 0.95([0,1] 置信度) | conf = 1.2 或 -3.8(原始 logit) | 阈值过滤失效,全部丢弃或全误检 |
解决方案:在后处理代码中手动补上 Sigmoid。
def _sigmoid(x):
"""补偿 OM 模型中可能丢失的 Sigmoid 激活"""
return 1.0 / (1.0 + np.exp(-x))
# 后处理时
for det in output:
x1, y1, x2, y2, raw_conf, cls_id = det[:6]
conf = _sigmoid(raw_conf) # 手动 Sigmoid
if conf < 0.25:
continue
# ... 后续处理
💡 判断是否需要手动 Sigmoid:
- 如果日志显示
max_conf=0.000026(远小于 0.25),且原 PyTorch 模型能正常检出 → 大概率丢失了 Sigmoid- 如果 FP32 编译的 OM 模型
max_conf=0.95,说明 Sigmoid 完整 → 不需要手动补
3.3 核心差异二:输入尺寸固定
atc 编译时需要指定 --input_shape,编译后的 OM 模型只能接受该固定尺寸。
--input_shape="images:1,3,1280,1280" → 永远只能输入 1280×1280
这和 ONNX Runtime 不同——ONNX 可以支持动态输入尺寸(如果导出时指定了 dynamic_axes)。OM 模型必须预处理时严格匹配,否则直接报错。
3.4 核心差异三:ACL API 的内存管理
昇腾使用 ACL(Ascend Computing Language)进行推理,需要手动管理 Device ↔ Host 内存拷贝:
# 输入:Host → Device
acl.rt.memcpy(input_buffer, input_size, input_data_ptr, input_size, memcpy_h2d)
# 推理
acl.rt.model_execute(model_ptr, stream)
# 输出:Device → Host
acl.rt.memcpy(output_data_ptr, output_size, output_buffer, output_size, memcpy_d2h)
对后处理的影响:输出数据的 dtype 依赖 acl.mdl.get_output_data_type() 获取,可能是 float32(dtype=0)或 float16(dtype=1)。需要根据实际 dtype 做 astype(np.float32) 转换后再进行后处理计算。
3.5 昇腾后处理检查清单
| 检查项 | 方法 |
|---|---|
| Sigmoid 是否丢失 | 看 max_conf 数量级,远小于 0.25 则需手动补 |
| 输出 dtype | 日志中 Output[0]: dtype=0 表示 FP32,dtype=1 表示 FP16 |
| imgsz 是否匹配 | OM 模型的 Input[0]: shape=(1,3,?,?) 必须和预处理一致 |
| 坐标是否需要还原 | 如果做了 LetterBox,需要 (x - pad) / scale |
四、瑞芯微 RKNN(Rockchip NPU):后处理"乾坤大挪移"
RK3588 / RK3568 等芯片搭载的 NPU 是专门为边缘推理优化的,它的后处理策略和前两个平台截然不同。
4.1 NPU 擅长什么,不擅长什么
| 操作 | NPU 效率 | 处理方式 |
|---|---|---|
| 卷积、矩阵运算 | ⭐⭐⭐⭐⭐ 极高效 | NPU 上跑 |
| Sigmoid / Softmax | ⭐⭐ 中等 | 保留在模型中,NPU 跑 |
| DFL(Distribution Focal Loss) | ⭐ 低效 | 移到 CPU 后处理 |
| NMS | ⭐ 低效 | 移到 CPU 后处理 |
| 坐标运算 | ⭐ 低效 | 移到 CPU 后处理 |
4.2 "后处理前置"策略
为了最大限度利用 NPU 算力,RKNN 的做法是把模型中原有的后处理部分从模型中拆出来,放到模型外的 CPU 代码中执行。
这就是为什么 RKNN 的 YOLO 部署有一个独特步骤——修改 ONNX 导出脚本,移除 DFL 层:
# 标准 Ultralytics 导出的 ONNX:包含 DFL
# RKNN 需要的 ONNX:去掉 DFL,模型只输出原始特征
# 典型修改:在 export 前 hook 掉 DFL 相关操作
# 把 DFL 的 Softmax + Conv 换成直接输出 reg_max 维度的原始值
4.3 RKNN 的实际输出格式
移除 DFL 后,模型输出变为:
输出形状:(1, reg_max × 4 + num_classes, N_anchors)
典型值: (1, 64 + 80, 8400) ← reg_max=16, num_classes=80
也就是说,坐标信息保留了 reg_max × 4 = 64 维的原始分布,需要在后处理中手动做 DFL 解码。
4.4 RKNN 后处理完整流程
RKNN 模型输出
→ 拆分:前 reg_max×4 维 = 坐标分布,后 num_classes 维 = 分类分数
→ DFL Decode(Softmax + 加权求和 → cx, cy, w, h)
→ Dist2BBox(网格偏移 → 绝对坐标)
→ Sigmoid(分类分数)
→ Confidence Filter
→ NMS(手动实现)
→ 坐标还原(LetterBox Inverse)
对比 ONNX 端到端:RKNN 多了整整 5 个额外步骤。
4.5 RKNN 特有的量化问题
RK3588 NPU 对 INT8 量化最友好。但量化会引入额外精度损失:
| 操作 | INT8 量化影响 |
|---|---|
| Sigmoid | 输出范围被压到 0~255,再缩放回 [0,1],精度降低 |
| DFL Softmax | 指数运算对量化敏感,建议移到 CPU 做 |
| 小置信度值 | INT8 下可能直接被量化为 0 |
缓解策略:
- 使用混合量化(Mixed Precision):对精度敏感层保持 FP16,其余 INT8
- 校准数据集选取与推理场景分布一致的样本
- 适当降低置信度阈值(INT8 模型建议从 0.25 降到 0.15~0.20)
4.6 RKNN 后处理检查清单
| 检查项 | 注意 |
|---|---|
| DFL 是否已移除 | 确认导出脚本已去掉 DFL 层,否则 NPU 跑 DFL 速度极慢 |
| reg_max 值 | 检查模型配置,DFL 的 reg_max 通常是 16 |
| 置信度阈值 | INT8 量化后需适当降低 |
| 是否需要 NMS | 需要! RKNN 不支持端到端格式 |
| 多尺度输出 | 经典 YOLO 有 3 个尺度的输出头,需分别处理 |
五、三平台完整对比
| 对比维度 | ONNX Runtime | 昇腾(Ascend OM) | RKNN(Rockchip NPU) |
|---|---|---|---|
| 端到端格式支持 | ✅ 原生支持 | ⚠️ 取决于 ONNX→OM 转换 | ❌ 不支持,自动回退 |
| 输出格式 | (1, 300, 6) xyxy |
同 ONNX(转换后保留) | (1, 64+80, 8400) 原始分布 |
| 需要 NMS | 端到端:不需要 | 端到端:不需要 | 需要! |
| Sigmoid 风险 | ✅ 无风险 | ⚠️ FP16/INT8 可能丢失 | ⚠️ INT8 量化精度降低 |
| DFL 位置 | 模型内部 | 模型内部 | 模型外部(CPU 后处理) |
| 坐标格式 | xyxy 绝对坐标 | xyxy 绝对坐标 | xywh 相对偏移(需 decode) |
| 输入尺寸 | 可动态 | 编译时固定 | 编译时固定 |
| 推理精度 | FP32 / FP16 | FP32 / FP16 / INT8 | INT8 为主,少量 FP16 |
| 后处理代码量 | ~5 行 | ~10 行(+ Sigmoid 判断) | ~200 行(DFL + NMS + decode) |
| 推理 API | session.run() | ACL(Device/Host 内存拷贝) | rknn.inference() |
六、跨平台部署最佳实践
6.1 统一预处理
无论哪个平台,预处理逻辑必须一致,否则后处理怎么调都没用:
原始图像
→ BGR → RGB(如果模型训练时用的是 RGB)
→ Resize + Padding(LetterBox)
→ 归一化:pixels / 255.0 → [0, 1]
→ NCHW: (H, W, 3) → (1, 3, H, W)
统一验证方法:对同一张图,在各平台上打印
input_tensor.mean()和input_tensor.std(),确认数值完全一致。
6.2 平台选择的决策树
你需要的精度?
├── FP32,不量化
│ └── 模型是端到端格式吗?
│ ├── 是 → ONNX Runtime 或 昇腾 FP32,后处理最简单
│ └── 否 → 三平台都要写 decode + NMS
│
├── FP16,半精度
│ ├── 桌面/服务器 → ONNX Runtime(FP16 模式)或 TensorRT
│ └── 昇腾边缘 → ATC FP16,⚠️ 注意检查 Sigmoid
│
└── INT8,极致性能
├── 昇腾 → ATC INT8,⚠️ Sigmoid + 精度双重风险
└── RKNN → 唯一选择,⚠️ 需要写完整后处理
6.3 调试技巧
当发现"同一张图,不同平台结果不一样"时,按以下顺序排查:
- 预处理是否一致:打印输入 tensor 的
mean/std - 输出 shape 是否正确:确认是
(1,300,6)还是(1,84,8400) - 置信度数量级:正常应在 [0, 1],如果全在
1e-5级别 → Sigmoid 丢失 - 坐标是否在合理范围:不应出现负数或超过图像尺寸的坐标
- dtype 是否匹配:昇腾 OM 模型的输出 dtype 可能是 FP16
七、总结
ONNX、昇腾、RKNN 三平台的后处理差异,本质上是 "谁来做后处理" 和 "以什么精度做" 两个问题的不同答案:
| 平台 | 谁做后处理 | 以什么精度做 |
|---|---|---|
| ONNX Runtime | 模型自己(端到端) | FP32/FP16,无精度损失 |
| 昇腾 Ascend | 模型自己 + 可能需补偿 | FP32 安全,FP16/INT8 需防 Sigmoid 丢失 |
| RKNN | 大部分移到 CPU 跑 | INT8 为主,后处理在 FP32 CPU |
理解了这个本质,你在跨平台部署时就能快速定位并解决问题——不是一个"玄学 Bug",而是有清晰原因的工程问题。
本文基于 YOLO26 端到端架构与三大主流推理平台的实际部署经验撰写。不同 YOLO 版本(v5/v8/v11/v12)的细节可能略有差异,但核心思路是通用的。