前言
7 月底做视觉检测实验时,我需要把一个单类别(ball)的 YOLO12n 小球检测模型部署到香橙派(RK3566)上,由摄像头连续采集画面并实时框出小球位置。
在电脑上跑完训练看指标时,我以为上板只是顺手转个格式的事;结果真正开始往 RK3566 迁移后,前前后后把能踩的坑全踩了一遍:PC 转换环境的 NumPy 与 OpenCV 版本打架、后处理套错解码模板导致检测框从 0 个暴增到两千多个、RKNN 模拟器报错卡死、板端 librknnrt 运行库版本过旧加载失败,以及 INT8 量化后的精度折损排查。
这篇笔记把那两天从模型导出、混合量化到板端跑通的完整链路梳理一遍:
单类别数据集 → YOLO12n 训练权重 best.pt
↓ 静态导出
best.onnx
↓ RKNN-Toolkit2
全量 INT8 基线 / INT8+FP16 混合量化
↓
RK3566 板端 RKNNLite 推理
↓
置信度过滤、NMS 与原图坐标还原模型训练指标与数据格式
训练结束后,我先对着生成的训练曲线看了一轮收敛情况。损失函数下降平稳,常规检出指标已经比较理想(以下为当时曲线图的近似读数):
| 指标 | 训练曲线读数 |
|---|---|
| Precision | 0.98~1.00 |
| Recall | 0.98~1.00 |
| mAP50 | 约 0.99 |
| mAP50-95 | 约 0.55~0.57 |
这里有一个很值得留意的现象:mAP50 接近满分,说明模型基本能稳稳找到画面里的球;但要求更高框重合度的 mAP50-95 落到了 0.55~0.57 左右,意味着预测框的边缘位置和尺寸还不够紧凑。如果后续需要拿检测框的中心点去做云台或小车控制,比起盲目增加训练轮数,回头检查标注框有没有紧贴球体边缘、数据集里是否包含足够的运动模糊与局部遮挡样本更有实际价值。
单类别检测的数据集遵循 Ultralytics 标准检测格式,每行标签为归一化后的 class x_center y_center width height(ball 对应类别 0),data.yaml 结构如下:
path: /absolute/path/to/ball-dataset
train: images/train
val: images/val
test: images/test
names:
0: ball静态 ONNX 导出与输出结构
部署阶段使用的是轻量的 YOLO12n 单类别权重。最开始我按默认的 640 分辨率导出排查,后面为了提高板端帧率又换成了 320。为了让 RKNN 编译器顺利解析计算图,导出时需要固定输入尺寸、关闭动态轴且不在图内捆绑 NMS(参数支持范围见 Ultralytics ONNX 导出文档):
from ultralytics import YOLO
model = YOLO("best.pt")
model.export(
format="onnx",
imgsz=320, # 最初使用 640;修改输入分辨率后需重新转换 RKNN
opset=12,
simplify=True,
dynamic=False,
batch=1,
nms=False,
)转换时 rknn-toolkit2 1.6.0 曾打出一条推荐使用 opset 19 的提示,但实际上 opset=12 的 ONNX 图已经顺利加载完成,不必把这条提示当成报错去折腾。
这次导出的单输出检测头张量布局非常规整:
| 输入分辨率 | 原始输出形状 | 转置后候选框形状 |
|---|---|---|
| 640 × 640 | (1, 5, 8400) | (8400, 5) |
| 320 × 320 | (1, 5, 2100) | (2100, 5) |
其中 5 个通道依次对应预测框的中心与宽高(x, y, w, h)以及单类别 ball 的置信度分数。
PC 转换环境搭建与依赖排查
模型转换放在 x86 Linux PC 上完成(当时环境为 Python 3.10 与 rknn-toolkit2 1.6.0)。在真正跑通转换脚本之前,光配齐依赖就接连撞上了三个典型报错:
| 报错信息 | 原因与处理方向 |
|---|---|
No module named 'pkg_resources' | 新版 setuptools 移除了旧版 Toolkit 依赖的 pkg_resources 接口,需安装兼容版本 |
cannot enable executable stack as shared object requires | onnxruntime 动态库加载受系统可执行栈策略限制 |
_ARRAY_API not found / multiarray failed to import | 环境里装了 NumPy 2.x,与基于 NumPy 1.x 编译的二进制扩展不兼容 |
需要注意最后一个坑:当时我直接执行 pip install numpy==1.26.4 降级后,pip 立刻提示环境里的 opencv-python 5.0.0.93 依赖 NumPy >= 2。因此不能只改单个包,要把 OpenCV 也一起降到兼容 NumPy 1.26.x 的 4.x 版本。
配这类工具链时,养成一个先自检再跑脚本的习惯能省不少时间:
python -m pip check
python -c "import numpy, cv2, onnxruntime; print(numpy.__version__, cv2.__version__, onnxruntime.__version__)"构建全量 INT8 量化基线
环境跑通后,先构建一版标准的 INT8 量化模型作为基线。量化校准集通过 dataset.txt 传入(每行一张校准图片路径),图片尽量挑真实摄像头拍出来的画面,涵盖不同光照、远近大小的小球以及纯背景图,用来帮编译器统计激活值分布范围:
from rknn.api import RKNN
rknn = RKNN(verbose=True)
try:
rknn.config(
target_platform="rk3566",
mean_values=[[0, 0, 0]],
std_values=[[255, 255, 255]],
quantized_dtype="asymmetric_quantized-8",
quantized_method="channel",
)
if rknn.load_onnx(model="./best.onnx") != 0:
raise RuntimeError("加载 ONNX 失败")
if rknn.build(do_quantization=True, dataset="./dataset.txt") != 0:
raise RuntimeError("构建 INT8 模型失败")
if rknn.export_rknn("./best.rknn") != 0:
raise RuntimeError("导出 RKNN 失败")
finally:
rknn.release()重要
归一化配置与推理输入必须对应 上面的
rknn.config已经配置了mean_values=[[0, 0, 0]]和std_values=[[255, 255, 255]],意味着“除以 255 归一化”已经被编译进了 RKNN 模型内部。板端推理喂图时,只需传入通道顺序为 RGB、类型为uint8(0~255) 的图像数据,千万不要在预处理代码里再手动除以一次255。
后处理排错:输出解析与坐标还原
模型转出来了,但刚接上后处理脚本测试时,终端先是打出 0 个检测框:
input: (1, 640, 640, 3)
output num: 1
0 (1, 5, 8400) float32 -19.677868 640.9363
Processed prediction shape: (8400, 5)
detections: 0结果改了一版后处理再跑,同一组测试图上的检测框数量又突然飙到了 2334、1948、2202 个,满屏幕全是框。顺着张量数据一路查下去,主要理清了三个后处理细节。
1. 适配单输出无锚框张量布局
板端最开始借用的参考脚本是按旧版 YOLOv5 写的:默认去读 outputs[0]、outputs[1]、outputs[2] 三个特征层,再结合预设 anchors 和 masks 解码。而当前导出的 YOLO12 模型是无锚框(Anchor-free)的单输出结构,直接套用旧模板必然出错。
正确的第一步是先校验单输出形状,把 (1, 5, N) 转置为 (N, 5) 后再拆分坐标与分数:
import numpy as np
if outputs is None or len(outputs) != 1:
raise ValueError("期望单个模型输出张量")
output = np.asarray(outputs[0])
if output.ndim != 3 or output.shape[:2] != (1, 5):
raise ValueError(f"输出形状不符合预期:{output.shape}")
pred = output[0].T
boxes_xywh = pred[:, :4]
scores = pred[:, 4]
print("坐标范围:", boxes_xywh.min(), boxes_xywh.max())
print("分数范围:", scores.min(), scores.max())
print("高于 0.25 的候选框数(NMS 前):", np.count_nonzero(scores > 0.25))2. 避免对类别概率重复执行 Sigmoid
看到上面日志里整个输出张量的最小值是 -19.677868 时,我一度误以为模型输出的是未经激活的原始 logits,于是在代码里顺手给分数通道补了一次 sigmoid。
后来拆开通道并核对 Ultralytics 的 Detect 检测头源码 才反应过来:张量里的负数出现在前 4 个坐标回归通道上,而第 5 个类别分数通道在导出 ONNX 时已经做过 sigmoid 激活了。
如果把原本接近 0 的背景概率再喂进一次 sigmoid,由于 sigmoid(0) = 0.5,所有背景网格的得分都会被强行抬高到 0.5 附近;紧接着再用 0.25 的置信度阈值去筛,成千上万个背景候选框自然就全漏进来了。排查这类问题时,一定要把坐标通道和分数通道拆开单独打印最值。
3. 对齐图像缩放方式与坐标还原公式
测试脚本里先后试过直接 resize 拉伸和 letterbox 等比缩放补边,这两种预处理对应的坐标还原逻辑不能混用:
- 直接
resize:横纵坐标分别乘回原图与输入尺寸的宽高比即可; letterbox等比补边:设缩放比例为r,左侧与顶部的实际整数补边像素为left和top,先将xywh转为xyxy后,需要先把补边减掉再除以缩放比:
x_original = (x_model - left) / r
y_original = (y_model - top) / r最后再把还原后的坐标裁剪到原图宽高范围内,就能避免框画歪或越界。
逐层误差分析与 INT8/FP16 混合量化
当全量 INT8 量化带来可见的精度下降时,没有必要把整个模型退回慢速的纯浮点,更实用的办法是做 INT8 + FP16 混合量化:让绝大部分主干和颈部网络继续跑高吞吐的 INT8,只把少数对量化截断敏感的层(比如检测头里的部分卷积或激活)保留为 float16。
在调用完 load_onnx() 和 build() 的同一个 RKNN 实例上,可以先用少量代表性图片跑一次逐层误差分析,挑出余弦相似度明显掉点的候选层:
# 在 build() 之后、release() 之前调用
ret = rknn.accuracy_analysis(
inputs=["./images/96.jpg"],
output_dir="./accuracy_int8",
)
if ret != 0:
raise RuntimeError("逐层误差分析失败")拿到分析报告后,混合量化分两步独立执行。
1. 生成混合量化配置与中间文件
第一步直接从原始 best.onnx 出发生成中间模型与量化配置文件(单独运行,不需要先跑普通的 build(),参考 官方混合量化 Step 1 示例):
from rknn.api import RKNN
rknn = RKNN(verbose=True)
try:
rknn.config(
target_platform="rk3566",
mean_values=[[0, 0, 0]],
std_values=[[255, 255, 255]],
quantized_dtype="asymmetric_quantized-8",
quantized_method="channel",
)
if rknn.load_onnx(model="./best.onnx") != 0:
raise RuntimeError("加载 ONNX 失败")
if rknn.hybrid_quantization_step1(
dataset="./dataset.txt",
proposal=False,
) != 0:
raise RuntimeError("混合量化 Step 1 失败")
finally:
rknn.release()执行完成后,当前目录下会生成 best.model、best.data 和 best.quantization.cfg 三个文件。
2. 指定敏感层的浮点精度
打开 best.quantization.cfg,找到默认的 custom_quantize_layers: {},将其替换为需要保留 float16 的层名映射(注意使用标准块式 YAML 缩进,不要乱动文件下方工具自动生成的 quantize_parameters):
custom_quantize_layers:
/model.21/cv3.2/cv3.2.0/cv3.2.0.0/conv/Conv_output_0: float16
/model.21/cv3.2/cv3.2.0/cv3.2.0.0/act/Mul_output_0: float16
/model.21/cv3.2/cv3.2.1/cv3.2.1.0/conv/Conv_output_0: float16
/model.21/cv3.2/cv3.2.1/cv3.2.1.0/act/Mul_output_0: float16上面这四层是当时针对第三个检测尺度分类分支(cv3.2)挑出的第一组试验配置。不同模型或不同导出版本生成的算子名会有差异,实际填写时一定要对照自己生成的 .quantization.cfg 和误差分析表。
3. 构建混合量化模型
第二步读取修改后的配置文件生成最终的 best_hybrid.rknn(保留原来的 best.rknn 方便上板对比,参考 官方混合量化 Step 2 示例):
from pathlib import Path
from rknn.api import RKNN
for name in ("best.model", "best.data", "best.quantization.cfg"):
if not Path(name).is_file():
raise FileNotFoundError(name)
rknn = RKNN(verbose=True)
try:
ret = rknn.hybrid_quantization_step2(
model_input="./best.model",
data_input="./best.data",
model_quantization_cfg="./best.quantization.cfg",
)
if ret != 0:
raise RuntimeError("混合量化 Step 2 失败")
if rknn.export_rknn("./best_hybrid.rknn") != 0:
raise RuntimeError("导出混合量化模型失败")
finally:
rknn.release()注意在 Step 2 脚本里不需要再重复调用 config() 或 load_onnx()。
说明
关于 INT16 的尝试 当时除了 INT8 + FP16 混合量化外,我也试探过另一条新版编译器的 INT16 量化路线,但在检测头
Sub算子处遇到了数据类型与张量布局报错,因此最终还是回到混合量化方案上推进。
PC 模拟器限制与模型验证方式
在 PC 上用模拟器验证输出时,我也连着踩了两个坑。
第一次是在构建脚本里直接用模拟器跑推理,日志停在 86% 后被系统拦下:
Target is None, use simulator!
GraphPreparing : 100%
SessionPreparing : 86%
fork: retry: Resource temporarily unavailable这说明当前环境下模拟器创建子进程或线程时撞上了系统资源上限。第二次为了省掉每次重新构建的时间,我想直接用 load_rknn("best_hybrid.rknn") 加载导出好的模型在 PC 上模拟运行,结果直接报错:
RKNN model that loaded by 'load_rknn' not support inference on the simulator,
please set 'target' first!原因很明确:在 RKNN-Toolkit2 中,通过 load_rknn() 加载的已编译 .rknn 模型不支持直接跑 PC 本地模拟器。平时验证模型需要按所处阶段选对入口(详见 RKNN-Toolkit2 项目说明):
| 验证方式 | 适用场景 |
|---|---|
PC 端 load_onnx() → build() → init_runtime() | 在不连板子的情况下跑 PC 模拟器核对输出逻辑 |
PC 端 load_rknn() → init_runtime(target="rk3566") | 通过 ADB / 网络连接真机,在 PC 侧驱动板端 NPU 验证 |
板端使用 rknn-toolkit-lite2 加载 .rknn | 香橙派本地独立运行,测试真实部署效果与帧率 |
香橙派 RK3566 板端部署排错
把 .rknn 模型拷到香橙派上用 RKNNLite 运行时,主要排掉了下面几处环境与外设问题:
1. 板端运行库版本过旧
第一次在板子上初始化模型时直接报错退出:
librknnrt version: 1.4.0
RKNN Driver Information: version: 0.9.6
Invalid RKNN model version 6看到 Invalid RKNN model version 6 就不用再去怀疑模型阈值或代码逻辑了——这是板端系统里的底层运行库 librknnrt.so(1.4.0)太老,不认识新版 Toolkit2 导出的模型格式。排查时不能只看 pip list 里的 Python 包版本,还要核对系统实际加载的 librknnrt.so 动态库与内核 NPU 驱动版本是否配套。
2. 摄像头采集节点与远程无界面报错
模型顺利加载后,接摄像头和画面显示时还遇到过三个小插曲:
- 选错 V4L2 设备节点:系统
/dev/底下往往会挂出好几个video*节点(当时填/dev/video21报无法打开或ValueError: error reading),并不是每个节点都是真实的视频流采集口。上板时建议先读一张本地静态图片确认模型推理正常,再去核对真正可用的摄像头节点。 - SSH 远程无桌面导致
imshow崩溃:通过 SSH 终端直接跑脚本时,OpenCV 调用cv2.imshow()会报 Qtxcb插件无法初始化。没有图形会话时先注释掉弹窗显示,改成把画好框的结果图落盘保存即可。 - Python 单元素元组漏写逗号:定义单类别名称列表时如果顺手写成
CLASSES = ("ball"),Python 会把它当成普通字符串,按索引取类别名时就会拆成单个字母'b',记得补上逗号写成("ball",)。
板端帧率复盘与性能优化方向
整条视频检测链路在香橙派上跑通后,即使把输入尺寸调到了 320 × 320,当时的端到端画面帧率也只有 12 FPS 左右。
因为串口或终端里看到的“总帧率”把摄像头读帧解码、CPU 颜色转换与缩放、NPU 模型推理、Python 后处理 NMS 以及画框编码全混在了一起,想进一步提速必须先把各阶段耗时拆开量清楚:
from time import perf_counter
t0 = perf_counter()
outputs = rknn_lite.inference(inputs=[input_data])
infer_ms = (perf_counter() - t0) * 1000
print(f"单次 NPU 推理接口耗时:{infer_ms:.2f} ms")如果后续要系统对比“全量 INT8 模型”和“INT8+FP16 混合量化模型”到底选哪一个,建议固定同一批带标注的测试集和完全一致的后处理阈值,按下面四个维度对照验收:
| 评估维度 | 关注重点 |
|---|---|
小球 Recall 与 mAP50-95 | 混合量化有没有实质性找回漏检或改善框贴合度 |
| 纯背景与复杂干扰样本 | 分数分布变化后是否引入了额外的背景误检 |
纯 inference() 接口耗时 | 将部分检测头层回退到 float16 增加了多少毫秒延迟 |
| 整条视频流端到端 FPS | 瓶颈究竟在 NPU 推理本身,还是在前后的 CPU 图像搬运与绘制 |
另外,针对远距离小球在画面中占比太小的问题,也可以在预处理前先裁剪出感兴趣区域(ROI)再送入模型。不过要注意:只要送入模型的张量分辨率还是 320 × 320,裁剪 ROI 提升的是小球的有效像素占比(检出率),并不会减少 NPU 每次推理的计算量;后处理把框坐标还原回 ROI 尺寸后,也别忘了再加上 ROI 左上角在原图中的偏移量。