YOLOv8 转 RKNN INT8 量化后置信度全变 0?把输出头拆开就好了
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)是否一致、检出框数、最大置信度差值。实测数据(判定全部一致):
| 量化模式 | 样本 | 置信度偏差 |
|---|---|---|
| FP16 | NG 样本 | 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 工控机的完整对比,后续更新——欢迎关注。
更多推荐




所有评论(0)