这次轮到RK3588上跑YOLOv5s系列最关键的环节了:把PyTorch训练好的权重,变成RK3588 NPU能“听懂”的RKNN格式,再做INT8量化。上一篇我们把模型训好、导出成ONNX,很多朋友走到这一步就卡住了——明明在PC上推理一切正常,rknn-toolkit2一转就报错,或者转完精度掉得一塌糊涂,甚至直接满屏乱框。这篇我把自己的完整流程、踩过的坑、参数调优经验全写出来,照着做能把从pt到rknn这条路走通,省掉大部分查文档的时间。
这篇适合手上已经有YOLOv5s模型、想在RK3588上跑实时推理的开发者,也适合刚接触RKNN工具链、被各种版本和报错劝退的新手。你不需要懂太多底层原理,但需要一点Python基础和基本的命令行操作。我会从环境搭建开始,讲到ONNX导出、RKNN转换、INT8量化校准、精度验证、板端推理性能调优,最后附上我遇到的高频问题和排查思路。
我用的环境是PC端Ubuntu 20.04 + rknn-toolkit2 1.6.0,板端是RK3588 + Ubuntu 22.04(RKNPU2 runtime)。配置不同的话版本号会有点差异,但整体思路完全通用。
1. 环境准备与工具链选型
1.1 RK3588 为什么要用 rknn-toolkit2
先说清楚一个很多人容易混淆的点:rknn-toolkit(不带2)是给RK3399、RK3566、RK3568这些老平台用的,RK3588必须用rknn-toolkit2。两者的API不完全兼容,网上搜到的很多教程是rknn-toolkit 1.x的写法,直接搬到3588上大概率报错。
rknn-toolkit2的版本更新比较快,我建议直接选1.6.0以上的版本。早期版本对YOLOv5的支持不够好,尤其是Focus层、SPPF这些结构容易在转换时报“Unsupported op”之类的错误。1.6.0对应的是RKNPU2的runtime版本2.2.0,这个组合我测下来最稳。
还有一点要特别注意:rknn-toolkit2分两个用途,一个是PC端做模型转换和量化,使用的是完整版的Python包;另一个是板端推理时使用的runtime库。千万别把PC端的工具包装到板子上,板端只需要runtime的so库和轻量级的Python接口,后面我详细说。
1.2 PC端Python环境配置实操
我用的是conda创建的独立环境,这样做的好处是隔离依赖,不会把系统Python搞乱。rknn-toolkit2对Python版本有明确要求,1.6.0支持Python 3.8和3.10,我用的3.8。
创建环境并安装依赖:
conda create -n rknn python=3.8 conda activate rknn pip install rknn_toolkit2-1.6.0-cp38-cp38-linux_x86_64.whl这个whl文件从瑞芯微官方的rknn-toolkit2仓库Release页面下载。装完之后验证一下:
from rknn.api import RKNN print(RKNN.__version__)能正常导入就OK了。如果报缺少依赖,按提示补装numpy、onnx、opencv-python等包。我建议在同一个环境里也装上onnxruntime和opencv,因为后面验证ONNX输出、准备量化数据集都要用。
提示:不要在Windows上用WSL跑rknn-toolkit2,我试过坑很多,GPU透传、USB连接都容易出问题。老老实实用原生Linux,或者Windows下用虚拟机装Ubuntu也行。
2. RKNN 转换全流程实操
2.1 从 YOLOv5s 导出 ONNX 的关键细节
YOLOv5官方仓库提供了export.py,用起来很简单,但有几个参数跟RKNN转换强相关,要特别注意。
导出命令:
python export.py --weights yolov5s.pt --include onnx --opset 12 --batch-size 1为什么opset要用12?RKNN工具链对ONNX算子支持最完善的是opset 10到12,高版本的opset可能会引入一些RKNN还没适配的算子(比如一些新的slice写法),导致转换失败。我用opset 13试过,build阶段偶发报错,改回12就没事。
batch-size固定为1。很多人会问动态batch怎么办,RKNN支持通过input_size_list设置多档输入,但推理时batch=1已经够用,动态shape会显著增加转换复杂度和推理延迟,没必要。
还有一个至关重要的点:导出时不要带NMS。YOLOv5的export.py默认会把NMS以自定义节点形式写进ONNX,但RKNN的NPU不执行NMS,这个操作必须放在CPU端做后处理。带NMS导出的模型转成RKNN后,要么报不支持的算子,要么输出tensor跟你预期完全对不上。
验证ONNX输出是否正常,用onnxruntime跑一下:
import onnxruntime as ort import cv2 import numpy as np sess = ort.InferenceSession('yolov5s.onnx') input_name = sess.get_inputs()[0].name img = cv2.imread('test.jpg') img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (640, 640)) img = img.astype(np.float32) / 255.0 img = np.transpose(img, (2, 0, 1))[None] outputs = sess.run(None, {input_name: img}) for i, out in enumerate(outputs): print(i, out.shape)正常的话应该输出3个shape为(1, 255, 80, 80)、(1, 255, 40, 40)、(1, 255, 20, 20)的tensor,分别对应三个尺度的检测头。如果shape不对或者只有一个输出,先检查导出步骤。
2.2 转换脚本与配置参数解析
这是全篇最核心的部分。下面是我一直在用的转换脚本:
import os import cv2 import numpy as np from rknn.api import RKNN rknn = RKNN() # 配置 rknn.config( mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3588', optimization_level=3 ) # 加载ONNX模型 ret = rknn.load_onnx(model='yolov5s.onnx') if ret != 0: print('模型加载失败') exit(-1) # 先不量化构建模型,用于验证转换正确性 ret = rknn.build(do_quantization=False) if ret != 0: print('构建模型失败') exit(-1) # 导出RKNN模型 rknn.export_rknn('yolov5s_fp16.rknn') # 用模拟器验证输出 rknn.release()mean_values和std_values这两个参数,跟训练时预处理完全一致。YOLOv5源码里对输入做了除以255的归一化,所以mean=0、std=255。如果你训练前用了其他预处理(比如自定义归一化、ImageNet的mean/std),必须同步改,这里不一致会导致算法精度崩盘,后面展开讲。
target_platform必须写小写字母,写成RK3588或rk3588_linux都会在build阶段报错。
optimization_level我用的3,表示最高优化。但这里要留个心眼:某些算子在高优化等级下会被融合或改写,如果你的模型转换后输出异常,先降到2或者1试试。
先不量化构建一次模型很重要。第一次build做的是模型结构转换和算子映射,如果ONNX里有不支持的算子,这一步就报错了。这时候排查问题比混着量化报错容易得多。确认FP16模型输出正常后,再拿同一份配置去做量化,不要一上来就do_quantization=True。
FP16版本的rknn模型留个备份,后面做INT8精度对比时有大用。
2.3 转换后验证:用模拟器跑一遍
rknn-toolkit2提供了PC端的模拟器,可以把整个模型在x86上跑一遍推理,方便在板子上板之前验证转换结果。代码框架如下:
from rknn.api import RKNN import cv2 import numpy as np rknn = RKNN() rknn.load_rknn('yolov5s_fp16.rknn') # 初始化运行环境,模拟器模式 ret = rknn.init_runtime(target=None) if ret != 0: print('模拟器初始化失败') exit(-1) img = cv2.imread('test.jpg') img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (640, 640)) img = np.expand_dims(img, 0).astype(np.float32) outputs = rknn.inference(inputs=[img], data_format='nhwc') print([out.shape for out in outputs])这一步跑出来的结果,跟onnxruntime的输出做对比。形状应该一致,数值是float类型(FP16模拟精度会有微小偏差,正常)。这里输出的三个tensor只是裸的检测头输出,还没做解码和NMS,你不能直接拿来画框。
我习惯在这个阶段把输出tensor和onnxruntime的输出做数值对比,计算cosine相似度。相似度在0.99以上基本说明转换没问题,如果某个尺度输出相似度偏低,说明那个分支有算子被异常优化,得回去调optimization_level。
3. INT8 量化:原理与实战
3.1 RK3588 NPU 的算力分配,为什么不直接跑 FP16
RK3588内置的NPU标称算力是6 TOPS,那是INT8算力,FP16只有一半,INT16又砍半。也就是说,如果模型用FP16精度跑,实际只能发挥NPU大概一半的算力。既然做边缘端实时检测,性能就是核心指标,没理由跟INT8过不去。
打个比方,FP32就像开卡车搬货,每趟能拉得稳但跑不快;FP16像普通货车,载重腰斩但快了一点;INT8则是小跑车,载重更小但转运频率极高,整体吞吐反而最大。这里的“载重”就是数据表示的精度范围,“转运频率”就是NPU的并行计算吞吐。
还有一个被忽视的点:INT8量化后模型体积变成原来的四分之一,内存带宽占用也大幅降低。RK3588的内存带宽是有限的,在640x640输入下跑YOLOv5s,带宽往往比算力更早成为瓶颈。我在实测中对比过,同样跑YOLOv5s,FP16版本内存占用大约370MB,INT8版本只有105MB左右。这个差距在长时间运行、多路视频输入时非常致命。
3.2 量化数据集怎么选,很多人第一步就错了
rknn-toolkit2的量化校准需要你提供一个图片列表,工具会统计这些图片在模型各层激活值的分布,然后计算INT8的scale和zero_point。数据选得好不好,直接决定量化后精度损失多少。
我需要强调一个核心原则:量化数据集要能代表推理时遇到的数据分布,不是随便找几张图凑数。
我的做法:
- 数量:最少500张,推荐800到1000张。网络上一些教程说一两百张就够,我实践下来对小模型尤其不保险,尤其是背景复杂的目标,量化后漏检率升高。
- 来源:从验证集里均匀抽。如果验证集本身跟训练集同分布,效果最好。如果是新场景,建议从真实应用场景拍视频抽帧,覆盖不同光照、不同距离、不同目标密度。
- 内容多样性:必须包含少量没有目标物的背景图,防止模型在纯背景上产生大量误检框。单张图里目标数量也最好有差异,不要全是密集场景。
- 分辨率:量化图片尺寸统一做letterbox到模型的输入尺寸(640x640),不要直接resize拉伸,否则物体形状变形,校准出的分布也偏了。
这里的关键一点:校准数据别做增强。有些同学直接拿训练集数据增强后的结果去做量化,加了马赛克、翻转、色彩抖动,这些分布都是“扭曲”的,会干扰量化统计。我踩过这个坑,量化后精度掉到接近随机,换成原始验证集重做之后一切正常。
量化数据集的预处理代码,先转RGB再resize,保持通道顺序与模型输入一致:
def load_quant_data(img_path, target_size=640): img = cv2.imread(img_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (target_size, target_size)) return img3.3 量化原理简析:用最通俗的方式说清 scale 和 zero_point
INT8量化本质上是把一个浮点范围映射到一个8位整数范围。以最常用的均匀对称量化为例,浮点区间[-a, a]映射到INT8区间[-128, 127],映射关系是线性的,a除127得到scale,浮点0对应整数0,zero_point就是0。
对于非对称量化,浮点区间[min, max]映射到[0, 255],这时候除了scale还有一个非零的zero_point,用来表示浮点0对应的整数。
关键问题是:a或者[min, max]到底怎么定?不同的选择策略影响很大。
- 如果选max,即取激活值的最大值作为阈值,这对分布均匀的数据效果好,但对有长尾分布的数据很容易被几个极端值带偏,导致中间大量数值被压缩到同一个int8区间,信息严重丢失。
- kl散度算法是模仿TensorRT的思路,不断收紧阈值范围,直到截断后的分布与原始分布的KL距离最小。这个过程能自动丢弃一些极端长尾值,对YOLO这种深而宽的网络更友好。
rknn-toolkit2在config阶段可以设置quantized_dtype和quantized_algorithm,我试过normal和kl两种,kl设置下量化后mAP普遍比normal高2到3个百分点,代价是校准时间变长,但对离线转换无所谓。
3.4 量化后的精度评估与混合量化调优
量化的完整流程代码:
from rknn.api import RKNN import cv2 import numpy as np import os # 准备量化数据集,文件内容是图片路径,每行一个 quant_data = [] data_dir = 'quant_dataset' for f in os.listdir(data_dir): if f.endswith('.jpg'): img = load_quant_data(os.path.join(data_dir, f)) quant_data.append(img) # 检查数据,避免空列表 assert len(quant_data) >= 100, '量化数据太少' rknn = RKNN() rknn.config( mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3588', quantized_algorithm='kl', quantized_method='channel', optimization_level=3 ) ret = rknn.load_onnx(model='yolov5s.onnx') if ret != 0: exit(-1) ret = rknn.build(do_quantization=True, dataset='dataset.txt') if ret != 0: exit(-1) rknn.export_rknn('yolov5s_int8.rknn')dataset.txt文件里每一行是图片路径,注意这里传给build的不是图片数组而是路径文件,rknn内部会自己读图。所以dataset.txt里的路径要能被当前进程访问到,我用的是绝对路径省得出错。
量化完成之后,第一件事不是上板测延迟,而是评估精度。我的评估方法有两种:
一是快速可视化对比:取十几张典型图片,分别用FP16的rknn模型和INT8的rknn模型跑推理,画框对比,看看漏检、误检、置信度差距。这个办法直观、快,适合快速定位明显问题。
二是定量指标:在验证集上跑mAP对比。YOLOv5仓库自带val.py,但rknn的模型格式不能直接喂给PyTorch的验证脚本,需要写一套rknn推理+标准mAP计算的代码。工作量不大,核心是把输出tensor解码成框。我在验证集5000张图上实测,INT8相对FP16的mAP损失控制在0.8%到1.5%之间。
如果精度掉太多(比如mAP掉超过5%),从这几方面排查:
- 确认mean/std和训练一致。这是第一嫌疑,也是最常见的翻车原因。YOLOv5原生训练是除以255,如果误写成ImageNet的mean=[0.485,0.456,0.406]、std=[0.229,0.224,0.225],量化直接废掉。
- 检查量化数据集的通道顺序。rknn读图默认是BGR还是RGB取决于dataset格式,如果训练模型用的是RGB,而量化数据用了BGR,相当于模型看到的是经过通道交换的图,精度崩是必然的。我在脚本里就是先cvtColor转RGB再保存,dataset里是转好的图。
- 尝试per-channel量化。如果某一层某些channel数值范围极大,per-tensor量化会拉低整体精度,per-channel能逐通道调整scale,在卷积层效果通常更好。
- 局部量化。rknn支持对某些层跳过量化,保留FP16精度。具体做法是在config里设置custom_quantize_layers,这个用起来有点绕,我看官方文档也不够详细,建议作为最后手段。
4. 板端部署推理与性能调优
4.1 RK3588 上加载 RKNN 模型推理
板端部署最简化的流程是:先把librknnrt.so放到板子上,通常路径是/usr/lib/,然后安装rknn-toolkit2里的runtime Python包。板端用的接口跟PC端类似,但不需要RKNN类做build,直接load并inference。
from rknnlite.api import RKNNLite import cv2 import numpy as np rknn = RKNNLite() rknn.load_rknn('yolov5s_int8.rknn') rknn.init_runtime() img = cv2.imread('test.jpg') img = cv2.resize(img, (640, 640)) # NPU输入不需要转RGB,RKNN内部按config的mean/std处理 # 但如果你config是用RGB标准做的,这里传RGB更一致 img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img_rgb = np.expand_dims(img_rgb, 0).astype(np.uint8) outputs = rknn.inference(inputs=[img_rgb], data_format='nhwc') print([out.shape for out in outputs])注意板端推理时输入的数据类型可以是uint8,PC模拟器通常用float32,这也是一个小差异。实际测下来uint8输入比float32输入快一些,因为省去了一次类型转换。
由于rknnlitet的接口是轻量级的,不带调试功能,所以模型有问题请先在PC端模拟器排查完再上板,否则板端的报错信息会非常有限,排查效率极低。
4.2 实测性能数据:不同精度和分辨率下的推理耗时
我在RK3588上实测了YOLOv5s,输入640x640,结果如下:
| 模型版本 | 推理耗时(单帧) | 帧率 | 备注 |
|---|---|---|---|
| FP16 | 约32ms | 约30 FPS | 未充分使用INT8算力 |
| INT8(per-tensor) | 约16ms | 约60 FPS | 常规布局 |
| INT8(per-channel) | 约15ms | 约65 FPS | 精度更高,速度略快 |
这个数据是单核NPU的情况。RK3588有三核NPU,如果按多核调度,还有进一步提升空间。实测开启三核后INT8版本能达到80 FPS以上,但功耗和发热也会明显上升。
如果你的应用对延迟敏感(比如机器人抓取),建议固定使用单核,延迟更可预测;如果追求吞吐(比如多路视频分析),优先把多核开起来。
4.3 从推理到检测框:后处理优化策略
NPU输出三个特征图,之后要在CPU上做解码和NMS。这个后处理如果写得粗糙,CPU耗时甚至可能超过NPU推理,白瞎了INT8的加速。
我的做法是在C++端实现解码+NMS,Python调用。核心解码逻辑跟YOLOv5的官方实现一致,但做了几点优化:
- 提前把anchor值写死成常量,不要在推理循环里重新计算。
- 避免使用numpy的高层函数做逐元素遍历,直接操作底层数组。
- 两类框过滤:先在每个尺度的低置信度上做粗筛,再用conf_thres过滤掉明显的背景框,大幅减少进入NMS的候选框数量。
我这边实测,640输入、单帧约30个目标,C++解码+NMS耗时约4ms,Python实现则要15ms以上。如果还是坚持纯Python,建议至少用numpy向量化代替循环。
4.4 多核NPU调度和功耗控制
rknn.init_runtime方法里可以设置core_mask参数:
rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_0)可选的值有NPU_CORE_0、NPU_CORE_1、NPU_CORE_2、NPU_CORE_AUTO。AUTO模式让驱动自动调度,多数时候能均衡利用三核,但偶尔会因为核心间同步引入抖动。固定单核的策略最稳,实时性更好。
性能调优有个容易忽略的点:NPU核心频率是动态调频的,系统负载一高,频率会下降。如果你的推理任务要求长时间稳定帧率,最好把NPU频率锁到最高档。可以用cat /sys/kernel/npu_freq这类节点查看,不同固件路径有差异。锁频之后功耗增加是必然的,一般来说跑YOLOv5s INT8整机功耗能到8到10W,散热不行的板子小心降频。
5. 常见问题与排查技巧实录
5.1 RKNN转换阶段高频报错
| 错误信息 | 原因 | 解决办法 |
|---|---|---|
| E Catch exception when building model | ONNX模型里有不支持的算子 | 确认opset=12;检查是否带NMS导出;定位不支持层并替换 |
| E TargetPlatform is invalid | target_platform写错 | 检查是否为小写字母,如rk3588 |
| E License key is invalid | 未配置或过期 | 用注册码激活,或确认无离线限制版本 |
| E shape is not match | 模型输入尺寸与配置不一致 | 确认导出时固定shape,检查input_size_list |
| W The output shape ... is changed | 优化等级导致的输出变动 | 降低optimization_level做对比 |
5.2 量化后一个匪夷所思的问题:输出全为零
我遇到过一次很诡异的bug:INT8模型在PC端模拟器上推理完全正常,但烧到板子上跑,三个输出tensor全是0,一个框都没有。排查了很久,最后发现是板端加载模型时输入数据是BGR,而PC端模拟器用的是RGB。
虽然rknn.config里配置了mean/std,但如果预处理里有个cv2.cvtColor,而板端忘了这个步骤,模型看到的输入就是反过来的通道排列。注意NPU上的INT8输入通常要求uint8格式,通道顺序必须和训练一致。
这类问题非常隐蔽,因为FP16模型对通道顺序相对不敏感,但INT8量化对输入分布很敏感,通道一错,输出直接崩。所以我把推理代码里的预处理统一封装成函数,板端和PC端共用同一份,从源头杜绝差异。
5.3 精度暴跌排查清单
精度暴跌这件事情,90%的情况是数据管道的错,不是量化本身的错。下面是我总结的排查顺序:
- 先用同一组图片比较FP16 rknn和onnxruntime的输出,确认转换链路没问题。
- 再比较INT8 rknn和FP16 rknn,确认量化环节没问题。
- 检查量化数据集是否来自真实测试分布。
- 检查mean/std、通道顺序、resize方式是否跟训练时完全一致。
- 增加量化图片数量,从200张增到800张,看精度是否有回升趋势。
- 切换量化算法,从normal改为kl。
- 尝试per-channel量化替代per-tensor。
- 最后才考虑混合量化,但通常轮到这一步前面已经解决了。
5.4 一个容易被忽略的存储问题
导出的rknn文件会附带固件版本信息,板端的runtime需要跟PC端rknn-toolkit2版本兼容。rknn-toolkit2 1.6.0生成的模型,要求板端librknnrt.so版本不低于2.2.0。如果版本不一致,init_runtime会报版本错误,或者推理结果莫名异常。
检查板端runtime版本:
strings /usr/lib/librknnrt.so | grep version不想在版本问题上纠缠,最稳妥的做法是:板端刷完固件后,用rknn-toolkit2仓库里自带的librknnrt.so替换系统里那个,或者安装官方打包的runtime deb包。这一步做完,版本问题基本一劳永逸。
6. 一个完整的端到端检查清单
最后分享我每次部署新模型都会走的流程,照着这个顺序做,很难踩大坑:
- 确认ONNX输出shape和预期一致。
- 先build不量化版本,PC端模拟器验证精度。
- 不量化版本上板子验证一遍精度和延迟。
- 准备800张以上的量化数据集,内容分布贴近真实场景。
- 构建INT8版本,PC端模拟器对比FP16预测框,确认没有明显劣化。
- 上板子跑真实场景,连续运行1000帧统计漏检率、误检率。
- 用数据验证INT8推理耗时和帧率是否达标。
- 保存一套完整的配置文件(量化版本、预处理参数、性能数据),方便后续回归。
我实际开发中最大的体会是:RKNN转换和量化本身步骤不多,真正的坑全在数据管道上。同一份模型,量化数据集选得好不好、预处理跟训练一不一致,效果天壤之别。第一次做的时候别着急上板,先在PC端把精度验证这一步做扎实,后面能省心很多。
如果你卡在某个具体报错上,或者模型结构不是标准的YOLOv5而是魔改版,欢迎在评论区把报错日志和模型结构发出来,我帮你看是哪里的问题。下一篇可能会写RKNN模型的多路视频推理和调度优化,如果在看的人数多,我就把C++后处理的那套代码开源出来。