YOLOv8 转 RKNN INT8 量化后置信度全变 0?把输出头拆开就好了

场景:把 YOLOv8(ONNX)转换成瑞芯微 RKNN 格式上 NPU(RK3576),FP16 一切正常,INT8 量化后模型直接"瞎了"——一张缺陷都检不出来。根因是 YOLOv8 的输出结构天生不适合 per-tensor INT8 量化,解法是把输出头拆开。本文完整记录排查过程和解法,转任何 NPU 平台(RKNN、地平线、昇腾)都可能遇到同样的问题。

失败现场:一种"安静的失败"

同一个 ONNX 模型,两种量化模式:

FP16 转换:仿真器跑测试图,检出 2 个框,置信度 0.88,与 OpenVINO 基线一致 ✓
INT8 转换:build 成功、推理不报错、耗时正常,但 8400 个候选框全军覆没,
          没有一张图有任何检出,所有样本一律判 OK ✗

这类问题最折磨人的地方在于没有任何报错:转换成功、推理成功、速度正常,输出形状也对——只是结果全空。如果直接上板才发现,大概率会怀疑人生:怀疑模型、怀疑数据、怀疑驱动,最后才怀疑量化。

排查三步法(可复用到任何 NPU 平台)

遇到"量化后精度异常",我们的排查顺序固定三步,每步排除一半嫌疑:

第一步:FP16 对照,定位是不是量化的锅。 同一模型转 FP16(不量化)跑一遍。FP16 正常 → 问题锁定在量化环节,模型结构和转换流程排除。这一步成本最低,永远先做。

第二步:dump 原始输出张量,跳过后处理。 不要从"检出 0 个框"倒推——后处理有阈值过滤,会掩盖真相。把 INT8 模型的原始输出 (1, 84, 8400) 直接打印出来,看数值分布。

第三步:按通道看数值范围。 输出前 4 行是框坐标、第 5 行是置信度。分别统计各行的 min/max:坐标行范围 0~512 正常,置信度行全部贴着 0——真凶现形。

到这一步,剩下的就是解释"为什么置信度会全变成 0"。

根因:坐标和置信度共用一个量化 scale

YOLOv8 的输出是单个张量 (1, 4+nc, N)

  • 前 4 行是框坐标(xywh),数值范围 0~512(随输入尺寸);
  • 后面 nc 行是类别置信度,数值范围 0~1。

INT8 per-tensor 量化给整个张量算一个统一的缩放系数:

scale ≈ 最大值 / 127 ≈ 512 / 127 ≈ 4.03

scale 由数值范围最大的坐标通道决定。然后看置信度量化后变成什么:

置信度 0.88  →  round(0.88 / 4.03) = round(0.22) = 0
置信度 0.30  →  round(0.30 / 4.03) = round(0.07) = 0

所有置信度全部量化成 0,后处理按阈值一过滤,一个框都不剩。不是模型瞎了,是置信度在量化那一刻就死了。坐标通道受影响不大(损失 1~2 像素精度),所以这个问题极具迷惑性:框的"骨架"还在,只是分数没了。

为什么不能用别的办法

明确根因后,有几个看似更省事的方案,我们逐个排除过:

  • 换 per-channel 量化:理论上每个通道独立 scale 就没事了,但很多 NPU 工具链对输出张量只支持 per-tensor 量化,此路不通或需要折腾工具链私有配置,移植性归零;
  • 改训练导出结构:从训练端就拆两个头导出,当然也行,但要动 ultralytics 的导出代码、维护一个分叉,以后每次换模型都要重走一遍——成本远高于后处理 ONNX;
  • 直接用 FP16:精度是保住了,但 INT8 比 FP16 快 2~3 倍,等于放弃 NPU 最大的卖点,属于认输不是解决。

结论:在后处理的 ONNX 上把输出头拆开,是成本最低、不动训练、不动工具链的解法。

解法:把输出头拆开,各用各的 scale

YOLOv8 导出的 ONNX,末尾是一个 Concat 节点把各检测头的输出拼成单张量。拆分就是找到这个 Concat,把它的输入直接提升为图的输出:

def split_yolo_head(onnx_path, out_dir, out_name):
    import onnx
    model = onnx.load(onnx_path)
    graph = model.graph
    if len(graph.output) != 1:
        return onnx_path                      # 已经是多输出,不用拆

    # 找到产生最终输出的 Concat 节点
    out0 = graph.output[0].name
    concat_node = None
    for node in graph.node:
        if node.op_type == "Concat" and out0 in node.output:
            concat_node = node
            break
    if concat_node is None:
        return onnx_path

    # 形状推断,按第 1 维是不是 4(坐标通道)区分 box 头和 cls 头
    inferred = onnx.shape_inference.infer_shapes(model)
    shapes = {vi.name: [d.dim_value for d in vi.type.tensor_type.shape.dim]
              for vi in list(inferred.graph.value_info) + list(inferred.graph.output)}

    box_heads, cls_heads = [], []
    for name in concat_node.input:
        dims = shapes.get(name)
        if dims and len(dims) == 3 and dims[1] == 4:
            box_heads.append(name)
        else:
            cls_heads.append(name)

    # Concat 的输入直接作为图输出
    del model.graph.output[:]
    for name in box_heads + cls_heads:
        vi = onnx.helper.make_tensor_value_info(name, onnx.TensorProto.FLOAT, shapes[name])
        model.graph.output.append(vi)

    out_path = os.path.join(out_dir, f"{out_name}_split.onnx")
    onnx.save(model, out_path)
    return out_path

