RK3588部署YOLOv8双头输出:NPU后处理优化实战

发布时间:2026/9/8 8:35:26
RK3588部署YOLOv8双头输出:NPU后处理优化实战 这次我们直接进到 RK3588 部署 YOLOv8 的实战第四集。前几集如果已经跑通了模型转换和基础推理这一集要解决的是一个非常实际的问题把 YOLOv8 的输出层拆开看明白然后把默认的“单头输出”改成“双头输出”让模型在 RK3588 的 NPU 上更容易做后处理。为什么要折腾输出层因为 YOLOv8 的检测头在导出 ONNX 后默认会把边界框回归和类别分类打包成一个大的输出张量形如[1, 84, 8400]。这个格式在 RKNN 部署时不是不能用但后处理时要把 84 个通道同时解析出来再拆出坐标和类别代码写起来绕而且当类别数变化、或者你想分别对坐标头、分类头做不同的量化精度时单头输出的灵活性很差。改成双头输出后模型直接吐出两个独立的张量一个管坐标一个管类别后处理逻辑一目了然。这篇文章不会停留在概念上我会从 YOLOv8 的 Detect 源码开始拆然后给出修改后的双头输出模型代码接着走一遍导出 ONNX、转 RKNN、板端后处理的完整链路。文章里会给出可复制的代码和命令部署过程中常见的坑也会列成排查清单。不管你是自己训练了模型还是拿官方权重做实验这套改造思路都能直接套用。1. RK3588 部署 YOLOv8 双头输出核心能力速览先把本次改造涉及的关键信息列出来方便你判断这篇内容是否对你的场景有用。能力项说明目标平台RK3588 / RK3588S 系列含正点原子、香橙派等常见开发板模型框架Ultralytics YOLOv8检测模型支持 v8n/v8s/v8m/v8l/v8x改造目标将 Detect 输出层从单头输出拆分为坐标回归、类别分类双头输出输出格式双头输出Box 头[B, 4, 8400]Cls 头[B, nc, 8400]工具链rknn-toolkit2PC 端转换 RKNN-Toolkit-Lite2 或 RKNN C API板端推理NPU 算力RK3588 内置 6 TOPS NPU支持 INT8/INT16 量化部署方式ONNX → RKNN通过 RKNN Python API 或 C API 调用后处理位置双头分别做阈值过滤和坐标解码最后统一 NMS适用场景目标检测、边缘计算、智能安防、工业缺陷检测、多路视频分析从材料看RK3588 的 NPU 跑 INT8 量化后的 YOLOv8s 是主流用法显存占用方面板端统一使用 NPU 内存模型权重一般在几十 MB 到一百多 MB对 8GB 内存的 RK3588 开发板完全无压力。但具体推理耗时和内存占用要看你选的模型尺寸、输入分辨率和量化方式下面我会给出性能观察方法但不会拍脑袋编一个帧率数字。2. YOLOv8 输出层结构拆解在改代码之前必须先把 YOLOv8 的输出层结构搞清楚。很多人在 RKNN 部署时出问题根源就是没理解 Detect 模块内部到底输出了什么。2.1 Detect 模块的工作方式YOLOv8 的检测头继承自 YOLOv5 的解耦检测头思路但细节不同。它的核心结构在 ultralytics 源码的nn/modules/head.py中名字叫Detect。这个模块接收 Backbone 和 NeckPAN-FPN输出的三个不同尺度的特征图分别对应 stride 8、16、32也就是常说的 P3、P4、P5 层。每个尺度上的处理逻辑可以拆成两条支路cv2分支负责边界框回归。每个网格输出4 * reg_max个通道默认reg_max16所以实际输出 64 个通道。这 64 个通道不是直接的坐标值而是 DFLDistribution Focal Loss使用的分布参数。cv3分支负责类别分类。每个网格输出nc个通道nc是类别数COCO 数据集就是 80 类。在训练模式下Detect 模块会返回这些原始特征图用于计算 DFL 和分类损失。但在推理模式下它会额外执行一个_inference过程用 DFL 解码把 64 通道的分布参数换算成 4 个坐标值(cx, cy, w, h)然后和分类结果拼在一起。2.2 单头输出到底长什么样以 COCO 80 类、输入 640x640 为例推理模式下三个尺度合并后的输出是一个张量形状是[1, 4 80, 8400]其中 8400 是三个尺度预测框的总数80*80 40*40 20*20 6400 1600 400 8400。这 84 个通道中前 4 个是解码后的坐标后面 80 个是类别得分。这个格式在后处理时有两个痛点每次做类别筛选时要从一个 84 维的向量里同时读坐标和分数内存访问不连续对 NPU 后的 CPU 后处理不友好。如果你只关心类别分数、想单独对坐标做精度分析或者想对两个头配置不同的 RKNN 量化粒度单头输出没法拆开操作。2.3 “单头变双头”的本质所谓双头输出就是在推理时不让 Detect 把坐标和类别拼接在一起而是让模型输出两个独立的张量Box 头[1, 4, 8400]每个预测框的(cx, cy, w, h)或(x1, y1, x2, y2)Cls 头[1, 80, 8400]每个预测框属于每个类别的得分这样做的好处有三个。第一后处理时可以先对 Cls 头做阈值过滤只保留得分大于阈值的框再用这些框的索引去 Box 头取坐标计算量大幅减少。第二两个头的张量边界清晰你在 RKNN 的 C API 里可以用两个独立的输出缓冲去接不用做通道切分。第三如果以后要扩展多任务比如检测 分割、检测 关键点这种多输出的思路可以直接复用。3. 环境准备与前置条件开始改代码之前先把环境梳理清楚。RK3588 上部署 YOLOv8 的完整链路分两段PC 端做模型转换板端做推理。3.1 PC 端环境模型转换必须在 x86 的 PC 上完成因为 rknn-toolkit2 的完整版依赖 x86 的 Python 环境。项目推荐配置操作系统Ubuntu 20.04 / 22.04 x86_64Python3.8 / 3.10rknn-toolkit21.5.x 或 1.6.x按你的 NPU 驱动版本选择深度学习框架PyTorch 2.x训练/导出用、ONNXGPU可选训练时建议有 NVIDIA GPU纯转换不需要 GPU磁盘空间建议预留 20GB 以上安装 rknn-toolkit2 时建议用虚拟环境隔离避免和已有的 PyTorch 环境冲突python3 -m venv rknn-env source rknn-env/bin/activate pip install rknn-toolkit2-1.6.0-cp38-cp38-linux_x86_64.whl pip install ultralytics onnx onnxruntime版本号请以你从官方获取到的 wheel 包为准不要盲目照抄。安装完成后可以用下面的命令验证python -c from rknn.api import RKNN; print(RKNN import success)3.2 板端环境RK3588 开发板上需要安装 RKNN 的运行时库。如果你用的是 Ubuntu 系统的开发板可以通过 pip 安装 rknn-toolkit-lite2 做快速验证如果要部署到生产环境更推荐用 RKNN C API 做交叉编译。项目说明开发板系统Ubuntu 20.04 / 22.04或 Buildroot 系统板端 Python3.8 / 3.10运行时库rknn-toolkit-lite2Python 验证或 librknnrtC 部署内存建议 4GB 以上散热风扇或散热片必须接好NPU 满载时发热明显板上安装 lite 版pip install rknn-toolkit-lite2-1.6.0-cp38-cp38-linux_aarch64.whl导入验证python -c from rknnlite.api import RKNNLite; print(RKNNLite import success)这里有一个容易被忽略的点PC 端的 rknn-toolkit2 和板端的 rknn-toolkit-lite2 版本必须匹配。比如 PC 端用 1.6.0 转换出的 rknn 模型板端也要用 1.6.0 的运行时去加载否则会出现算子不兼容或直接加载失败。4. 修改 YOLOv8 输出层代码这一节是全文的核心。我们把 ultralytics 的 Detect 模块改造成双头输出。改造思路是保留 Detect 模块里训练好的cv2和cv3卷积层权重但不使用默认的拼接输出逻辑改成返回两个独立张量。4.1 自定义双头检测模块在 ultralytics 环境下写一个自定义模块。为了让改造对原模型影响最小我们直接实例化一个DetectionModel然后把它的 Detect 模块替换成自研的双头版本。import torch import torch.nn as nn from ultralytics.nn.tasks import DetectionModel from ultralytics.utils.ops import dist2bbox class DualHeadDetect(nn.Module): 双头检测输出模块将坐标回归和类别分类拆成两个独立输出。 与 ultralytics Detect 模块的区别 1. 不拼接 box 和 cls而是分别输出 2. 坐标格式通过 dist2bbox 解码为 xyxy def __init__(self, detect): super().__init__() # 复用原始 Detect 模块训练好的参数 self.cv2 detect.cv2 self.cv3 detect.cv3 self.reg_max detect.reg_max self.nl detect.nl self.nc detect.nc self.no detect.no self.stride detect.stride def forward(self, x): # x 是 P3, P4, P5 三个尺度的特征图 shape x[0].shape box_heads [] cls_heads [] for i in range(self.nl): # box 分支原始输出: [B, 4*reg_max, H, W] box_feat self.cv2[i](x[i]) # cls 分支原始输出: [B, nc, H, W] cls_feat self.cv3[i](x[i]) # 转成 [B, anchors, 4*reg_max] 和 [B, anchors, nc] box_feat box_feat.flatten(2).permute(0, 2, 1) cls_feat cls_feat.flatten(2).permute(0, 2, 1) box_heads.append(box_feat) cls_heads.append(cls_feat) # 合并三个尺度: [B, 8400, ...] box_feat_all torch.cat(box_heads, dim1) cls_feat_all torch.cat(cls_heads, dim1) # DFL 解码将分布参数转为边界框坐标 # 输入 box_feat_all: [B, 8400, 4*reg_max] # 输出 bbox: [B, 8400, 4]坐标格式为 xyxy bbox dist2bbox( self._dfl(box_feat_all), self.stride, xywhFalse, dim1 ) # 双头输出 # bbox: [B, 4, 8400] # cls: [B, nc, 8400] box_out bbox.permute(0, 2, 1) cls_out cls_feat_all.permute(0, 2, 1) return box_out, cls_out def _dfl(self, x): DFL 解码。 x: [B, anchors, 4*reg_max] 将每 4 个 reg_max 分布参数解码为 1 个坐标值 if self.reg_max 1: return x b, a, c x.shape x x.view(b, a, 4, self.reg_max) x x.softmax(-1) # 投影矩阵: [reg_max] proj torch.arange(self.reg_max, dtypetorch.float32, devicex.device) # 加权求和: [B, anchors, 4] x torch.matmul(x, proj) return x def wrap_dual_head(ckpt_path: str, nc: int 80) - DualHeadDetect: 加载 ultralytics YOLOv8 权重替换 Detect 模块为双头输出。 model DetectionModel(cfgyolov8s.yaml, ch3, ncnc) ckpt torch.load(ckpt_path, map_locationcpu) model.load_state_dict(ckpt[model].state_dict()) # 替换 Detect 模块 dual_head DualHeadDetect(model.model[-1]) model.model[-1] dual_head return model这段代码做了三件事加载官方或自己训练的 YOLOv8 权重将最后一层 Detect 替换为DualHeadDetect在forward中完成 DFL 解码并输出两个独立张量。dist2bbox是 ultralytics 提供的坐标解码函数可以从ultralytics.utils.ops导入解码后的坐标是xyxy格式后处理时直接用。4.2 导出 ONNX 模型模型改造完成后用 ONNX 导出。这里注意两点一是 opset 版本建议 12RKNN 对 ONNX 算子兼容性最好二是输入输出节点名固定下来方便后面写 RKNN 转换脚本时引用。import torch # 加载双头模型 model wrap_dual_head(yolov8s.pt, nc80) model.eval() # 构造输入 dummy_input torch.randn(1, 3, 640, 640) # 导出 ONNX torch.onnx.export( model, dummy_input, yolov8s_dual_head.onnx, opset_version12, input_names[images], output_names[box_out, cls_out], dynamic_axesNone # RKNN 固定输入尺寸更稳定 ) print(导出完成: yolov8s_dual_head.onnx)导出后可以用 onnxruntime 做一次快速验证对比输出形状import onnxruntime as ort import numpy as np sess ort.InferenceSession(yolov8s_dual_head.onnx) inputs np.random.randn(1, 3, 640, 640).astype(np.float32) outs sess.run(None, {images: inputs}) print(fBox 输出形状: {outs[0].shape}) # 期望 [1, 4, 8400] print(fCls 输出形状: {outs[1].shape}) # 期望 [1, 80, 8400]如果输出形状符合预期说明双头改造成功。如果这里就报错多半是dist2bbox或torch.arange在 ONNX 导出时算子兼容问题可以先简化_dfl实现或者升级 ultralytics 版本。4.3 双头输出的另一种实现上面代码是在模型内部做了 DFL 解码输出已经是 xyxy 坐标。这种做法的优点是后处理简单缺点是一些 reshape 和 softmax 操作在 RK3588 NPU 上可能被映射到 CPU 算子影响端到端性能。更工程化的做法是模型只输出 DFL 解码前的原始特征也就是 Box 头输出[1, 64, 8400]Cls 头输出[1, 80, 8400]把 DFL 解码放到板端后处理的 C/Python 代码里做。这样 NPU 只负责纯卷积计算效率最高。如果后面用 C API 部署强烈建议用这种方案。# 双头输出原始特征版本 class DualHeadDetectRaw(nn.Module): def __init__(self, detect): super().__init__() self.cv2 detect.cv2 self.cv3 detect.cv3 self.nc detect.nc self.nl detect.nl def forward(self, x): box_heads [] cls_heads [] for i in range(self.nl): box_feat self.cv2[i](x[i]) # [B, 64, H, W] cls_feat self.cv3[i](x[i]) # [B, nc, H, W] box_feat box_feat.flatten(2).permute(0, 2, 1) # [B, anchors, 64] cls_feat cls_feat.flatten(2).permute(0, 2, 1) # [B, anchors, nc] box_heads.append(box_feat) cls_heads.append(cls_feat) box_out torch.cat(box_heads, dim1).permute(0, 2, 1) # [B, 64, 8400] cls_out torch.cat(cls_heads, dim1).permute(0, 2, 1) # [B, nc, 8400] return box_out, cls_out然后导出的 ONNX 就是两个输出box_out: [B, 64, 8400]、cls_out: [B, nc, 8400]。板端后处理时再对 64 通道做 DFL 解码。后面介绍 RKNN 转换时我会同时给出这两种方案在板端的处理差异。5. RKNN 转换与板端部署ONNX 模型准备好后进入 RKNN 转换环节。这一步直接决定模型能不能在 RK3588 的 NPU 上跑起来。5.1 PC 端 RKNN 转换脚本新建convert_rknn.py内容如下from rknn.api import RKNN def convert(): rknn RKNN(verboseTrue) # 配置量化参数 # mean_values/std_values 按训练时的归一化参数调整 rknn.config( mean_values[[0, 0, 0]], std_values[[255, 255, 255]], target_platformrk3588, quantized_dtypew8a8, quantized_algorithmnormal, quantized_methodlayer ) # 加载 ONNX ret rknn.load_onnx( modelyolov8s_dual_head.onnx, input_size_list[[1, 3, 640, 640]] ) if ret ! 0: print(load_onnx failed) return # 构建 RKNN 模型 ret rknn.build( do_quantizationTrue, datasetdataset.txt ) if ret ! 0: print(build failed) return # 导出 rknn 文件 ret rknn.export_rknn(yolov8s_dual_head.rknn) if ret ! 0: print(export failed) return print(convert done) if __name__ __main__: convert()dataset.txt是量化校准数据集列表每行一个图片路径。建议从训练集或测试集中取 100 到 200 张有代表性的图片覆盖你要检测的目标类别。图片不需要提前缩放RKNN 转换时自动按输入尺寸处理。/path/to/calib_images/img_001.jpg /path/to/calib_images/img_002.jpg /path/to/calib_images/img_003.jpg5.2 板端推理验证Python 快速版把生成的yolov8s_dual_head.rknn拷贝到 RK3588 开发板上。先用 Python 版本做功能验证确认双头输出在板端正常import numpy as np import cv2 from rknnlite.api import RKNNLite def test_rknn(): rknn RKNNLite() ret rknn.load_rknn(yolov8s_dual_head.rknn) if ret ! 0: print(load rknn failed) return ret rknn.init_runtime() if ret ! 0: print(init runtime failed) return # 读取测试图片并预处理 img cv2.imread(test.jpg) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (640, 640)) img img.astype(np.float32) img img / 255.0 img np.transpose(img, (2, 0, 1)) img np.expand_dims(img, 0) # 推理 outputs rknn.inference(inputs[img]) # 双头输出结果 box_out outputs[0] # [1, 4, 8400] 或 [1, 64, 8400] cls_out outputs[1] # [1, nc, 8400] print(fbox_out shape: {box_out.shape}) print(fcls_out shape: {cls_out.shape}) rknn.release() if __name__ __main__: test_rknn()如果输出形状正确说明模型在 RK3588 NPU 上已经跑通了。接下来要做的是后处理。5.3 双头输出的后处理逻辑因为双头输出已经把坐标和类别分开了后处理可以写得非常清晰。流程是对 Cls 头做 sigmoid得到类别概率。对每个类别的概率做阈值过滤只保留大于conf_thres的索引。用筛选后的索引去 Box 头取坐标。做 NMS。import numpy as np def postprocess(box_out, cls_out, conf_thres0.25, iou_thres0.45): box_out: [1, 4, 8400] 或 [1, 64, 8400] cls_out: [1, nc, 8400] nc cls_out.shape[1] # cls 头 sigmoid cls_scores 1 / (1 np.exp(-cls_out[0])) # [nc, 8400] # 找到每个 anchor 的最高类别得分 max_scores cls_scores.max(axis0) # [8400] max_cls_id cls_scores.argmax(axis0) # [8400] # 阈值过滤 keep np.where(max_scores conf_thres)[0] boxes [] scores [] class_ids [] for idx in keep: box box_out[0, :, idx] # [4] 或 [64] # 如果是 64 通道需要做 DFL 解码这里先假设模型输出已是 xyxy cx, cy, w, h box x1 cx - w / 2 y1 cy - h / 2 x2 cx w / 2 y2 cy h / 2 boxes.append([x1, y1, x2, y2]) scores.append(max_scores[idx]) class_ids.append(max_cls_id[idx]) if len(boxes) 0: return [], [], [] boxes np.array(boxes).astype(np.float32) scores np.array(scores).astype(np.float32) class_ids np.array(class_ids).astype(np.int32) # NMS keep_indices nms(boxes, scores, iou_thres) return boxes[keep_indices], scores[keep_indices], class_ids[keep_indices] def nms(boxes, scores, iou_thres): # 简化的 NMS 示例生产环境可以用 cv2.dnn.NMSBoxes from cv2 import dnn indices dnn.NMSBoxes( boxes.tolist(), scores.tolist(), score_threshold0.0, nms_thresholdiou_thres ) if len(indices) 0: return [] return np.array(indices).flatten()如果你用了 4.3 节的“原始特征版本”Box 头输出是[1, 64, 8400]那么在取坐标前需要先把 64 通道做 DFL 解码再把解码后的分布参数加权求和成坐标值。这部分代码要放在后处理里逻辑和前面_dfl一样只是从 PyTorch 换成 NumPy。5.4 双头输出在 RKNN C API 中的接法如果要用 C API 做生产级部署RKNN 模型的输出缓冲区可以分别绑定两个张量。// 假设已经初始化 rknn_app_ctx rknn_tensor_attr outputs_attr[2]; // 绑定 box 输出 rknn_query(ctx, RKNN_QUERY_OUTPUT_ATTR, output_attr_box, 0); box_out_buf malloc(output_attr_box.n_elems * sizeof(float)); // 绑定 cls 输出 rknn_query(ctx, RKNN_QUERY_OUTPUT_ATTR, output_attr_cls, 1); cls_out_buf malloc(output_attr_cls.n_elems * sizeof(float)); // 设置输入输出 rknn_input inputs[1]; rknn_output outputs[2]; outputs[0].want_float 1; outputs[0].buf box_out_buf; outputs[1].want_float 1; outputs[1].buf cls_out_buf; rknn_run(ctx, NULL); rknn_outputs_get(ctx, 2, outputs, NULL);双头输出的优势在 C API 里体现得很明显两个输出缓冲区独立管理后处理线程可以并行解析坐标头和分类头不需要等整个大张量拷贝完成。6. 功能测试与效果验证模型跑通不代表结果正确。这一节给出功能验证的方法确保双头输出改造没有破坏检测精度。6.1 精度对比测试测试目的确认双头输出模型与原版 YOLOv8 检测结果一致。准备一张测试图分别在 PyTorch 原版和 RKNN 双头模型上推理对比检测框。对比项原版 YOLOv8RKNN 双头输出输入尺寸640x640640x640检测框数量以实际为准以实际为准置信度排序前 5 个框前 5 个框坐标误差基准允许少量偏移具体操作先从原来的单头模型导出每个检测框的坐标和类别保存成 JSON再用 RKNN 双头模型推理同一张图同样保存 JSON最后写脚本比较两个 JSON 中匹配框的 IoU 和置信度差异。python compare_results.py --onnx_result onnx_result.json --rknn_result rknn_result.json正常情况下同一个模型转 RKNN 后坐标和置信度会有很小偏差这是 NPU 量化和 PC 上 FP32 推理的差异属于正常现象。但类别不应该改变大目标位置不应该有明显偏移。如果 RKNN 结果出现大量漏检、误检首先怀疑量化校准集选择是否合理。6.2 多类别场景测试如果你训练的是自己的数据集比如工业缺陷、烟火检测等务必在每个类别上都做验证。双头输出改造后Cls 头的通道数等于你的类别数。如果你改了模型结构后忘记同步调整nc输出形状会直接不匹配。测试建议每个类别准备 10 到 20 张测试图统计每类的 precision 和 recall。重点关注小目标类别因为 RKNN 量化后小目标的特征损失相对更大。6.3 批量推理稳定性RK3588 跑视频流或多路摄像头时模型会被反复调用。建议测试连续推理 1000 帧以上观察两个指标推理耗时是否稳定有没有个别帧明显变慢。板端内存/内存占用是否持续增长排除内存泄漏。import time # 连续推理稳定性测试 for i in range(1000): start time.time() outputs rknn.inference(inputs[img]) elapsed time.time() - start if i % 100 0: print(fframe {i}: {elapsed * 1000:.2f} ms)如果出现耗时逐步增大大概率是后处理部分有内存没有释放或者是板端其他进程干扰。7. 资源占用与性能观察RK3588 的 NPU 占用和内存占用是判断部署是否达标的重要指标。7.1 查看 NPU 占用RK3588 的 NPU 占用可以通过cat /sys/kernel/debug/rknpu/load查看实时读取 NPU 负载百分比。watch -n 1 cat /sys/kernel/debug/rknpu/load正常推理时NPU 负载会稳定在一个区间。如果负载接近 100%说明模型已经接近 NPU 算力上限如果负载很低但推理耗时仍然很高重点检查是不是某些算子被放到 CPU 上执行了。7.2 查看 CPU 和内存占用双头输出模型如果在模型内部做了 DFL 解码这部分计算很可能落到 CPU 上。可以用top或htop观察 CPU 占用。如果某个 CPU 核占用明显偏高大概率是 DFL 解码在 CPU 跑。htop这种情况下建议改用 4.3 节的“原始特征版本”把 DFL 解码放到后处理中。NPU 只做卷积CPU 只做后处理分工更合理。7.3 影响性能的关键因素RK3588 部署 YOLOv8 的推理耗时主要受以下因素影响因素影响方向输入分辨率分辨率越高耗时越长640 是平衡点模型尺寸n/s/m/l/x 依次变慢量化方式w8a8 比 w16a16 快是否混用 CPU 算子有 CPU 算子的模型端到端耗时明显变大后处理优化NMS 在 CPU 上做数量多时耗时不可忽略降低耗时的方法有几种降低输入分辨率到 512优先使用yolov8n或yolov8s将 NMS 改成类别独立的并行 NMS如果场景固定可以裁剪输出类别数减少 Cls 头的计算量。8. 常见问题与排查方法RK3588 部署 YOLOv8 双头输出最容易踩的坑集中在这几个地方。问题现象可能原因排查方式解决方案导出 ONNX 报错dist2bbox在 torch.onnx 导出时不兼容查看报错堆栈定位到具体算子改用 4.3 节原始特征版本把 DFL 解码移到后处理RKNN 转换时 build 失败ONNX 包含 NPU 不支持的算子用rknn.load_onnx后打印详细日志定位失败节点简化模型、升级 rknn-toolkit2、避开不支持的算子板端推理输出全为 0输入预处理和量化参数不匹配检查 mean/std 是否和训练时一致修改rknn.config里的 mean_values/std_values板端推理耗时很高部分算子在 CPU 上执行用rknn.build日志查看算子分配情况改用原始特征版本或调整模型结构检测框偏移严重量化校准集选择不当检查校准图片数量和类别覆盖增加校准图数量覆盖各种光照和背景NMS 后框数量异常后处理坐标格式理解错打印box_out前几个值确认是 xyxy 还是 cxcywh统一坐标格式必要时转换批量推理内存增长后处理中数组未释放用top观察内存及时释放不再使用的 NumPy 数组NPU 驱动版本不匹配PC 端转换版本和板端运行时版本不一致分别打印 rknn-toolkit2 和 lite2 版本号统一版本重新转换模型上面有一个比较隐蔽的问题dist2bbox的dim参数。在 ultralytics 的不同版本里dist2bbox默认的维度可能不同。如果你在导出 ONNX 时报维度相关错误检查一下dist2bbox的dim参数是否传了1。如果传错坐标解码结果会完全错乱但程序不报错。另一个常见问题是量化精度不足。如果 RKNN 转换后模型精度明显下降先把quantized_dtype从w8a8改成w16a16试一次看精度是否恢复。如果恢复说明 INT8 量化损失偏大需要优化校准集或考虑混合量化。9. 最佳实践与使用建议双头输出改造这件事看起来只是改了一下模型输出格式但实际部署时带来的便利是实打实的。下面几条建议可以帮助你少走弯路。第一第一次改造时先用yolov8n最小模型跑通全链路再去换大模型。小模型导出快、转换快、出问题也好定位。等整套流程稳定了再替换成自己训练的模型。第二严格区分“模型内部解码”和“板端后处理解码”。如果你的 RK3588 应用对端到端延迟很敏感优先选择模型只输出原始特征的方案把 DFL 解码和 NMS 全部放到 C 代码里优化。模型内部做 DFL 解码虽然省事但 NPU 和 CPU 的负载会变得不清晰性能调优困难。第三量化校准集不要随便找几张图。校准集的质量直接决定 INT8 量化后模型的精度。建议从训练集的验证集中随机抽取 100 到 200 张图确保覆盖你实际业务中的所有目标类型、光照和背景。第四文件管理要规范。建议目录结构如下rk3588_yolov8/ ├── weights/ │ ├── yolov8s.pt │ ├── yolov8s_dual_head.onnx │ └── yolov8s_dual_head.rknn ├── calibrate/ │ └── images/ ├── scripts/ │ ├── convert_rknn.py │ ├── export_onnx.py │ └── postprocess.py ├── runtime/ │ └── rknn_yolov8_demo.py └── logs/第五批处理和多路视频场景下建议在 NPU 推理前后增加队列缓冲。RK3588 的 NPU 一次只接收一个 batch 时利用率不高如果你的输入是多路视频流可以先把多路帧拼成一个 batch再统一推理能有效提升 NPU 利用率。第六版权和授权问题不能忽视。如果你用自己的数据重新训练 YOLOv8要确认数据集的采集和使用合乎规定。涉及人脸、车牌等敏感信息的目标检测部署到真实环境前要确认符合隐私合规要求。不要拿未授权的图像或视频做商用检测。第七散热是 RK3588 部署的隐形门槛。NPU 长时间满载时开发板发热非常明显。官方散热风扇或主动散热片必须装好否则温度过高会触发降频推理耗时突然变长。从相关热词里也能看到很多人关心 RK3588 风扇转速和 PWM 控制这确实是高负载推理场景里绕不开的问题。10. 后续扩展方向与思路双头输出的改造思路不仅仅适用于 YOLOv8 的目标检测任务。如果你接下来要在 RK3588 上做更复杂的视觉任务这套多输出的设计可以继续扩展。比如把 YOLOv8 改成同时输出检测框和分割掩码这就是 YOLOv8-Seg 的部署形态。只需要在双头基础上再加一个 Mask 头模型输出从两个张量变成三个张量后处理也对应地分成三条链路。再比如做姿态估计可以在双头中把坐标头换成关键点头类别头保留用于区分目标类别后处理时同时解码关键点和分类结果。从工程角度看把“单头拼接输出”改造成“多头独立输出”本质上是在为边缘端 NPU 部署做算子分配和内存规划。RK3588 的 NPU 适合做高并行的卷积计算但像 DFL 解码、softmax、NMS 这类非线性操作放在 CPU 上反而更自由。提前把输出层的分工在模型结构上定好板端代码就能写得非常直接NPU 专注跑卷积CPU 专注做业务逻辑。这一集把 YOLOv8 输出层的双头改造完整走了一遍如何拆分 Detect 模块、如何导出 ONNX、如何转 RKNN、如何在板端做后处理。每一步都给出了可以直接运行的代码。如果你正在 RK3588 上做目标检测项目建议先拿一张自己的测试图把双头输出跑通再对比一下原版单头输出在板端的效果差异。下一次可以继续深入显存和性能调优方向看看如何针对特定硬件榨干 NPU 的算力。