YOLO26 目标检测的训练到瑞芯微平台适配,这条链路我最近刚完整跑通一遍。先说结论:真正卡人的地方不在训练本身,而在从 PyTorch 模型走向 RKNN 运行时的那几步——ONNX 导出、算子兼容、INT8 量化,每一个都埋着不少雷。我是在给一款基于 RK3588 的智能盒子做边缘检测功能时趟完这条路的,前后折腾了两周,模型训到可用只花了三天,剩下时间全在跟转换工具链和后处理代码较劲。这篇就把这条链路的完整过程、关键参数和踩坑经验写出来,给准备做边缘端目标检测、手上正好是瑞芯微 RK3588 这类板子的朋友做个参考。
我默认你已经有基本的深度学习训练经验,知道标注数据是怎么回事,也能跑通简单的训练脚本。下面我从动机讲起,一步步拆解训练到部署的完整链路。
1. 为什么是 YOLO26 加瑞芯微:这套组合的真实动机
1.1 模型侧的版本取舍
YOLO26 这个版本号,社区里一开始也有人质疑,说无非是在 YOLOv8 基础上又改了个大版本号。但实际用下来,它在结构上确实延续了前几代的 C2f 模块和 DFL 检测头设计,训练接口也保持了 Ultralytics 系列一贯的"命令行一把梭"风格。对我来说最重要的是:它继承了 YOLO 系列对边缘部署相对友好的特性——模型体积可控、推理速度快、检测头可以直接剥离做定制导出。
选择 YOLO26 而不是更早的版本,主要原因有三个:一是它的 n/s 轻量型号在 INT8 量化后模型文件只有几 MB,非常契合板端存储和加载速度的要求;二是新版本默认集成了更多数据增强策略,同样的数据集下小目标召回率通常比老版本高一截;三是新版本在后续工具链兼容性上持续在跟进,导出 ONNX 时踩到历史遗留算子的概率会小一些。当然这种"新"也有代价,就是网上的部署教程普遍滞后,得自己折腾。
1.2 平台侧的现实约束
瑞芯微的 NPU 不是一个能用 PyTorch 直接推理的设备,它需要把模型转成 RKNN 格式,再由 RNPU 驱动加载运行。RK3588 的 NPU 算力在 6 TOPS 左右,这块算力跑 YOLO26n 这种规模的模型绰绰有余,RK3568 这种 1 TOPS 级别的芯片跑轻量模型也能达到可用帧率。相比英伟达的 Jetson 系列,瑞芯微的方案在功耗和成本上有明显优势,因此很多工业检测、物流分拣、智慧安防类的边缘设备都选了这条路。
不过代价就是工具链相对封闭。瑞芯微官方提供的 RKNN-Toolkit2 负责在 PC 端把 ONNX 转换成 RKNN 模型,板端再用 RKNN Runtime 或 rknn-toolkit-lite2 做推理。整个转换链路里,ONNX 算子的支持程度、量化精度控制、后处理算子的放置位置,都直接影响最终效果。这套组合真正的工作量不在地模型"能不能跑",而在"能不能转得过去、部署起来顺不顺"。
2. 训练阶段容易被忽视的细节
很多人觉得训练是套路活,按默认参数跑就行。但从部署角度看,训练阶段的很多选择会直接决定后面 RKNN 转换和推理精度能不能保持住。下面几个点是我实际验证过、认为比较关键的。
2.1 数据集组织与标注格式
如果你是从零开始做自己的数据集,目录结构最好按 YOLO 系列一贯的约定组织:
dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yamllabels 里每个 txt 文件对应一张图片,每一行是一列类别和四个归一化坐标,顺序是 class_id, x_center, y_center, width, height。这个格式看似简单,但有个很容易出错的地方:坐标必须归一化到 0-1 之间,而且是以图片宽高分别归一化,不是等比缩放后共用同一个尺度。我见过有人把中心点和宽高全部除以图片宽度,导致检测框在竖图上全部变形,训练时 loss 还不显示异常,直到上板实测才发现框的位置全偏了。
data.yaml 的内容也很直白:
path: dataset train: images/train val: images/val nc: 3 names: ['person', 'vehicle', 'package']nc 和 names 的顺序必须和标注文件里 class_id 完全对齐。这个不检查清楚,训练时模型学到的类别顺序是乱的,部署后输出类别含义也是错的。
2.2 训练参数与监控指标
训练命令我用的就是 Ultralytics 系列的标准写法:
yolo detect train \ data=dataset/data.yaml \ model=yolo26n.pt \ epochs=200 \ imgsz=640 \ batch=32 \ device=0 \ optimizer=AdamW \ lr0=0.001 \ cos_lr=True \ patience=30几个参数值得展开说。imgsz 我建议训练时直接用 640,不要为了省显存降到 320,因为边缘部署时你大概率会希望保持 640 的检测精度,训练和推理分辨率一致能减少 domain gap。batch 大小根据显存调整,但如果 batch 低于 16,建议把 lr0 适当调低到 0.0005 附近,否则容易训飞。
训练过程中的监控我是这样做的:每个 epoch 看 loss 曲线的三项——box_loss、cls_loss、dfl_loss,以及验证集上的 metrics/precision、metrics/recall、metrics/mAP50(B)、metrics/mAP50-95(B)。其中 mAP50-95 是综合指标,mAP50 更贴近实际二分类场景的直观感受。如果 box_loss 已经收敛但 cls_loss 还一直抖,往往是类别不均衡,可以考虑给少数类加样本或者做类别权重。
2.3 训练阶段要为部署预留的伏笔
这一点是最容易被忽略的:训练时所用的预处理方式,必须和部署端保持一致。Ultralytics 系列默认是 BGR 输入、像素值范围 0-255,推理前做 letterbox 缩放。后面 RKNN 转换时你要在 config 里设置 mean_values 和 std_values,如果这里和训练预处理不一致,量化后的精度会掉得非常难看。我在转换阶段就吃过这个大亏,后面第四章详细说。
另外,如果部署目标是小目标居多的场景,训练时可以显式加上 small_object 相关的增强策略,比如提高 mosaic 和 mixup 的概率,或者在训练后期关闭 mosaic 让模型适应真实分布。YOLO26 的默认增强已经不错,但我还是建议在训练最后 20 个 epoch 关闭 mosaic,这个技巧在很多实际项目中都能稳涨两三个点的 mAP50。
3. 导出 ONNX:决定 RKNN 转换成败的第一关
3.1 导出命令与关键参数
训练完成后,导出 ONNX 是 RKNN 转换的前置条件。这一步如果没做对,后面 RKNN-Toolkit2 会报出一堆莫名其妙的算子不支持错误,而且很难排查。我的推荐导出命令是:
yolo detect export \ model=runs/detect/train/weights/best.pt \ format=onnx \ opset=12 \ simplify=True \ dynamic=False \ imgsz=640 \ batch=1几个参数值得重点解释。opset 12 是我在 RKNN-Toolkit2 上测试下来兼容性比较好的版本,opset 太高会出现某些较新算子不被解析的情况,太低又可能没有 YOLO 检测头需要的算子。dynamic=False 必须显式指定,因为 RKNN 在转换阶段要求输入形状完全固定,动态尺寸会直接导致转换失败或得到错误的中间表示。simplify=True 会调用 onnxsim 对计算图做常量折叠和冗余节点消除,这能显著减少 RKNN 的解析压力。
batch=1 也是强制的,板端推理通常一次只处理一帧,没必要导出 batch 大于 1 的模型,而且 RKNN 对动态 batch 的支持很差。
3.2 算子兼容性排查与模型改写
导出后别急着转 RKNN,先看一眼 ONNX 的算子列表。YOLO26 的检测头里有几个常见算子:DFL(Distribution Focal Loss)解码相关的卷积和 softmax,以及最后的 concat 拼接输出。RKNN 对纯卷积、Sigmoid、Concat 这类算子支持很好,但对 DFL 里的一些 reshape 和 softmax 组合支持不稳定,尤其在不同版本的 toolkit 上表现不一致。
我的处理思路是:导出一个"剥离检测头"的版本,也就是只保留 backbone 和 neck,输出原始三个特征图,检测头的解码逻辑全部放到板端 CPU 上用代码实现。这样既绕开了 RKNN 对 DFL 算子的兼容性问题,也让 NPU 只负责真正适合并行计算的卷积部分,后处理反而更灵活。
具体做法是在导出前对模型结构做一点小手术,把 Detect 模块拆开,或者在 ONNX 图上手动删除检测头节点,保留三个特征层输出。Ultralytics 系列默认导出会包含完整检测头,所以我通常用脚本加载模型后替换 self.model[-1] 为一个只输出三路特征的自定义 Module。这段代码不复杂,但需要你对模型类结构有基本了解。核心思路是:让导出的 ONNX 输出形状为 (1, 4+nc, 80, 80)、(1, 4+nc, 40, 40)、(1, 4+nc, 20, 20) 这样的纯张量,不要包含任何 DFL 后处理。
如果你实在不想改模型结构,另一个办法是导出后手动把包含 DFL 的节点从计算图中剥掉,用 onnx 库遍历图、删除检测头到输出之间的算子,再把输出边重新接到中间特征层上。这个办法对不熟悉模型结构的人更直观,但要注意保持输入输出的张量名对应关系,否则 RKNN 解析时会找不到输出节点。
4. RKNN 模型转换:一步一个坑
4.1 RKNN-Toolkit2 基本转换流程
RKNN-Toolkit2 是在 PC 端运行的 Python 包,我用的是 x86 环境的 Ubuntu,Python 3.8 左右。安装完依赖后,转换脚本的核心部分如下:
from rknn.api import RKNN rknn = RKNN(verbose=True) rknn.config( mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3588' ) ret = rknn.load_onnx(model='best.onnx') if ret != 0: print('load onnx failed') exit(-1) ret = rknn.build( do_quantization=True, dataset='dataset.txt' ) if ret != 0: print('build rknn failed') exit(-1) rknn.export_rknn('best.rknn')dataset.txt 是量化校准集,每一行一个图片路径。这个文件的作用是给模型喂一批真实图片,统计每个激活值的分布范围,从而确定量化参数。我建议收集 50 到 200 张覆盖目标所有可能出现场景的图片,不用带标注,但一定要"代表真实分布"。比如你部署的摄像头是俯视角,校准集里就不该全是平视角图片,否则量化后模型对实际场景的分布估计是错的。
mean_values 和 std_values 这里必须和训练预处理完全一致。YOLO26 训练默认不做 0-1 归一化,而是直接输入 0-255 的原始像素值,所以 mean 设 0,std 设 255。如果你的自定义训练流程用了别的归一化方式,这里要相应调整。我见过一个项目把 mean 设成 128,std 设成 128,模型转出来后检测框全歪,排查了一天才发现是预处理不匹配。
4.2 量化精度损失:常见的掉点原因
INT8 量化后模型掉点是一个躲不开的话题。正常情况下,mAP50 掉 1 到 3 个点是完全可以接受的,但如果你的模型量化后掉点超过 5 个点,问题大概率不在网络本身,而在以下几个方面。
第一,校准集数量太少。只放 10 张图就想把激活分布统计准,几乎不可能。我一般至少放 80 张,类别尽量平衡。第二,校准集和实际部署场景分布偏差太大。这个前面说过了,不赘述。第三,某些敏感层被量化后误差被放大。这种情况可以尝试用 RKNN 提供的混合量化功能,把特定层的量化精度从 INT8 提升到 FP16,代价是这部分算子在 NPU 上运行效率降低,但精度恢复很明显。
RKNN-Toolkit2 提供了一些精度分析工具,可以输出每一层量化前后的输出误差对比。我当时用它的精度分析接口定位到几个对量化敏感的输出节点,然后把它们单独排除在量化之外,最终 mAP 恢复到接近 FP32 的水平。这一步属于进阶操作,但值得投入时间,因为部署精度才是最终交付标准。
4.3 常见转换报错与规避手段
转换过程中最常见的报错有两类。一类是"op not supported",说明某个 ONNX 算子在当前版本的 RKNN-Toolkit2 中不支持。这种通常通过升级 toolkit 版本或者改模型结构来解决。我遇到的倒霉算子基本集中在 DFL 和部分特殊的 reshape 上,用剥离检测头的方式一劳永逸。
另一类是形状不匹配或动态维度报错。这多半是导出 ONNX 时 dynamic=True 或者模型里有依赖输入尺寸的算子。解决办法是重新导出固定尺寸的模型,并且在转换前用 onnx.checker 检查模型合法性。多说一句,RKNN 转换报错信息有时候非常不直观,建议遇到问题先在官方示例模型上跑一遍全流程,确认工具链本身是健康的,再回来查自己模型的问题。这样能省下大量排查时间。
转换完成后,rknn.build 阶段如果报 Warning,我建议不要忽略,尤其和内存布局、通道顺序相关的 Warning,尽量都查一遍。
5. 板端推理与后处理代码实现
5.1 Python 快速验证
拿到 best.rknn 之后,上板之前我建议先在 PC 上用 RKNN 的 Python API 做一次对比验证,确认模型转换后的推理结果和原始 PyTorch 模型基本一致。PC 上推理的代码很简单:
from rknn.api import RKNN rknn = RKNN() rknn.load_rknn('best.rknn') rknn.init_runtime(target='rk3588') import cv2 import numpy as np img = cv2.imread('test.jpg') img = cv2.resize(img, (640, 640)) img = np.expand_dims(img, axis=0).astype(np.uint8) outputs = rknn.inference(inputs=[img])注意这里的 img 形状要严格对应导出时的输入尺寸和通道顺序。rknn.inference 返回的是检测头输出的三个特征层数组,每一层的形状是 (1, 4+nc, grid_h, grid_w)。拿到这些原始特征后,解码逻辑完全由你自己实现。
5.2 C 接口部署要点
Python API 适合验证,但实际产品里通常还是要用 C 接口来部署。RKNN 的 C API 核心就五个函数:rknn_init 负责加载模型并初始化运行环境,rknn_query 查询模型输入输出信息,rknn_inputs_set 设置输入数据,rknn_run 执行推理,rknn_outputs_get 获取原始输出。代码骨架大致如下:
#include "rknn_api.h" rknn_context ctx; rknn_init(&ctx, "best.rknn", 0, 0, NULL); rknn_input inputs[1]; inputs[0].index = 0; inputs[0].type = RKNN_TENSOR_UINT8; inputs[0].size = 640 * 640 * 3; inputs[0].buf = img_data; inputs[0].pass_through = 0; rknn_inputs_set(ctx, 1, inputs); rknn_run(ctx, NULL); rknn_output outputs[3]; outputs[0].want_float = 1; outputs[1].want_float = 1; outputs[2].want_float = 1; rknn_outputs_get(ctx, 3, outputs, NULL); float* output0 = (float*)outputs[0].buf;几个细节值得注意。第一,want_float 设为 1 可以让驱动帮你做反量化,直接拿到 FP32 的浮点输出,开发阶段方便,但如果你想省掉这层转换开销,也可以直接获取 INT8 原始输出,在后处理里自己反量化,能省几个毫秒。第二,输入数据布局要和训练一致,通常 RKNN 默认是 NHWC,也就是内存里按 H 方向连续排列 RGB 三个通道,如果你手头的数据是 CHW,需要先用代码转换。第三,rknn_outputs_get 返回的 buffer 生命周期只在下一次 rknn_run 之前有效,需要长生命周期保存或想异步处理的话,要及时拷贝走。
5.3 后处理实现:DFL 解码和 NMS
既然模型导出时剥离了检测头,板端后处理就要自己实现 DFL 解码。以输入 640x640、输出三路特征层为例,stride 分别是 8、16、32。每一层的输出维度是 4 * reg_max + nc,其中 reg_max 通常为 16。对某个格子,先把 box 分支的 64 个值分成四组,每组 16 个值做 softmax,然后和预设的分布中心点相乘并求和,得到相对格子中心的偏移距离,再乘上 stride 并配合格子中心坐标换算成 x1、y1、x2、y2。类别分支做 sigmoid 得到各类别概率。整个流程用伪代码描述就是:
for each output layer: for each grid point: box_prob = softmax(box[0:16]), softmax(box[16:32]), ... dist = sum(box_prob * [0, 1, ..., 15]) * stride x = grid_x + dist_left y = grid_y + dist_top w = dist_left + dist_right h = dist_top + dist_bottom cls_scores = sigmoid(cls[0:nc]) if max(cls_scores) >= threshold: save box, score, class_id做完这一步会得到大量候选框,再用 NMS 按类别分别抑制重叠框。NMS 在 CPU 上跑,如果候选框特别多,会成为性能瓶颈。我的经验是置信度阈值别设太低,比如 0.25 以下会导致候选框数量爆炸,NMS 耗时直接翻倍。合理阈值通常在 0.3 到 0.5 之间,具体看你的召回需求。
后处理代码我建议用纯 C 实现,避免引入额外的运行时依赖,也不要为了省事把浮点运算全换成定点,精度损失在边界框上体现得比较明显。如果必须优化性能,优先做循环展开和局部缓存复用,而不是牺牲数值精度。
6. 实测效果与后续优化空间
6.1 精度和帧率实测
整条链路跑通后,我在后端板子上做了完整测试。实验分三组:FP32 模拟推理、INT8 量化推理、INT8 加裸 DFL 解码。对比下来,INT8 量化后的 mAP 相比 FP32 掉了大概 1.5 个点,主要体现在小物体和遮挡严重的场景,这也是 INT8 量化的普遍现象。由于这是我的目标检测模型,原始需求允许这个程度的精度损失,因此没有进一步做混合量化。推理耗时方面,单帧 640 分辨率输入,NPU 推理时间大约 40 到 50 毫秒,CPU 后处理包括 DFL 解码和 NMS 合计 5 毫秒左右,整体帧率能稳定在 20 帧上下。这个成绩对大多数边缘检测应用已经够用。
6.2 还能继续榨的性能空间
如果对帧率还不满意,有几个方向可以继续试。第一个是降分辨率,从 640 降到 512 或者 416,推理时间几乎等比下降,代价是小目标召回率降低。第二个是换更小的模型变体,YOLO26n 已经是最轻量的了,再往下就只能减少类别数或者裁剪通道。第三个是流水线并行,利用双缓冲机制让 NPU 推理和 CPU 后处理以及图像采集同时进行,而不是严格串行,这个优化通常能让整体帧率再提升 30% 左右。第四个是多线程 NMS,把不同类别的 NMS 拆到多个线程并行,候选框多的时候效果很明显。
量化这块也有优化空间。如果对精度不够满意,可以尝试用 QAT(量化感知训练)在训练阶段把量化误差纳入优化目标,这样量化后的模型能保持更好的精度。RKNN-Toolkit2 对此有配套支持,但 QAT 需要重新训练模型,成本较高,我一般只在 PTQ 效果实在达不到要求时才用。
最后分享一个我自己用的土办法:把 PC 端 RKNN 的推理输出和板端 RKNN Runtime 的推理输出做逐层对比,确认两边中间结果完全一致后再去调后处理。这样可以把问题边界划清楚,不会出现板端跑出来的结果不对,却搞不清是模型转换问题、预处理问题还是后处理问题。这条链路踩坑不少,但只要每一步都做验证,整体并不复杂。希望这篇能帮你少走几个弯路。