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 上最常踩的坑

  1. LetterBox 坐标还原:如果你预处理做了 padding + resize,输出的坐标是 LetterBox 后的,必须 (x - pad) / scale 还原
  2. BGR vs RGB:ONNX 模型通常吃 RGB,但 OpenCV 默认读 BGR,颜色通道反了会导致置信度整体偏低
  3. 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 调试技巧

当发现"同一张图,不同平台结果不一样"时,按以下顺序排查:

  1. 预处理是否一致:打印输入 tensor 的 mean/std
  2. 输出 shape 是否正确:确认是 (1,300,6) 还是 (1,84,8400)
  3. 置信度数量级:正常应在 [0, 1],如果全在 1e-5 级别 → Sigmoid 丢失
  4. 坐标是否在合理范围:不应出现负数或超过图像尺寸的坐标
  5. 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)的细节可能略有差异,但核心思路是通用的。

THE END