把 YOLOv8s 量化成 int8部署到 RK3588 上。画面里人的位置能被框住但 coco 80 类的置信度很乱行人只有 0.4旁边的椅子反而到 0.6。第一反应是训练数据问题第二反应是校准集没选好折腾到最后才发现问题可能出在输出层的组织结构上。默认导出模型只有一个输出节点里面同时装着 4 个回归坐标和 80 个分类得分经过 RKNN 逐张量量化之后分类分支的动态范围被拉低得分自然失真。这件事让我重新认识了“单头输出”和“双头输出”在边缘部署里的差别。在 RK3588 上部署 YOLOv8 时把默认的单头输出改成双头输出不是一个炫技操作而是和 NPU 量化机制、后处理流程强相关的实际需求。这篇就把整个改法拆开讲清楚包括 YOLOv8 输出层到底输出了什么、为什么要在 RK3588 上用双头、模型代码怎么改、转换和板端部署怎么配、后处理怎么适配以及哪些场景其实不必改。1. 输出层拆开看一个“头”里其实装了两类信息1.1 默认的 detect head 是怎么拼接的YOLOv8 的检测头在模型结构里叫 Detect输入是 P3、P4、P5 三个尺度的特征图。以 640x640 输入为例P3、P4、P5 对应的 stride 分别是 8、16、32特征图尺寸是 80x80、40x40、20x20。三个尺度加起来会产生 8400 个 anchor 位置也就是 80x80 40x40 20x20。每个尺度的特征图在 Detect 内部会分别走两套卷积分支一条负责框的回归一条负责类别判断。回归分支和分类分支的输出会在最后被拼到同一个张量里。所以在默认导出模型里你看到的输出是一个形状为[1, 84, 8400]的单一节点。这里的 84在 COCO 数据集上是 4 804 是坐标回归参数80 是类别分数。这算是默认的“单头输出”形态。注意这里说的“单头”不是指只有一个检测头而是指最终导出模型的输出节点只有一个。三个尺度在空间维上被合并坐标和分类在通道维上被合并于是所有信息都藏在一个二维矩阵里。如果只是 PyTorch 里做推理这个拼接没什么问题。浮点计算下每一种特征都能保留足够的数值精度。真正的问题出现在把模型转成 RKNN int8 量化的那一刻。这时候输出张量内部的“结构语义”开始直接影响量化效果。一个 84 维的输出通道里前 4 维是连续变化的坐标量后 80 维是接近 0 或 1 的分类概率。NPU 量化时按整个张量去估计数值范围得出的 scale 是折中值。对坐标来说折中可能够了对类别分数来说折中往往会让它失真。很多人把这归因于数据集或校准集实际上输出张量的组织形式就已经埋下隐患。1.2 这个拼接在浮点推理里没毛病在量化场景里要重新思考你可以把单头输出想象成把两张不同量纲的表格拼在一张 Excel 里一列是坐标一列是置信度。浮点运算下 Excel 能完整显示但如果你把它们统一压缩成整数就必须选同一个缩放比例。坐标范围小但变化密集置信度范围接近 0 到 1 但分布极不均匀一个缩放比例很难同时满足两边的精度需求。双头输出的本质就是让这两列数据各用各的缩放比例。不过光知道这个还不够。要判断自己到底该不该改得先理解 RKNN 量化机制对输出张量的实际处理逻辑。RKNN-Toolkit2 在做 int8 量化时会为每个需要量化的张量确定 scale 和 zero_point。这个统计过程是“面向整个张量”的不是面向通道的。也就是说一个 84 维的输出张量不管内部是不是由两个语义完全不同的部分组成量化器只会算出一套全局缩放参数。这套全局缩放参数理想情况下要能同时表达“目标在图像上的位置”和“目标属于某个类别的概率”。但这两个信息在分布上天然不同量化器必须做一个折中。折中的结果往往是坐标大体可用分类分数明显失真。如果你在 RK3588 上做过 int8 量化大概率遇到过类似现象框的位置还准但置信度整体偏低或者某些类别分数忽高忽低看起来像后处理问题实则和输出结构有关。2. 为什么在 RK3588 上双头比单头更适合2.1 RKNN 量化关心的是张量的统计分布不是逻辑通道RKNN 量化时一个输出张量如果同时包含回归值和分类分数它的统计分布实际上是两个混合分布。NPU 只会从整个张量范围取一个全局缩放比这样不能分别刻画“坐标的局部变化”和“类别分数的集中分布”。当这种情况出现时后果通常不是检测不出目标而是检测框位置基本靠谱、但分类分数明显偏低或偏高。坐标量在整个张量里的数值范围比较稳定分类分数却有大量接近 0 的负样本和少量接近 1 的正样本全局 scale 会被拉向某个中间位置。这时把单头输出改成双头输出相当于告诉量化器坐标归坐标分类归分类各自统计各自的范围。RKNN 会对 box_out 和 cls_out 两个输出张量分别估计量化参数分类分支的集中分布能被保留得更完整。实际量化后分类置信度通常会有明显恢复。这个改动不是优化模型本身而是优化模型与 NPU 量化机制的匹配度。2.2 双头输出的第二个价值不是精度而是可维护性单头输出在后处理阶段需要一个固定认知前 4 行是坐标后面 80 行是类别。代码里经常出现切片魔法数字比如out[:, 0:4, :]、out[:, 4:, :]。双头输出让后处理代码与模型结构的关系更直接。box_out 就是一个 4 行的坐标矩阵cls_out 就是一个 80 行的分类矩阵。无论后续是扩展类别数量、调整检测头还是叠加额外的回归输出接口表达都更清晰。在 RK3588 这种频繁要调试模型、换模型的部署场景里这种清晰度很值钱。尤其在多人协作的工程里一个只看过后处理代码的同事不需要理解 YOLOv8 的通道布局也能接手维护。2.3 但并不是所有 RK3588 项目都需要双头如果自定义数据集的类别数很少比如只有 1 到 3 类输出通道从 84 降到 7 或 10单头张量要承载的信息量小了很多全局 scale 造成的影响没那么明显。此时可以先保持单头把精力放在校准集质量、量化混合精度和预处理一致性上。另一个场景是已经用了 i16 或 fp16 混合量化或者明确把输出层设置为较高精度。量化损失被混合精度抵消后再拆输出头的收益就非常有限。如果原有流程已经稳定没必要为了“结构更清晰”去动模型。所以下面的实操主要针对这个条件COCO 80 类或类别数量较多、int8 量化后分类置信度明显漂移、并且已经排除了数据校准和预处理问题的场景。3. 动手把 YOLOv8 的单头输出改成双头3.1 修改 Detect 的 forward让两个分支各走各的如果你使用 ultralytics 的 YOLOv8 训练源码改动点集中在 Detect 类的 forward 上。默认流程在循环里对每个尺度做卷积然后把回归分支和分类分支 cat 到通道维。改成双头就是把 cat 这步拆开分别收集、分别 concat。下面是一个示例结构基于 ultralytics 8.x 的常见写法但省略了一部分和具体训练流程耦合的细节。不同小版本的导出实现有差异落地时要以你自己 fork 的源码为准。# 示例结构重点看输出如何拆分DFL 解码和 sigmoid 需要保留在模型内 class Detect(nn.Module): def forward(self, x): box_outputs [] cls_outputs [] for i in range(self.nl): # 回归分支经过 conv 后通常是 [B, 4 * reg_max, H, W] reg_out self.cv2[i](x[i]) # 分类分支经过 conv 后通常是 [B, num_cls, H, W] cls_out torch.sigmoid(self.cv3[i](x[i])) # 这里原本会做 DFL 解码和 stride 缩放再和 cls cat # 拆双头后回归分支和分类分支先分开收集 box_out self.decode_bbox(reg_out, strideself.stride[i]) box_outputs.append(box_out.flatten(2)) cls_outputs.append(cls_out.flatten(2)) # 三个尺度在空间维拼接 box_out torch.cat(box_outputs, 2) cls_out torch.cat(cls_outputs, 2) return box_out, cls_out这个示例的重点是回归分支和分类分支不再走同一个 cat 拼接点而是各自产出独立张量。注意 YOLOv8 的 box 分支默认输出的是4 * reg_max个通道reg_max 默认是 16也就是 64 个通道必须经过 DFL 解码才能得到 4 个坐标参数。在 ultralytics 导出 ONNX 时通常已经在模型内做了 DFL 解码但如果你修改的是早期版本的导出流程或者自己写了导出脚本拿到的 box_out 第一维可能不是 4而是 64。改完模型之后第一件事不是直接转 RKNN而是先用一个小输入把 PyTorch 模型跑一遍确认两个输出的 shape 符合预期。3.2 导出 ONNX确认输出节点只有两个模型 forward 改好之后导出 ONNX 的代码可以沿用原流程只是 output_names 里要写两个名字。import torch model load_yolov8s_dual_model() model.eval() dummy_input torch.randn(1, 3, 640, 640) torch.onnx.export( model, dummy_input, yolov8s_dual_output.onnx, input_names[images], output_names[box_out, cls_out], opset_version12, dynamic_axes{images: {0: batch}, box_out: {0: batch}, cls_out: {0: batch}}, )这里把输出节点名称定为box_out和cls_out。推荐用固定名称后续在 RKNN 配置和板端代码里都能直接对照避免靠记忆索引。导出之后检查 ONNX 的输出节点python -c import onnx; m onnx.load(yolov8s_dual_output.onnx); print([o.name for o in m.graph.output])如果看到[box_out, cls_out]说明结构对了一半。接下来还要确认每个输出的 shape。3.3 验证 box_out 的通道数到底是 4 还是 64有一个很关键的细节YOLOv8 的 box 分支默认输出的是4 * reg_max个通道reg_max 默认是 16也就是 64 个通道。如果导出时没有把 DFL 解码放在模型内部那么 box_out 的形状就不是[1, 4, 8400]而是[1, 64, 8400]。所以在做后续 RKNN 转换前建议先用 onnxruntime 或 torch 导出后的模型跑一遍import onnxruntime as ort import numpy as np sess ort.InferenceSession(yolov8s_dual_output.onnx) inputs {images: np.random.randn(1, 3, 640, 640).astype(np.float32)} outs sess.run(None, inputs) print([o.shape for o in outs])如果是正确的双头输出打印结果应该是[(1, 4, 8400), (1, 80, 8400)]这一类的形状。如果你看到[(1, 64, 8400), (1, 80, 8400)]说明回归分支没有经过 DFL 解码。这时你要回到模型代码里把 DFL 解码过程放到 forward 的导出路径中或者在后续板端后处理里按 64 维做积分解码。两种方式都能实现双头但后续代码差异很大建议在模型导出阶段就解决避免把 DFL 解码逻辑搬到板端增加调试难度。4. RKNN 转换与板端部署的关键配置4.1 RKNN-Toolkit2 加载多输出 ONNXRKNN-Toolkit2 支持多输出 ONNX 模型加载后会自动识别输出节点的名称和顺序。常见代码如下但不同小版本的 config 和 build 参数会有差异使用前先查你当前版本的接口说明。from rknn.api import RKNN rknn RKNN() rknn.config(target_platformrk3588) rknn.load_onnx(modelyolov8s_dual_output.onnx) rknn.build(do_quantizationTrue, datasetdataset.txt) rknn.export_rknn(yolov8s_dual_output.rknn)这里target_platform写成rk3588。如果使用的是其他 RKNN-Toolkit2 版本参数名和枚举值可能不同但思路一致。dataset.txt是校准图片列表文件每行一个图片路径。4.2 校准集怎么选直接决定双头改完后有没有效果双头只是给了量化器分头统计的机会但校准集要是本身覆盖不全照样白搭。校准集不要只放干净、光照均匀、目标居中的图片。尽量包含小目标、遮挡、夜间或低对比度、不同角度的场景。每张图片内部最好有多个类别避免某个类别在统计上缺失。校准集的数量也要控制。数量太少统计出来的分布不稳定数量过多转换时间变长还可能出现重复图片抢占统计权重的问题。常见做法是准备 200 到 500 张有代表性的验证集图片覆盖主要场景分支即可。这个数量不需要等同于训练集的完整分布但必须能反映目标场景的边界情况。校准集不是越多越好关键是“覆盖”。宁可每张图里信息密集也不要堆几千张高度相似的背景图。4.3 在 RK3588 上跑一遍先检查输出顺序和数值范围使用 RKNNLite 加载 rknn 文件后inference 返回一个输出列表。顺序一般和 ONNX 输出节点顺序一致但最稳妥的方式是按名字或按 shape 先确认哪个是 box_out、哪个是 cls_out。from rknnlite.api import RKNNLite rknn_lite RKNNLite() rknn_lite.load_rknn(yolov8s_dual_output.rknn) rknn_lite.init_runtime() outputs rknn_lite.inference(inputs[img]) box_out, cls_out outputs[0], outputs[1] print(box_out.shape, cls_out.shape)这一步不要急着接后处理。先用固定测试图片对比 PyTorch 原始 fp32 输出和 RKNN int8 输出的数值形态。位置量级和类别分数量级是否符合预期比直接看框更可靠。如果输出列表顺序和预期不一样不要在推理代码里写死索引建议用 shape 判断第一维为 4 的输入是 box_out第一维为类别数的输入是 cls_out。5. 后处理适配双头输出5.1 双头后处理的基本流程拿到box_out [1, 4, 8400]和cls_out [1, num_cls, 8400]之后后处理逻辑比单头版本更直接从 cls_out 中找到每个 anchor 的最大类别分数和对应索引。用阈值过滤低置信度的 anchor。用 box_out 中保留下来的 anchor 做坐标解码。按类别分别做 NMS或统一 NMS 后再按类别过滤。一个常见伪代码如下import numpy as np box box_out[0] # [4, 8400] cls cls_out[0] # [80, 8400] scores cls.max(axis0) labels cls.argmax(axis0) mask scores conf_thres candidate_box box[:, mask] candidate_score scores[mask] candidate_label labels[mask] # 坐标解码 NMS但这里有个坑YOLOv8 的坐标解码不是简单的 xywh 直接使用。如果 ONNX 导出时没有把 bbox 解码到输入分辨率你还需要根据 stride 或缩放比例把坐标映射回原图。最稳妥的做法是先用一张已知框的测试图分别跑 PyTorch 和 RKNN 输出对照解析结果确认坐标解码的公式完全一致再继续调后处理。5.2 最容易踩的坑双头的顺序与维度不对齐单头输出只有一个张量切片时容易犯的错是行列颠倒双头输出则容易犯两个错。第一个是拿到 box_out 之后以为它也是 84 维结构直接取第 4 行到第 84 行当类别结果找不到。第二个是 cls_out 的 shape 是[1, 80, 8400]有人习惯性地把第 0 维当成 80导致类别数取错。我的建议是在写后处理之前先加一个 shape 断言assert box_out.shape[1] 4, fbox_out second dim should be 4, got {box_out.shape} assert cls_out.shape[1] num_classes, fcls_out second dim should be {num_classes}, got {cls_out.shape}如果输出不是预期 shape立刻报错而不是带病跑到 NMS 才看到空框或乱框。这个小步骤在 RK3588 上调试时能省很多时间因为 RKNN 量化后的输出 shape 偶尔会与 PyTorch 不一致特别是某个通道被优化掉或合并时。5.3 封装一个统一接口方便单头和双头之间切换很多项目会在不同阶段跑不同模型比如开发阶段用 fp32 版本调算法部署阶段用双头 rknn。为了让后处理代码不必改两套可以封装一个输出解析函数传入 outputs 列表和输出模式返回统一的 boxes、scores、labels。def parse_outputs(outputs, modedual): if mode dual: box_out, cls_out outputs box box_out[0] cls cls_out[0] else: single outputs[0][0] box single[:4, :] cls single[4:, :] return box, cls这样以后如果重新导出单头模型只需要改一个 mode 参数。实际工程中这个接口比在推理代码里到处加 if 判断要干净得多。尤其在 RK3588 上反复分享模型给同事、对比量化效果时统一接口能避免每个人理解都偏差一点、到处改 bug 的情况。6. 一套排查链路你的精度问题到底该不该改输出头6.1 不要一上来就拆头按顺序排查在 RK3588 上部署 YOLOv8发现量化后精度下降不要立刻把模型改成双头。先按这个顺序走检查顺序检查点具体操作1校准集是否覆盖目标场景是否和训练集分布接近2预处理letterbox、归一化、通道顺序、缩放比例是否一致3量化方式尝试把输出层或关键层设置为 i16/fp16看精度是否明显恢复4输出结构前三条确认没问题后再考虑把单头拆成双头双头只是一种模型结构选择它不能替代校准和预处理。如果校准集和数据流本身有问题拆了头也不会恢复精度。很多情况下问题不在输出头而是一张图在训练预处理和板端推理之间经历了两次不同的尺寸变换或者用了不同的通道顺序。6.2 适用场景和不适用场景不是每个 RK3588 项目都应该改双头。给一个更明确的判断参考场景建议COCO 80 类或多类别数据集int8 分类置信度漂移值得改双头自定义数据集 1 到 3 类精度偏差不明显先保持单头优先查校准集已经采用 i16/fp16 混合量化不急着改先看混合量化能否解决后处理频繁扩展需要清晰输出接口双头收益明显模型还在频繁迭代输出格式经常变建议用统一封装避免每版改一次后处理还有一个现实因素改双头意味着重新导出 ONNX、重新跑 RKNN 转换、重新验证后处理整个流程需要半天到一天。如果项目只跑一次实验或者模型已经稳定上线没必要为了理论上的“更合理”去动它。但如果你正在做一个从训练到部署长期迭代的项目拆双头带来的可维护性和量化精度收益通常值得这次改动。6.3 这类改动的长期价值回到最开始那个问题单头输出不是错双头输出也不是银弹。真正重要的是边缘部署时的模型设计不能只考虑训练期的便利还要考虑量化、硬件线程、后处理链条的耦合关系。在 RK3588 上做 YOLOv8 部署迟早会遇到“训练好好的转完就变样”的阶段。输出头从单头变双头是我会优先尝试的手段之一因为它改动可控、边界清晰、不影响模型主干结构。但它并不是万能的必须配合正确的校准集和量化策略一起用。把这一环补上整个部署链路才算是真正闭合。