之前在RK3568板子上调一套视频分析方案,解码和模型推理都跑得很顺,但一压到720P就掉帧。排查半天,问题不在NPU,不在解码器,卡在预处理里的图像缩放上——OpenCV的resize吃掉了一个核,还把内存带宽拖得死死的。后来把缩放和格式转换全部挪到Rockchip的RGA硬件引擎上,帧率直接翻了接近一倍。从那之后,凡是在Rockchip平台上做嵌入式AI预处理,我第一件事就是看RGA能不能先把这事接走。
这篇就围绕RGA图像缩放展开,结合RKNN、MPP和实际项目里的踩坑过程,聊聊RGA到底是什么原理、怎么用好它,以及嵌入式AI预处理里那些用硬件加速才能解决的问题。适合正在RK3568/RK3588等平台做视觉应用、跑YOLO或分类网络的工程师参考。
1. 一个缩放函数也能让AI流水线“卡脖子”
1.1 多数人忽略的“预处理第一公里”
很多嵌入式AI项目里,大家最关注模型结构、量化精度、NPU算力,却往往低估了预处理这一环。实际上,一帧图像从传感器或视频流进入推理引擎之前,要经历解码、缩放、色彩空间转换(比如YUV转RGB)、归一化、数据排布调整等步骤。其中缩放和格式转换,在计算量上往往比一次小模型的推理还要夸张。
举个例子:1920x1080的NV12帧,要送进一个640x640输入的YOLO模型。如果直接用OpenCV的resize做双线性插值,在RK3568这种A55核心上,单帧耗时可能到十几毫秒。再叠加YUV到RGB的转换,以及HWC到CHW的转换,预处理链路的CPU开销很容易超过模型推理本身。此时NPU再强,整条管线也只能在低帧率运行。
1.2 CPU缩放为什么会成为瓶颈
CPU做图像缩放,本质上是一个二维插值运算。对每个目标像素,需要根据缩放比例找到源像素坐标,读取相邻像素,再按权重计算。这个过程不只是简单算几个乘法,还会带来大量随机访存。图像数据动辄几MB甚至十几MB,在嵌入式平台上,CPU的缓存和DDR带宽都有限,频繁访问大图片会让内存子系统充血,拖慢解码线程和推理线程。
而且嵌入式平台通常已经在多个核心上跑采集、编解码、推理调度、网络通信等任务,再去抢占一个核心专门做resize,确实不划算。Libyuv做了很多NEON优化,会比OpenCV快不少,但它优化的也只是CPU算力,并没有解决内存带宽占用问题。只要数据还要在CPU里过一遍,带宽成本就免不掉。
1.3 RGA在流水线里的位置
RGA(Raster Graphic Acceleration)是Rockchip芯片上的2D硬件加速引擎,专门处理图像缩放、格式转换、旋转、镜像、裁剪和色彩空间转换等操作。它不和NPU抢占算力,也不需要CPU一个像素一个像素地算。你在代码里把源图地址、目标图地址、尺寸和格式告诉它,它自己通过DMA搬运数据并完成像素运算,CPU只负责发起任务和检查状态。
在典型的多媒体AI流水线里,RGA被安排在“解码器”和“推理引擎”之间:
- MPP/RKMPP解码视频流,输出YUV帧(通常是NV12或NV21)
- RGA把YUV帧缩放到模型输入尺寸,同时按需转换成RGB888或RGBA格式
- 数据喂给RKNN推理引擎
因为RGA是独立硬件模块,它可以在解码器还在输出下一帧时并行处理当前帧,和NPU任务也能形成流水线重叠。这一层设计,就是嵌入式AI预处理性能的关键。
2. RGA缩放没有“魔法”,背后是2D引擎与像素算法
2.1 硬件到底在做什么
RGA本质上是一个2D DMA引擎加像素计算单元。它内部支持把源缓冲区的一片矩形区域,经过缩放和像素格式处理后,写入目标缓冲区的矩形区域。整个操作以行为单位处理,内部有专门的行缓冲和插值器,对不同格式的YUV、RGB数据做解码、重采样和重新打包。
从软件视角看,RGA最核心的能力是“把一块源矩形变成一块目标矩形”。所谓缩放,只是源矩形和目标矩形大小不同而已。如果再加上颜色空间转换,它就是“任意格式到任意格式”的硬件路由器。
这个过程通常在毫秒级完成。比如RK3568上,将1080P的NV12缩放成640x640的NV12,RGA实测单次操作往往在1毫秒以内,加上后续的RGB转换,也远快于CPU。RGA在硬件上做双线性插值,内部有专用插值单元,数据在DMA通道里走一趟就完成了。
2.2 缩放质量模式该怎么选
RGA支持多种缩放算法,在libRGA中对应不同的缩放模式。常见的有:
- 最近邻(Nearest):速度快,但放大后马赛克严重,适合做目标检测的简单缩放,或对质量要求不高的场合
- 双线性(Linear):默认常用的模式,图像平滑,边缘损失可控,适合大多数AI模型输入
- 双三次(Cubic):质量更高但更耗时,一般来说RGA跑底层引擎差异不大,但仍需匹配sink带宽
- 平滑模式(Smooth):主要用于缩小图片时减少噪点,适合视频监控类场景
个人建议:对于跑深度学习模型来说,双线性通常足够了。模型本身对输入图像有一定模糊容忍度,缩放引入的微小纹理损失不会造成精度明显下降。如果你做的是图像分割或超分模型,可以尝试更高的插值阶数,并通过验证集确认是否带来实质提升,再决定是否增加开销。
2.3 不同芯片上的RGA规格差异
Rockchip不同芯片集成的RGA版本和规格不一样,开发前需要先确认。
- RK3399、RK3566、RK3568等芯片集成RGA2,支持最大4096x4096左右的操作尺寸
- RK3588同时包含RGA2和RGA3,其中RGA3是更新的核心,支持更大分辨率、更多格式组合,并且可以完成一些更复杂的2D操作
- 新一代libRGA库会自动封装底层核心差异,多数场景下你不需要关心具体跑在哪个核心上
虽然库做了封装,但芯片差异仍然存在,最典型的是对齐要求。RK3568和RK3588对宽stride的对齐要求不完全一致,旧版本libRGA还存在旋转时宽高需对齐16像素的限制。所以“同一个程序在RK3588验证过的参数,拿到另一个平台就花屏”的情况很常见。跨平台前,最好都跑一遍自测图。
3. 跑通libRGA最小缩放示例所需的基础工作
3.1 获取libRGA的三种方式
libRGA是Rockchip维护的RGA用户态库,提供了C接口和C++封装。获取方式主要有:
- 在Debian/Ubuntu类系统里直接安装rockchip提供的
librga-dev包 - 从GitHub的airockchip/librga仓库克隆源码自行编译
- 使用Rockchip Linux SDK里自带的版本
从源码编译时,需要依赖cmake、gcc等工具链:
git clone https://github.com/airockchip/librga.git cd librga mkdir build && cd build cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr .. make sudo make install编译完成后,开发时主要包含im2d.h和rga.h头文件,链接时加-lrga即可。
3.2 缓冲区:别随便malloc
RGA虽然是独立硬件,但它访问的还是内存。这里有个关键区别:它需要物理地址或DMA缓冲区,而不是普通的用户态虚拟地址。在现在的Linux系统中,用户态malloc出来的内存通常是虚拟内存,物理页可能不连续,硬件DMA无法直接访问。
为了解决这个问题,有三种常见路径:
- 使用DRM/IOMMU分配出dma-buf文件描述符,传给RGA
- 使用Rockchip特定的
RkRgaGetBufferFd接口,从缓存里拿一块连续物理内存 - 使用
wrapbuffer_virtualaddr接口,让libRGA在内部完成地址映射逻辑
实际开发中推荐优先使用dma-buf或RGA专门的分配路径。虽然wrapbuffer_virtualaddr写demo很方便,但在高帧率场景下,CPU虚拟地址到物理地址的映射转换会增加额外开销,性能不如直接能用DMA隔离的fd缓冲区稳定。
3.3 最小缩放代码与关键API语义
下面是一个最基础的RGA缩放示例,把一帧1920x1080的NV12图像缩放到640x640:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include "im2d.h" #include "rga.h" int main() { int src_w = 1920, src_h = 1080; int dst_w = 640, dst_h = 640; // NV12是YUV420半平面格式,一像素占1.5字节 size_t src_size = src_w * src_h * 3 / 2; size_t dst_size = dst_w * dst_h * 3 / 2; unsigned char *src_buf = (unsigned char *)malloc(src_size); unsigned char *dst_buf = (unsigned char *)malloc(dst_size); // 把源图像数据读入src_buf,省略 // 用虚拟地址包装源和目标缓冲区 rga_buffer_t src = wrapbuffer_virtualaddr(src_buf, src_w, src_h, RK_FORMAT_YCbCr_420_SP); rga_buffer_t dst = wrapbuffer_virtualaddr(dst_buf, dst_w, dst_h, RK_FORMAT_YCbCr_420_SP); // 执行缩放 IM_STATUS ret = imresize(src, dst); if (ret != IM_STATUS_SUCCESS) { printf("imresize failed: %s\n", imStrError(ret)); return -1; } // 现在dst_buf里就是640x640的NV12数据 return 0; }这里重点说三个API语义:
wrapbuffer_virtualaddr:把用户态地址包装成rga_buffer_t结构。传入的宽高会作为默认的stride,如果实际缓冲区有对齐填充,需要用wrapbuffer_virtualaddr_ex指定wstride和hstrideimresize:封装了“设置src.rect和dst.rect,然后调用RGA Blit”的完整流程IM_STATUS_SUCCESS:所有RGA操作的返回值。检查时最好用这个枚举,不要用0/1等硬编码
如果你需要同时做缩放和格式转换,可以使用improcess函数,或者先调用imresize再调用imcvtcolor。但通常一次RGA操作可以同时完成,减少DMA搬运次数。比如下面这句,把NV12源图直接缩放并转换成RGB888:
rga_buffer_t src = wrapbuffer_fd(src_fd, 1920, 1080, RK_FORMAT_YCbCr_420_SP); rga_buffer_t dst = wrapbuffer_fd(dst_fd, 640, 640, RK_FORMAT_RGB_888); IM_STATUS ret = improcess(src, dst, {}, {}, {}, IM_SYNC);注意IM_SYNC表示同步操作,RGA执行完成后函数才返回。如果希望异步,则用IM_ASYNC,并通过imsync或查询状态来等待完成。
4. 结合MPP和RKNN的端到端预处理实例
4.1 整条数据流的“正常姿势”
实际嵌入式AI项目里,RGA很少单独使用,通常和MPP解码、RKNN推理组成一条完整流水线。以一个USB或RTSP摄像头输入、输出目标检测结果的项目为例,典型数据流是:
- MPP从视频流中解码出NV12帧,得到dma-buf fd
- RGA将NV12帧缩放为模型输入尺寸,同时转为RGB888或RGBA格式
- RKNN拿到RGB数据,执行推理
- 推理结果送往后处理线程,同时可以将原图交给RGA做画框叠加或OSD渲染
这里最大的好处是,解码、缩放、推理三个阶段可以并行。MPP解码线程输出的帧,经过RGA处理后放入缓冲队列,RKNN推理线程从队列里取数据,三者在不同硬件上同时工作。
4.2 关键代码实现
下面是一个简化的处理流程,说明RGA在码流解码和RKNN推理之间的衔接方式:
// 假设已经从MPP解码器拿到一帧NV12: // mpp_frame_fd: dma-buf fd // frame_w/frame_h: 解码帧宽高 // model_input_w/model_input_h: 模型输入宽高 rga_buffer_t src = wrapbuffer_fd(mpp_frame_fd, frame_w, frame_h, RK_FORMAT_YCbCr_420_SP); rga_buffer_t dst = wrapbuffer_fd(rknn_input_fd, model_input_w, model_input_h, RK_FORMAT_RGB_888); // 一步完成缩放+色彩空间转换 IM_STATUS status = imresize(src, dst); if (status != IM_STATUS_SUCCESS) { // 错误处理 } // 然后把rknn_input_fd对应的内存交给RKNN rknn_input input; input.index = 0; input.type = RKNN_INPUT_FORMAT_RGB_DATA; input.size = model_input_w * model_input_h * 3; input.fmt = RKNN_TENSOR_NCHW; input.buf = rknn_input_virt_addr; input.pass_through = 0; rknn_inputs_set(rknn_ctx, 1, &input);这段代码最关键的一点:wrapbuffer_fd要求传入的是dma-buf的fd,这样RGA可以直接通过DMA访问内存,省去CPU拷贝。MPP解码输出时如果配置成dma-buf导出,这个fd可以直接复用,形成零拷贝链路。
4.3 实际性能数据与瓶颈观察
我在一块Linux环境下的RK3568开发板上,对同一条预处理链路做过对比测试:
- 输入:1080P H.264解码输出的NV12帧
- 目标:640x640 RGB888
- 操作内容:缩放 + YUV转RGB
实测结果大致如下(不同板卡和库版本略有差异):
| 方案 | 单帧耗时 | 备注 |
|---|---|---|
| OpenCV resize + cvtColor | 13~18 ms | CPU占用极高,DDR带宽压力大 |
| libyuv缩放 + 转换 | 5~9 ms | NEON优化后快很多,但仍吃CPU |
| RGA(同步操作) | 1~2 ms | CPU占用极小,适合并行调度 |
把整条流水线从“CPU预处理”切换成“RGA预处理”后,720P输入下总吞吐从不到20帧提升到接近30帧以上。如果再把RGA操作和RKNN推理做成异步双buffer,帧率还能继续往上涨。
RGA本身不是唯一的优化方向。如果模型输入本身比较小,比如224x224,CPU缩放也不是不可接受。但当输入分辨率高、路数多(比如4路或8路摄像头),RGA的硬件加速优势就非常明显。
5. 排错实录:白屏、绿屏、花屏背后的共同原因
5.1 症状一:目标图像全绿或花掉
最经典的现象:RGA执行完缩放到640x640,显示出来的图像整体偏绿或像“打了马赛克”。这通常不是RGA坏了,而是格式不匹配。
NV12和NV21虽然都是YUV420半平面,但U、V的顺序刚好相反。如果你把NV12的数据用RK_FORMAT_YCrCb_420_SP(NV21)去解释,就会出现色偏和伪彩。反过来也一样。
排查方法明确:确认源图像格式是NV12还是NV21,再去对照libRGA头文件里的格式后缀。另外,YUV和RGB转换时也有full range和limited range的差异。摄像头或解码器产生的YUV范围如果不一致,画面可能看起来发灰或对比度不对。
5.2 症状二:裁剪或拼接图错位
另一个高频问题:对一个大图先做局部裁剪再缩放,结果输出图像歪斜、错位,或者右侧出现一条黑边。这多半是stride对齐问题。
RGA要求源和目标每一行的字节数对齐,不同芯片要求从16字节到64字节不等。当你手动裁剪一个矩形区域时,源图像的宽度stride往往大于实际裁切区域的宽度。如果只设置了rect的宽高,没有正确传入原始图像的wstride,RGA读取行的起始位置就错了。
正确做法是使用支持stride参数的包装函数,把整帧的stride明确告诉RGA:
rga_buffer_t src = wrapbuffer_fd(mpp_frame_fd, frame_w, frame_h, RK_FORMAT_YCbCr_420_SP); // 如果帧的stride比宽度大,用下面的方式设置实际stride src.wstride = real_stride_w; src.hstride = real_stride_h; src.rect.x = crop_x; src.rect.y = crop_y; src.rect.width = crop_w; src.rect.height = crop_h;很多从Rockchip平台入门的朋友都卡在这里。概念上记住:wstride是一行数据实际占用的像素宽度,rect.width是你这次要使用的宽度。两者不一定要相等。
5.3 症状三:旋转后图像破损
旧版本libRGA在旋转操作上有对齐限制。比如做90度旋转时,源图和目标图的宽高都要求对齐16像素(部分场景要求64像素)。如果输入是1080x1920,看起来没问题,但如果输入是650x650这种不满足对齐的尺寸,旋转输出就可能只剩下一部分有效图像,其余区域是黑色或垃圾数据。
遇到这类问题时,两个方向处理。一是手动把输入先pad到对齐尺寸再旋转,旋转完再裁剪出去;二是确认当前板卡使用的libRGA版本是否已经放宽对齐要求。新版库、新芯片平台对旋转对齐的限制已经大幅放宽,但老平台仍然存在。
5.4 排查RGA问题的基本路数
RGA问题排查起来并不难,难在环境复杂时不知道该信谁。我自己的排查顺序固定不变:
- 先确认返回值。所有
imresize、improcess、imrotate等函数都必须检查IM_STATUS,不要想当然 - 再确认格式。打印
src.format和dst.format的枚举值,排除NV12/NV21、RGB/BGR这类“看起来一样实际相反”的坑 - 然后确认尺寸和stride。特别是在做裁剪、旋转时,手动检查每条边的对齐情况
- 最后用单色填充图或渐变图测试。比如放一张纯红或纯蓝的输入图,看输出颜色是否一致,能快速定位是格式问题还是数据搬运问题
按照这个顺序,大多数花屏、错位、黑边问题都能在十分钟内定位出来。
6. 真实项目中验证过的几条取舍建议
6.1 哪些场景不必用RGA
RGA也不是万能药。如果图像尺寸很小,比如只有64x64,或者操作频率很低,比如每秒一次,引入RGA反而增加了代码复杂度和异步调度的麻烦。这种情况下直接CPU计算就够了。
还有一种情况:如果模型本身对输入有非常特殊的预处理,比如自定义的gamma变换、逐像素查表、复杂的数据增强,RGA可能没办法一条命令完成,需要CPU配合。这时可以把RGA当作“缩放+格式转换”的预处理步骤,剩下的特殊变换留在推理前做。
6.2 DMA-FD与虚拟地址,选谁
能走dma-buf就优先走dma-buf。尤其是和MPP解码联动时,解码器输出的dma-buf fd直接交给RGA,可以做到真正的零拷贝。如果自己从用户态数据构造RGA输入,虚拟地址方式虽然省事,但在高负载下性能会打折扣,而且要注意缓冲区物理连续性。RGA内部有MMU可以做非连续内存映射,但映射本身也有开销,不要滥用。
在多路视频场景下,建议预先分配好一组固定尺寸的dma-buf缓冲池,FG/BG双buffer甚至三buffer轮转,避免每帧都申请释放内存。我见过有项目在这里频繁malloc/free,导致RGA操作延迟波动,帧率时高时低。
6.3 “异步化”与多路预处理
RGA的高效用法是配合异步队列。代码里不要一帧一帧同步等待RGA完成,而是把多个RGA任务提交给不同通道,或者用线程池维护多个RGA上下文。尤其在做4路、8路视频分析时,每路都发起同步RGA操作会使任务互相等待,异步化之后整体吞吐可以接近线性扩展。
我实际用过的模式是:解码线程把帧丢进队列,RGA预处理线程组从队列取帧,各自调用improcess(异步),完成后把输出放入RKNN输入缓冲队列。这样RGA操作和NPU推理天然重叠,CPU只做轻量调度。
6.4 关于RGA2/RGA3的最后一句话
RK3588上的RGA3更新,支持的能力更强,但芯片自带的RGA2也依然可用。libRGA会根据操作参数自动选择合适核心,但你也可以手动指定。做产品化开发时,最好不要把代码绑死到某个RGA核心的特定行为,留出feature开关,方便在不同Rockchip芯片之间迁移。
另外,板子厂商给的libRGA版本差异很大,有的内核驱动是旧版,用户态库却是新版,接口对不上就会出现莫名其妙的IM_STATUS_FAILED。真正动手之前,先确认内核里的RGA驱动版本和用户态libRGA版本是否匹配,这点比代码本身还关键。
嵌入式AI的预处理从来不是“随便缩一下”那么简单。RGA的引入让我重新理解了流水线设计:把合适的工作放到合适的硬件上,比单纯优化某个函数的算法更能带来系统级提升。调RK平台项目,RGA值得你花时间去掌握。