拆完后 RKNN 转换时每个输出头独立量化:坐标头 scale 大没关系,置信度头自己一个 scale(≈1/127),0.88 的置信度量化后还能还原到 0.87 左右。

推理侧:合并回去,后处理零改动

检测器拿到多个输出后,按形状认亲、拼回 (1, 4+nc, N) 标准布局,原来的后处理代码一行不改:

def merge_rknn_outputs(outputs):
    """把拆分的 box/cls 输出头合并回 (1, 4+nc, N)。"""
    if len(outputs) == 1:
        return outputs[0]
    boxes = [o for o in outputs if o.ndim == 3 and o.shape[1] == 4]
    others = [o for o in outputs if not (o.ndim == 3 and o.shape[1] == 4)]
    return np.concatenate(boxes + others, axis=1)

这也是我们坚持的一个设计:模型格式的适配集中在"拆分/合并"两个函数里,后处理和业务代码完全不感知 RKNN 的存在——检测器接口与 OpenVINO 版完全一致,配置文件里切换后端即可。

验证:转换完立刻和 CPU 基线对账

不要等上板才发现精度问题。我们的流程是 build 之后立刻用 PC 仿真器init_runtime(target=None))跑评估集,每张图和 OpenVINO/ONNXRuntime 基线对比三项:判定(OK/NG)是否一致、检出框数、最大置信度差值。实测数据(判定全部一致):

量化模式样本置信度偏差
FP16NG 样本0.0003 量级,几乎无损
INT8(拆头后)NG 样本0.006 量级,完全可接受

两个实操细节:

  • 导出的 .rknn 文件不能 load 回仿真器,必须在 build 之后、export 之前直接 init_runtime(target=None) 做验证,顺序错了会浪费一小时排查;
  • 校准图必须用现场实拍图(100~300 张,覆盖光照/角度变化),拿网图或摆拍图校准,INT8 必掉点——和训练集一个道理。

后续:这套模型最终在 RK3576 真机上跑通,INT8 @512 纯推理 20~30ms,逐张判定与仿真器完全一致——仿真器的结论在真机上全部应验(验证流程详见姊妹篇《NPU 量化掉不掉点,别等上板才知道》)。

两个附带的坑

1. opset 版本:rknn-toolkit2 只支持 opset ≤ 19,新版 ultralytics 导出的模型可能更高,转换前用 onnx.version_converter 降级:

from onnx import version_converter
converted = version_converter.convert_version(model, 19)

2. 归一化内化进模型:转换时把 /255 归一化配置进 RKNN(mean_values=[[0,0,0]], std_values=[[255,255,255]]),板端直接喂 uint8 图,省掉 CPU 上的 float 转换——ARM 的 CPU 核很弱,能进 NPU 的计算都别放 CPU。

顺带一个有意思的发现:转换过程生成的中间文件显示,RKNN 把 YOLOv8 里 56 组 Sigmoid + Mul(SiLU 激活)自动融合成了单个 exSwish 算子——NPU 厂商对主流网络的算子融合已经做得挺成熟,不需要手动改网络结构。

小结

  • YOLOv8 单输出张量混合了坐标(0512)和置信度(01),INT8 per-tensor 量化共用一个 scale,置信度被整体压成 0——YOLOv5/v8/v11 的输出结构都有同样隐患;
  • 排查"安静的失败"三步走:FP16 对照定位量化 → dump 原始输出跳过后处理 → 按通道看数值范围;
  • 解法:找到末尾 Concat,把 box/cls 头拆成独立输出,各自量化;推理侧按形状合并回去,后处理零改动;
  • 转换完立刻用仿真器和 CPU 基线对账,别等上板;
  • 这个坑与平台无关,凡是 per-tensor INT8 量化的 NPU 工具链都可能踩到。

系列文章:

  • 《NPU 量化掉不掉点,别等上板才知道:一套 x86 上就能跑完的精度验证流程》
  • 《YOLOv8 部署到无 GPU 工控机实战:ONNX 导出 + OpenVINO 推理 + 手写后处理》
  • 预告:RK3576 NPU 上板实测与 x86 工控机的完整对比,后续更新——欢迎关注。
Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