Article

YOLO12 小球检测实践:从训练到 RK3566 混合量化

前言

7 月底做视觉检测实验时,我需要把一个单类别(ball)的 YOLO12n 小球检测模型部署到香橙派(RK3566)上,由摄像头连续采集画面并实时框出小球位置。

在电脑上跑完训练看指标时,我以为上板只是顺手转个格式的事;结果真正开始往 RK3566 迁移后,前前后后把能踩的坑全踩了一遍:PC 转换环境的 NumPy 与 OpenCV 版本打架、后处理套错解码模板导致检测框从 0 个暴增到两千多个、RKNN 模拟器报错卡死、板端 librknnrt 运行库版本过旧加载失败,以及 INT8 量化后的精度折损排查。

这篇笔记把那两天从模型导出、混合量化到板端跑通的完整链路梳理一遍:

text
单类别数据集 → YOLO12n 训练权重 best.pt
                         ↓ 静态导出
                      best.onnx
                         ↓ RKNN-Toolkit2
              全量 INT8 基线 / INT8+FP16 混合量化
                         ↓
               RK3566 板端 RKNNLite 推理
                         ↓
               置信度过滤、NMS 与原图坐标还原

模型训练指标与数据格式

训练结束后,我先对着生成的训练曲线看了一轮收敛情况。损失函数下降平稳,常规检出指标已经比较理想(以下为当时曲线图的近似读数):

指标训练曲线读数
Precision0.98~1.00
Recall0.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 结构如下:

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 导出文档):

python
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 requiresonnxruntime 动态库加载受系统可执行栈策略限制
_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 版本。

配这类工具链时,养成一个先自检再跑脚本的习惯能省不少时间:

bash
python -m pip check
python -c "import numpy, cv2, onnxruntime; print(numpy.__version__, cv2.__version__, onnxruntime.__version__)"

构建全量 INT8 量化基线

环境跑通后,先构建一版标准的 INT8 量化模型作为基线。量化校准集通过 dataset.txt 传入(每行一张校准图片路径),图片尽量挑真实摄像头拍出来的画面,涵盖不同光照、远近大小的小球以及纯背景图,用来帮编译器统计激活值分布范围:

python
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 个检测框:

text
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) 后再拆分坐标与分数:

python
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 后,需要先把补边减掉再除以缩放比:
text
x_original = (x_model - left) / r
y_original = (y_model - top) / r

最后再把还原后的坐标裁剪到原图宽高范围内,就能避免框画歪或越界。

逐层误差分析与 INT8/FP16 混合量化

当全量 INT8 量化带来可见的精度下降时,没有必要把整个模型退回慢速的纯浮点,更实用的办法是做 INT8 + FP16 混合量化:让绝大部分主干和颈部网络继续跑高吞吐的 INT8,只把少数对量化截断敏感的层(比如检测头里的部分卷积或激活)保留为 float16。

在调用完 load_onnx() 和 build() 的同一个 RKNN 实例上,可以先用少量代表性图片跑一次逐层误差分析,挑出余弦相似度明显掉点的候选层:

python
# 在 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 示例):

python
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):

yaml
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 示例):

python
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% 后被系统拦下:

text
Target is None, use simulator!
GraphPreparing : 100%
SessionPreparing : 86%
fork: retry: Resource temporarily unavailable

这说明当前环境下模拟器创建子进程或线程时撞上了系统资源上限。第二次为了省掉每次重新构建的时间,我想直接用 load_rknn("best_hybrid.rknn") 加载导出好的模型在 PC 上模拟运行,结果直接报错:

text
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. 板端运行库版本过旧

第一次在板子上初始化模型时直接报错退出:

text
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() 会报 Qt xcb 插件无法初始化。没有图形会话时先注释掉弹窗显示,改成把画好框的结果图落盘保存即可。
  • Python 单元素元组漏写逗号:定义单类别名称列表时如果顺手写成 CLASSES = ("ball"),Python 会把它当成普通字符串,按索引取类别名时就会拆成单个字母 'b',记得补上逗号写成 ("ball",)。

板端帧率复盘与性能优化方向

整条视频检测链路在香橙派上跑通后,即使把输入尺寸调到了 320 × 320,当时的端到端画面帧率也只有 12 FPS 左右。

因为串口或终端里看到的“总帧率”把摄像头读帧解码、CPU 颜色转换与缩放、NPU 模型推理、Python 后处理 NMS 以及画框编码全混在了一起,想进一步提速必须先把各阶段耗时拆开量清楚:

python
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 左上角在原图中的偏移量。

YOLO12 小球检测实践:从训练到 RK3566 混合量化

https://www.renpengyu.com/articles/yolo12-rk3566-hybrid-quantization/
作者
Penry
发布于
2026.07.31
许可协议
CC BY-NC-SA 4.0
站内搜索

找到你感兴趣的内容

文章分类
选择文章分类,匹配任意一个所选分类

多选时显示属于任一所选分类的文章。

文章标签
选择文章标签,多个标签需同时包含

多选时只显示同时包含所选标签的文章。

输入关键词开始搜索