前阵子帮客户做一套边缘目标检测盒子选型选了 RK3568检测算法用的 YOLOv8s。模型在 GPU 服务器上训练得很好mAP 也不错结果一跑到 RK3568 上就傻眼了CPU 推理勉强跑到个位数帧不走量化优化的话那个 0.8 TOPS 的 NPU 根本发挥不出来。把 ONNX 转成 RKNN 的过程更是踩了一堆坑从算子不支持到量化后精度崩掉再到板端输出维度对不上每一个问题都能让你怀疑人生。这篇文章就记录我完整跑通 YOLOv8 RK3568 量化部署的全过程。适合两类人看一类是手上有 RK3568/RK3566 板子、想把 YOLOv8 模型真正部署到 NPU 上的工程师另一类是本地训练没什么问题、一上板就被各种报错折磨的同学。我会尽量讲清楚每一步为什么这么做而不是只丢给你一串能跑的命令。1. 整体设计为什么是 RK3568 量化这条路1.1 RK3568 的 NPU 算力与部署预期RK3568 是瑞芯微一颗很常见的边缘 SoC四核 Cortex-A55 CPU主频最高 2.0GHz 左右NPU 算力标称 0.8 TOPS支持 INT8、INT16 量化。这个算力放在今天不算高但在很多工业、商业场景里够用尤其是设备功耗和成本卡得很死的时候RK3568 是比较均衡的选择。需要先说清楚一个认知0.8 TOPS 不是你随便丢一个浮点模型进去就能白嫖的。NPU 的峰值算力只有在模型结构适配、数据尽量走定点的前提下才可能接近。YOLOv8s 这种尺寸的模型如果不做 INT8 量化在 RK3568 的 CPU 上跑 640x640 输入单帧推理耗时基本要几百毫秒帧率只有个位数。用了 RKNN-Toolkit2 转成 RKNN 格式并做 INT8 量化后单帧推理能压到几十毫秒级别整个系统的可用性才真正起来。我实际测试下来在 RK3568 上 YOLOv8n 的 INT8 量化模型输入 640x640单帧 NPU 推理耗时大致在 30~45ms 之间。YOLOv8s 大概在 55~80ms。这个数据受模型导出结构、工具链版本、NPU 工作频率和系统负载影响很大不同批次驱动版本也可能有差异。所以网上一上来就报一个精确到毫秒的数字看看就好自己板子上跑出来才是真的。1.2 部署链路选型ONNX 是绕不开的中间格式RKNN 是瑞芯微 NPU 的专用模型格式不能直接从 PyTorch 的.pt转出来。官方工具链 RKNN-Toolkit2 支持 PyTorch、ONNX、TensorFlow、Caffe 等格式转换但实际项目里最稳、最常用的路线就是PyTorch 训练 - 导出 ONNX - RKNN-Toolkit2 转换并量化 - 生成 .rknn - 板端加载推理。很多人会问为什么不能直接从.pt转 RKNN理论上工具链支持但实际转出来的图和算子映射往往很不可控而且你没法对中间的量化过程做精细调整。ONNX 是一个相对中立的计算图描述RKNN 工具对 ONNX 的算子覆盖也是最成熟的。所以不管你是用 ultralytics 官方仓库还是自己魔改过的 YOLOv8部署前先老老实实导出 ONNX这是最省事的一条路。方案选型还有一点要考虑是否要保留板端后处理。YOLOv8 的检测头不带非极大值抑制NMSONNX 导出后模型输出的是原始检测结果。NMS 这种逻辑不推荐放进 NPU 算子图里转换麻烦而且不同工具链支持度和效率都很难保证。常规做法是模型只负责输出 bounding box 和类别置信度NMS 放在 CPU 侧用 C/C 或 Python 处理这也是瑞芯微官方 rknn_model_zoo 里 YOLOv8 示例采用的方案。2. 环境准备与工具链版本匹配2.1 宿主机安装 RKNN-Toolkit2RKNN-Toolkit2 是运行在 PC 上的转换工具。我用的环境是 Ubuntu 20.04 x86_64 Python 3.8工具链版本是 2.6.0 左右的某个版本。官方建议的版本组合还是要尽量贴近因为 rknn 转换出的模型和板端 RKNN Runtime 是严格绑定的宿主机工具版本和板端运行库版本如果差了太多模型可能加载失败或者运行时报版本不匹配。安装过程不算复杂但有几个坑。首先需要创建一个干净的 Python 虚拟环境避免和系统环境冲突。然后安装依赖常见的是sudo apt update sudo apt install -y libxslt1-dev zlib1g-dev libglib2.0-dev libsm6 libxext6 libxrender-dev libgomp1之后到 rknn-toolkit2 的发布包或者 GitHub 仓库里找到对应 Python 版本和系统架构的 wheel 文件比如rknn_toolkit2-2.6.0-cp38-cp38-linux_x86_64.whl执行pip install rknn_toolkit2-2.6.0-cp38-cp38-linux_x86_64.whl pip install onnx onnxruntime opencv-python这里最容易踩的坑是 Python 版本。RKNN-Toolkit2 对不同 Python 版本支持不一致如果你用 Python 3.10 或 3.11有可能根本找不到对应的 wheel 文件。我试下来最稳的还是 Python 3.8。装完可以检查一下python -c from rknn.api import RKNN; print(rknn ok)能正常输出就说明基础环境没问题。很多时候转换时才报 ImportError 或找不到动态库基本都是依赖没装全。2.2 板端 RKNN Runtime 准备RKNN 模型在板端真正运行的库叫librknnrt.so这个库一般会预置在官方固件的系统目录里。如果你的板子是自己编译的固件或者移除了部分组件需要手动确认下ls -l /usr/lib/librknnrt.so如果找不到可以从 rknn-toolkit2 仓库里对应的rknpu2/runtime/Linux/librknn_api目录下把librknnrt.so推到板子上注意要选aarch64目录下的版本。板端还有一个比较重要的验证点确认/dev/rknpu设备节点存在且当前用户可以访问。常见问题是在自定义板子上Cannot open libmali或open /dev/rknpu failed。如果遇到权限问题可以直接用 root 先跑通流程之后再考虑 udev 规则。另外可以通过cat /sys/kernel/debug/rknpu/version或者板子上的/proc/rknpu节点查看 NPU 驱动版本先做到心里有数。2.3 YOLOv8 模型导出与 ONNX 验证模型准备这块我用的是 ultralytics 官方仓库导出的 YOLOv8。导出命令非常简单yolo export modelyolov8s.pt formatonnx opset12 dynamicFalse simplifyTrue如果熟悉 API 方式也可以用 Pythonfrom ultralytics import YOLO model YOLO(yolov8s.pt) model.export(formatonnx, opset12, dynamicFalse, simplifyTrue)很多同学在这里会忽略opset参数。RKNN 工具链对 ONNX opset 的支持是有限制的opset 版本太高可能导致某些算子无法解析。我一般用 opset 12稳一点。simplifyTrue会调用 onnx-simplifier 把计算图做常量折叠和冗余节点清理有利于 RKNN 转换。如果没装 onnxsim先pip install onnxsim。导出后先用 onnxruntime 跑一下确认模型结构正常import onnxruntime as ort import numpy as np import cv2 sess ort.InferenceSession(yolov8s.onnx, providers[CPUExecutionProvider]) input_name sess.get_inputs()[0].name input_shape sess.get_inputs()[0].shape output_shape sess.get_outputs()[0].shape print(input:, input_shape, output:, output_shape) img cv2.imread(test.jpg) img cv2.resize(img, (640, 640)).astype(np.float32) / 255.0 img np.transpose(img, (2, 0, 1))[None] out sess.run(None, {input_name: img})[0] print(onnx output shape:, out.shape)这个验证环节非常关键能提前过滤掉很多模型层面的问题。我最开始就是直接拿去转 RKNN转完才发现导出的 onnx 输出形状和我预期的不一致排查了半天白白浪费大量时间。3. 从 ONNX 到 RKNN转换与量化实操3.1 理解 YOLOv8 的 ONNX 输出结构用 ultralytics 官方仓库导出的 YOLOv8 ONNX对于 COCO 80 类模型输入 640x640 时输出张量形状通常是[1, 84, 8400]。其中 84 4x_center, y_center, width, height 80类别分数8400 是三个尺度特征图上的候选框总数。比较关键的是这个 ONNX 在导出时已经把 DFLDistribution Focal Loss解码逻辑和 bbox 坐标转换包含进去了。也就是说模型直接输出的是解码后的 xywh 坐标不再是 DFL 的原始分布表示。板端后处理只需要做坐标缩放、置信度过滤和 NMS不需要再单独实现一遍 DFL 解码这能省很多事。但要注意不是所有 YOLOv8 导出方法都这样。如果你自己手动改过模型检测头或者从某些第三方仓库导出时把检测头拆开来导出ONNX 输出可能是[1, 144, 8400]这样的裸特征多出来的 64 维是每个边界框四个边对应的 16 个分布值。遇到这种情况就必须自己在板端实现 DFL 解码复杂性会明显上升。所以我在导出后一定会先用 onnxruntime 实际跑一下确认输出到底是哪种格式。3.2 量化校准集最容易被忽略的一环RKNN-Toolkit2 做 INT8 量化时需要一组校准图片用来统计每一层激活值的分布从而确定定点量化参数。这个环节直接决定了量化后模型的精度。校准集不是随便拿几张测试图就可以。我个人的经验是数量尽量在 200 张以上太少了量化统计不稳定图片分布要和实际部署场景接近检测目标是行人就多放行人场景检测工件就多放工件场景不要只选一类很相似的图片否则量化后模型对某些少见类别会掉点严重图片尺寸尽量统一到和模型输入接近避免因为 Resize 导致内容失真。准备一个文本文件比如dataset.txt每行写一张图片的绝对路径或相对路径calib/0001.jpg calib/0002.jpg calib/0003.jpg ...在转换脚本里build阶段传入datasetdataset.txt工具会自动读取这些图片完成量化。需要说明的是校准图片本身不需要提前归一化到 0~1也不需要做 letterbox。工具内部在量化统计时会按照你配置的 mean/std 做预处理。所以 dataset.txt 里直接放原始图片路径就可以不必手动预处理后再存成另一个目录这点我最初也误解过。3.3 转换脚本核心里面的几个参数写一个最简转换脚本from rknn.api import RKNN rknn RKNN() # 关键mean_values / std_values 决定板端输入数据该喂什么格式 rknn.config( mean_values[[0, 0, 0]], std_values[[255, 255, 255]], target_platformrk3568 ) # 加载 ONNX 模型 ret rknn.load_onnx(modelyolov8s.onnx) assert ret 0, load_onnx failed # 构建并量化 ret rknn.build(do_quantizationTrue, datasetdataset.txt) assert ret 0, build failed # 导出 RKNN 模型 ret rknn.export_rknn(yolov8s.rknn) assert ret 0, export failed # 可以用模拟器快速验证 # rknn.init_runtime() # outputs rknn.inference(inputs[img])这里很多人会纠结mean_values和std_values到底该怎么填。我当时也踩了坑。YOLOv8 训练时候的输入是 RGB 或 BGR 的 0~1 浮点数但为了板端性能和内存带宽我们通常希望喂给 RKNN 的输入是 uint8 的 0~255 图像。这个时候设置mean_values[[0,0,0]]、std_values[[255,255,255]]工具和板端运行时会自动帮你把(pixel - mean) / std变成 0~1 范围内的浮点。也就是说板端推理时你只需要把 OpenCV 读出来的 BGR uint8 数据直接丢进去不用再手动除以 255。反过来如果你的 ONNX 模型接受的就是 0~1 浮点输入并且你打算在板端喂 float32 数据就可以设mean_values[[0,0,0]]、std_values[[1,1,1]]。但我不推荐这么做因为 float32 输入在内存和带宽上都不占优势尤其是在 RK3568 这种 NPU 资源有限、系统带宽也很紧张的平台上能用 uint8 输入就用 uint8。target_platformrk3568这个参数是根据板子 NPU 型号来的。如果你的板子是 RK3566和 RK3568 的 NPU 基本一致一般也可以直接用 rk3568 目标平台。最好在rknn.config里显式指定避免默认平台和你实际硬件不一致。3.4 模拟器与真实硬件的差异RKNN-Toolkit2 可以在 PC 上通过模拟器做推理得到一个 inference result。但不是所有情况都代表板端真实表现。模拟器的主要作用是验证模型转换成功、输出 shape 正确、后处理逻辑能跑通。它的计算精度和板端 NPU 实际推理精度可能有细微差异速度更是完全不反映真实性能。我见过有同学在模拟器上看到输出正常高高兴兴部署到板子结果发现坐标偏移一点点最后发现是板端预处理和后处理 scale 写错了。所以我一般把模拟器当作“能不能跑通”的检查不把它当作“精度达标”的依据。真正精度是否可用还是得部署到板端用实际图片验证。4. 板端部署与后处理实现4.1 用 C API 还是 Python APIRKNN 板端有两种使用方式C/C API 和 Python API。如果你只是做原型验证可以在板子上用 Python API代码写起来快。但如果是正式项目别犹豫直接用 C/C。原因很简单RK3568 的 CPU 本身不算强Python 解释器的开销和 numpy 的临时内存分配都会挤占资源。YOLOv8 后处理本来就有大量的遍历、排序、NMS 操作用 Python 写容易把整个帧率拖垮。我自己实测单独 Python 后处理就可能占掉 30ms 以上而用 C/C 实现同样的逻辑在几毫秒内就能跑完。调用 C API 的大致流程是#include rknn_api.h rknn_context ctx; ret rknn_init(ctx, model_data, model_size, 0, NULL); int input_width 640; int input_height 640; rknn_input inputs[1]; inputs[0].index 0; inputs[0].type RKNN_TENSOR_UINT8; inputs[0].size input_width * input_height * 3; inputs[0].fmt RKNN_TENSOR_NHWC; inputs[0].buf img_data; rknn_run(ctx, NULL); rknn_output outputs[1]; outputs[0].want_float 1; rknn_outputs_get(ctx, 1, outputs, NULL); float *output_data (float *)outputs[0].buf; // 此时输出是 (1,84,8400) 的连续数据在写 C 代码时要注意rknn_output的want_float字段决定要不要把定点输出转成 float。YOLOv8 的 ONNX 输出如果带着 DFL 解码和坐标转换一般设置want_float 1更稳避免自己在板端做反量化。虽然 float 输出会多一次转换但模型后处理简单很多。如果想追求极致性能可以尝试want_float 0然后根据量化参数手动反量化但那样后处理复杂度会高不少多数场景不值得。4.2 YOLOv8 后处理坐标缩放 置信度过滤 NMS拿到模型输出之后后处理主要做三件事坐标缩放、置信度过滤、NMS。假设 ONNX 输出 shape 是[1, 84, 8400]那么对每个候选框 i坐标字段是float cx output[i]; float cy output[8400 i]; float w output[16800 i]; float h output[25200 i];类别分数从第 4 个字段开始也就是output[4 * 8400 i]开始一直到output[83 * 8400 i]。这里是一段比较常见的嵌入式代码写法for (int i 0; i 8400; i) { float cx output[i]; float cy output[8400 i]; float w output[16800 i]; float h output[25200 i]; int class_id 0; float max_score 0.0f; for (int c 0; c num_classes; c) { float score output[4 * 8400 c * 8400 i]; if (score max_score) { max_score score; class_id c; } } if (max_score conf_thres) continue; float x1 (cx - w / 2 - pad_w) / scale; float y1 (cy - h / 2 - pad_h) / scale; float x2 (cx w / 2 - pad_w) / scale; float y2 (cy h / 2 - pad_h) / scale; // 保存 bbox 和 score }如果板端输入图像直接cv::resize到 640x640没有做 letterboxscale 就是640.0 / original_width和640.0 / original_height注意宽高可能不同。如果做了 letterbox坐标要先减去 pad 值再除以同一个 scale。YOLOv8 的输出是解码后的中心点 宽高坐标不像 YOLOv5 那样有 objectness 分数。所以我在代码里直接把每个类别的最大分数当作置信度然后过滤低置信度框。不要还按旧习惯去找第 4 个字段当 objectness那会出问题。NMS 我一般用最简单的按类别分别做的方式类别间不互相抑制。框数量在 8400 里面经过置信度过滤后一般只剩几十到几百个用双层循环的朴素 NMS 就够了。如果框特别多再考虑按类别排序或使用更高效的 NMS 实现否则没必要为了炫技把代码复杂度拉高。4.3 输入预处理与内存规划板端输入预处理有一个容易踩的坑直接用cv::resize拉伸到 640x640如果测试图片和训练图片长宽比差别很大检测精度会下降。YOLOv8 默认训练使用了 letterbox部署时也应该保持一致。具体做法float scale std::min(640.0f / img.cols, 640.0f / img.rows); int new_w (int)(img.cols * scale); int new_h (int)(img.rows * scale); cv::resize(img, resized, cv::Size(new_w, new_h)); int pad_w (640 - new_w) / 2; int pad_h (640 - new_h) / 2; cv::copyMakeBorder(resized, padded, pad_h, 640 - new_h - pad_h, pad_w, 640 - new_w - pad_w, cv::BORDER_CONSTANT, cv::Scalar(114, 114, 114));填充值用 114这就是训练时使用的默认填充颜色。当设置mean0, std255时114 归一化后大约是 0.447和训练时处于同一分布。内存方面RK3568 只有 0.8 TOPS 的 NPU通常系统的 DDR 带宽也有限。RKNN 推理时会申请模型输入输出缓冲区建议一套流程只初始化一次rknn_context不要每帧都重新加载模型或反复 init。帧循环里尽量复用输入输出 buffer避免频繁 malloc/free否则系统内存碎片会导致后期帧率下降。5. 实际部署中的常见问题与避坑记录5.1 算子不支持或转换报错这是 RKNN 转换时最经典的报错。有些算子 RKNN-Toolkit2 不支持常见的有Gather、ScatterND、NonMaxSuppression等。YOLOv8 官方导出的 ONNX 一般问题不大但如果你在检测头里加了自定义模块或者用了某些注意力机制就有可能在load_onnx或build时报 unsupported operator。处理这类问题我的排查思路是第一步用 onnx-simplifier 精简模型去掉不必要的节点第二步把模型导出里的检测头去掉只导出 backbone neck最后在板端自己接后处理第三步实在不行就用混合精度的思路把不支持的算子放在 CPU 侧执行虽然会拖慢速度但至少能跑通。我遇到过最麻烦的一次是在检测头里加了一个类似GridSample的模块RKNN 工具链不支持后来只能把这个模块拆到后处理里用 C 自己实现。所以如果你要魔改 YOLOv8一定要提前考虑算子最终能不能落到 RKNN 上别等训练完再想部署。5.2 INT8 量化后精度明显下降量化后 mAP 掉几个点很正常但如果掉到没法用就要检查校准集了。我见过很多案例量化校准集只用了几十张测试图片而且全是同一类目标量化出来的模型对场景外的图片基本瞎掉。还有一个容易忽略的点校准集图片的 EXIF 信息、色域、噪声分布如果和真实场景差太远也会影响量化精度。工业现场如果光照比较极端最好拿现场实际拍摄的图片做校准不要光拿公开数据集。如果校准集没问题但精度依然掉点严重可以尝试 RKNN-Toolkit2 提供的量化误差分析功能或者对某些敏感层做混合量化。不过 YOLOv8 经过良好导出的 ONNX 在 INT8 下通常掉点不大至少检测框还能保持大概率正确。真要追求极致精度在训练阶段做 QAT量化感知训练是更彻底的办法但成本也高很多大多数项目先用 200 张校准图跑一遍都能拿到能用的模型。5.3 输出 shape 不是我想要的 1x84x8400这里要分情况看。如果你拿到的输出是[1, 144, 8400]说明模型导出时并没有把 DFL 解码过程包括进去。这时板端后处理就不能直接使用 xywh 坐标而要做以下事情把1x144x8400的 144 维拆成1x4x16x8400对每个边对应的 16 个值做 softmax再和投影权重[0,1,...,15]做加权求和得到四个边的距离然后由 anchor 坐标换算成 xywh。这块要是完全用 C 写工作量不小所以除非有特殊原因我还是建议直接用官方的 ultralytics 导出流程拿到的是[1,84,8400]后处理省一半事。如果已经拿到[1,144,8400]的模型又不想重新导出那就只能硬着头皮实现 DFL网上也有参考代码但务必要把 softmax 和坐标尺度搞清楚。5.4 版本不匹配宿主机工具链和板端 Runtime 不一致RKNN 模型的相互兼容性不像 ONNX 那么好。有时候在 PC 上用高版本工具链转换成功推到板子上一加载就报parse RKNN failed或者driver version mismatch。这种情况八成是宿主机 RKNN-Toolkit2 和板端 librknnrt.so 的版本不一致。解决办法就是先确认板端 Runtime 版本然后反过来选择匹配的 RKNN-Toolkit2 版本。一般来说同一个版本的 rknn-toolkit2 配套的rknpu2运行库是固定对应的。如果已经有线上固件不能随便刷就按固件里 librknnrt.so 版本来装对应版本的转换工具不要图新装个最新版。查询方法strings /usr/lib/librknnrt.so | grep -i version或者看版本节点。这个坑最容易出现在“先用 A 版本工具转换后来升级了 B 版本工具”的情况。项目里最好把这个版本信息写到部署文档里不然过几个月没人记得当初用的哪一版工具。5.5 板端推理速度达不到预期如果你明明用的是 INT8 模型但上板后速度还是很慢先不要怀疑 NPU 不行检查几个地方确认模型输入分辨率是不是 640x640分辨率过高会显著增加 NPU 计算量检查板端 CPU 是不是把大量时间花在了 NMS 后处理上后处理要放到编译优化后的 C/C 代码里别用 Python检查 NPU 频率是否被限制有些定制板子默认 NPU 工作在低频率可以看看cat /sys/class/misc/rknpu/device/devfreq/*/cur_freq必要时调高频率检查图像预处理是否在 CPU 上做了大量缩放和拷贝如果每帧都做cv::resize copyMakeBorder 格式转换CPU 占用也会上去。我遇到过一个诡异情况转换时用了simplifyTrue之后模型确实变小了但板端推理反倒变慢。最后定位发现是某些算子被合并成一个大矩阵乘法在 NPU 上分片变多了。所以性能优化这个东西不能只看模型体积还是要实际板端 profile 为准。5.6 常见问题速查表现象可能原因解决思路load_onnx 报 unsupported op模型里有 RKNN 不支持的算子精简模型、拆分后处理、CPU 兜底量化后精度崩校准集数量少 / 分布不匹配增加校准图贴近实际场景输出 shape 是 1x144x8400检测头 DFL 解码没进 ONNX按格式实现 DFL 解码或重新导出板端加载 RNK 失败工具链与 Runtime 版本不匹配统一版本组合帧率低输入分辨率、后处理、NPU 频率逐项排查优化检测框偏移预处理未做 letterbox 或 scale 换算错检查垫边和坐标缩放类别检测不到校准集缺少该类别样本补充该类别的代表性图片6. 最后再分享一点实战体会从我个人的经验看YOLOv8 在 RK3568 上部署最大的门槛不是“转换”本身而是对整条链路的理解。很多同学在 PC 上把模型训练好之后下意识地认为部署就是把模型文件格式换一下。但嵌入式部署是一个系统工程模型结构、算子支持、量化校准、输入预处理、后处理实现、内存管理每一个环节都会影响最终效果。我现在做 RK3568 相关项目固定流程是先拿一个最简单的官方 demo 跑通 NPU 推理再加入自己的模型先在 PC 上用模拟器验证输出再上板先不优化速度把精度和点位全部对齐再去做性能和内存优化。这个顺序能帮你少走很多弯路。如果你也准备在 RK3568 或其他 RKNN 平台上部署 YOLOv8建议先把 yolov8s.onnx 转换到[1,84,8400]这个标准输出然后把后处理代码写透再谈性能优化。希望这份避坑记录能给你省下几天调试时间。
