ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RK3588视频分析性能优化:用RGA硬件加速替代OpenCV的NV12转BGR

RK3588视频分析性能优化:用RGA硬件加速替代OpenCV的NV12转BGR 上个月调一块RK3588开发板遇到一个很典型的视频分析性能问题GStreamer 从摄像头拉流 1080p 完全流畅一接上 OpenCV 做帧处理帧率直接从 30fps 掉到 10fps 出头。起初我以为是解码器没用对或者是 appsink 的线程模型有问题查了大半天最后发现幕后元凶是色彩空间转换——GStreamer 送进来的 NV12 帧被 OpenCV 用 CPU 硬生生转成了 BGR。这个“转格式”的代价远比很多人以为的更高尤其是在 RK 这类低功耗 ARM 平台上。这个问题几乎踩中了所有做多媒体应用的人GStreamer 和 OpenCV 各说各话一个要给 NV12一个只认 BGR/RGB中间那道格式转换如果走 CPU高分辨率高帧率下很容易把整个管道拖垮。最近几周我把 CPU 转换和 RGA 硬件加速这条路完整走了一遍下面把现象、定位方法、数据计算和优化方案都整理出来希望能帮同样在 RK3588/RK3568 上做视频项目的朋友少走弯路。1. 一个非常典型的“跑不动”案例GStreamerOpenCV在RK芯片上的帧率惨案1.1 场景还原摄像头拉流到深度学习推理先说现场。板子是 RK3588系统是正常的 Linux 发行版加 Rockchip BSP摄像头走 USB UVC视频参数固定为 1920x1080 NV12 30fps。GStreamer 拉流这层完全正常我用命令行验证过gst-launch-1.0 v4l2src device/dev/video0 ! \ video/x-raw,formatNV12,width1920,height1080,framerate30/1 ! \ fpsdisplaysink video-sinkfakesink画面稳定在 30fpsCPU 占用也不高。问题出在应用层我用 appsink 拿到 NV12 数据后需要通过cv::cvtColor转成 BGR再送给后端的检测模型。流程非常常规大概是这样while (true) { GstSample* sample gst_app_sink_pull_sample(GST_APP_SINK(sink)); GstBuffer* buffer gst_sample_get_buffer(sample); // 从 GstBuffer 构造 cv::MatNV12 // cv::Mat yuv_mat ...; cv::Mat bgr_frame; cv::cvtColor(yuv_mat, bgr_frame, cv::COLOR_YUV2BGR_NV12); // 送入推理 }就这么一个cvtColor整个处理帧率从 30fps 崩到了 11fps 左右画面肉眼可见地卡顿CPU 占用却只升不降。一开始我怀疑是应用层拉流方式的问题但做了一系列对照之后发现真正的元凶几乎完全集中在色彩空间转换这一步。1.2 第一反应排查别急着骂解码器碰到帧率掉一半以上第一反应往往是怀疑 GStreamer 的线程同步、buffer pool 配置、或者 VPU 解码能力。我把这些全部排除了一遍fpsdisplaysink单独测拉流30fps 稳定。去掉 OpenCV 转换只从 appsink 拿 buffer 然后立刻丢弃帧率恢复到 28-30fps。保留cv::cvtColor但把输出做得很小比如缩放成 320x240帧率就回到 25fps 以上。保留cvtColor但输出 1920x1080帧率立刻掉到 12fps。结果非常明显解码器没事线程模型没事问题就在色彩空间转换本身。实际上这个结论也符合常理——摄像头 UVC 输出的就是 NV12VPU/摄像头驱动天生喜欢 YUV 这类格式而 OpenCV 传统上以 BGR 为主流两者之间一旦在 CPU 上转换数据搬运和像素计算都是开销。1.3 先算一笔账NV12转BGR到底有多贵很多人对“转格式”觉得没什么毕竟平时开发里经常转来转去。但到了视频场景数据量是“每秒 30 帧”这种量级我们不妨把数字算出来。NV12 在内存里分两个区域Y 平面宽乘高每个像素 1 字节。以 1920x1080 为例就是 2,073,600 字节。UV 平面宽乘高的一半UV 交错存储于其中合起来 1,036,800 字节。所以一帧 NV12 总计约 3.1MB。而 BGR 是连续三通道排列每像素 3 字节一帧 1920x1080 的 BGR 大小是 6.22MB。每秒 30 帧意味着每秒要额外产生约 186MB 的 BGR 输出这还只是写入量。转换过程中还要读取 3.1MB 的 NV12 数据加上 cache 回写、内存分配和释放实际内存带宽消耗要翻好几倍。CPU 上的cvtColor不是简单 memcpy它要做 YUV 到 RGB 的矩阵运算每个像素还要做饱和和溢出处理。我实测过 RK3588 的 Arm Cortex-A76 单核1920x1080 NV12 转 BGR 大约需要 2.5ms 到 4ms如果换到 4K 分辨率直接奔 10ms 以上走。一帧 33ms 的预算里光一次转换就吃掉 3ms 以上看似还能接受。但一旦多个核同时处理多路流或者后端推理模型也需要 CPU这几个毫秒就成了压垮帧率的最后一根稻草。2. 为什么OpenCV的cvtColor在ARM上成了“电老虎”2.1 NV12的亲妈是视频编解码BGR的亲妈是显示器要理解为什么转换这么贵得先明白这两种格式为什么会长成这个样子。NV12 属于 YUV 420 采样格式它的核心思路是把亮度信息和色度信息分开。人眼对亮度敏感、对色彩不敏感所以视频编码时色度只需要存四分之一的信息量压缩率就能高不少。GStreamer 从摄像头拿到 NV12本质上是因为 UVC 摄像头和视频编码器直接支持这种格式可以省掉一次转换。BGR/RGB 则完全是显示器和图像处理生态里的格式。每个像素的三个颜色分量连续排列读取时是一个像素一个像素连续访问方便图像算法逐像素处理。问题在于 NV12 的 UV 平面是隔行交错存放的一个 Cb 分量会对应 2x2 的 Y 块。做色彩空间转换时每处理 4 个 Y 像素才用到一组 CbCr 数据。这个“2x2 共享一份色度”的行为会带来比较尴尬的访存模式CPU 在处理一行像素时UV 平面的读取位置每隔两个像素才会前进一次并且行与行之间需要重复取相同的 UV 值。这种偏非线性的读取模式对 cache 不友好效率比连续平面差很多。2.2 Neon优化过的cvtColor依然不省心肯定有人会问OpenCV 不是做了 ARM Neon 优化吗确实现代 OpenCV 的cvtColor在 ARMv8 平台上有完整的 SIMD 实现汇编代码也写得很快。但“快”是有上限的而且瓶颈往往不是算术指令而是内存带宽和访存效率。我做过一个粗略估算。NV12 转 BGR 需要对每个像素做类似这样的运算B 1.164 * (Y - 16) 2.018 * (Cb - 128) G 1.164 * (Y - 16) - 0.391 * (Cb - 128) - 0.813 * (Cr - 128) R 1.164 * (Y - 16) 1.596 * (Cr - 128)即便 OpenCV 会预先查表把系数合并成整数乘法每个像素仍然有十余次融合乘加和饱和操作。在 1080p 下是 200 多万个像素每像素十几个操作就是千万级指令。对于一个主频 2.4GHz 的 A76 核心算下来确实需要几个毫秒。再加上读取和写入的内存带宽想要在 30fps 下同时完成转换和推理CPU 自然会成为瓶颈。这也是为什么在 x86 平台上很少感知到cvtColor太贵x86 的 IPC 更高内存控制器带宽更大而且通常 CPU 频率高、缓存大。但到了 ARM 嵌入式平台每毫秒都很值钱必须把这类固定操作从 CPU 上卸掉。2.3 除了cvtColor还有隐藏的内存拷贝在真正定位问题时我还发现很多人忽略了一个事实GStreamer 的 buffer 不一定能直接用cv::Mat去包装。GStreamer 为了对齐buffer 里一行的实际 stride 可能大于图像宽度例如宽度 1920 的 NV12一行实际存储可能是 1920 padding。OpenCV 的Mat默认按连续 stride 处理这就导致你不能只传给cv::Mat一个裸指针就完事必须要么拷贝成连续布局要么手动把 stride 设置给Mat。一旦用到memcpy或cv::Mat::clone做一次数据整理一帧数据又多了一次完整的内存拷贝。NV12 3.1MB BGR 6.2MB一进一出接近 10MB 的拷贝在内存控制器上可不是免费午餐。结合cvtColor的计算量掉帧是必然的。当时我把 GStreamer 输出的 buffer 直接用cv::Mat包装后frame.data 和实际行宽对不上画面还是斜的。这也是下面排查过程中需要注意的一个细节。3. 把“真凶”锁死一步步定位帧率瓶颈的实测过程3.1 先用fpsdisplaysink量化每一环的损失排查这种性能问题我的习惯是从 GStreamer 层面开始做“减法”把每一段管道的帧率量化出来。第一步单独拉流gst-launch-1.0 v4l2src device/dev/video0 ! \ video/x-raw,formatNV12,width1920,height1080,framerate30/1 ! \ fpsdisplaysink video-sinkfakesink text-overlayfalse帧率稳定在 30fps。第二步加一个appsink但在应用里只取 sample、不做任何处理立刻丢弃gpointer data nullptr; g_signal_emit_by_name(sink, pull-sample, data);测试下来帧率也在 28fps 以上。第三步加入cv::cvtColor帧率直接掉到 11fps。这个对照已经足够说明问题了但我还是做了第四步用perf进一步确认 CPU 热点防止有别的因素干扰。3.2 用perf把CPU热点拍到桌面上在 RK3588 上perf工具可以直接使用。我先在应用里打印主线程 PID然后跑perf top -p pid --sort comm,dso可以看到热点几乎都集中在 libopencv_imgproc 相关的库函数上展开调用栈能看到大量cvtColor内部的 YUV 转换符号。进一步用perf record采一段时间perf record -p pid -g -- sleep 10 perf report -g graph --no-children结果很直观cvtColor相关调用占总 CPU 的 40% 以上另外还有一部分是memcpy。这基本就把幕后元凶钉死了。我当时还试过用top -H看线程占用发现主线程单个核心始终处于 100%其他核心表现相对正常说明单线程色度转换仍然是整个管道的最大瓶颈。3.3 对照实验把cvtColor删掉帧率立刻反弹为了做最终确认我还做了一个“假转换”实验不做cvtColor但用memcpy把 NV12 数据完整复制一份然后直接丢弃观察帧率变化。结果很有意思单纯的memcpy复制 3.1MB 数据帧率只下降到 24fps 左右和cvtColor的 11fps 差别巨大。这说明占用最高的不是数据搬运而是色彩空间转换本身的像素级计算。到这一步我已经不需要再犹豫——新的优化方向非常明确要么降低转换频率要么把转换搬到专用硬件单元上。考虑到后续还要做模型推理直接在应用层优化cvtColor的意义不大最合理的选择是用 RK 芯片自带的 RGA 硬件来做格式转换。实验链路帧率表现结论v4l2src fpsdisplaysink30fps拉流正常appsink 取帧后直接丢弃28fps管道本身问题不大appsink OpenCV cvtColor 转 BGR约 11fps色彩转换是最大瓶颈appsink 纯 memcpy 数据拷贝约 24fps拷贝不是主因计算才是4. RGA硬件加速的救赎RK芯片自带的2D硬件加速器4.1 RGA是什么能干什么RGA 的全称是 Raster Graphic Acceleration是 Rockchip 平台内置的 2D 图形加速硬件单元常见于 RK3588、RK3568、RK3399 等芯片。RGA 的设计目标就是做大量固定的像素级操作比如图像格式转换NV12、YUV、RGB、RGBA 等各种格式互转图像缩放图像旋转、镜像图像叠加混合blend纯色填充、颜色填充这些操作如果交给 CPU每一类都是几百个周期起步的像素级计算交给 RGA 后CPU 只需要下发一个请求配置硬件单元会在几十毫秒内完成整帧处理而且几乎不占用 CPU 的核心。在 RK3588 上RGA 的规格足以支撑 4K60fps 的格式转换和缩放做 1080p NV12 转 BGR 属于非常轻松的负载。和mpp视频编解码专用硬件不一样RGA 专注的是 2D 图像处理。简单理解MPP 负责把 H.264/H.265 解码成 NV12RGA 负责把 NV12 变成 OpenCV 想要的 BGR一个管编解码一个管像素形态各司其职。4.2 用librga把NV12变成BGR的完整操作要在应用层调用 RGA最常用的库是 Rockchip 发布的librga头文件是im2d.h核心 API 围绕rga_buffer_t展开。大致流程如下创建源 buffer描述输入图像宽、高、stride、像素格式。创建目标 buffer描述输出图像宽、高、stride、目标像素格式。调用imcvtcolor或更通用的improcess完成格式转换。代码看起来是这样基于 librga 2.x 常见的 im2d 接口#include im2d.h #include rga.h int rgaconvert_nv12_to_bgr(int src_fd, int dst_fd, int width, int height) { // 源图NV12stride 为 width如果不是连续内存需要传入真实 stride rga_buffer_t src wrapbuffer_fd(src_fd, width, height, width, RK_FORMAT_YCbCr_420_SP); // 目标BGR888每像素 3 字节stride字节为 width * 3 rga_buffer_t dst wrapbuffer_fd(dst_fd, width, height, width * 3, RK_FORMAT_BGR_888); IM_STATUS ret imcvtcolor(src, dst, RK_FORMAT_YCbCr_420_SP, RK_FORMAT_BGR_888); if (ret ! IM_STATUS_SUCCESS) { // 错误处理可查 ret 的具体错误码 return -1; } return 0; }实际项目中src_fd 和 dst_fd 通常来自 GStreamer 的 dmabuf memory 或dma_buf文件描述符。如果你手头不是 fd也可以在主存地址上用wrapbuffer_virtualaddr包装。但是为了发挥 RGA 的效率我建议优先使用 dma-buf 来避免多余的 mmap/copy。老版本 librga 还提供RgaBlit这类接口签名略有不同但核心思路完全相同建议以当前 SDK 中的头文件为准。4.3 把RGA装进GStreamer管道的两条路线RGA 可以挂在 GStreamer 管道里也可以放在应用层手动调用。两条路我都试过各有适用场景。路线A用 Rockchip 的 gst-rga 插件。在部分 Rockchip SDK 中GStreamer 插件库里会带一个rgaconvert元素。命令行可以直接做这种转换gst-launch-1.0 v4l2src device/dev/video0 ! \ video/x-raw,formatNV12,width1920,height1080 ! \ rgaconvert ! \ video/x-raw,formatBGR,width1920,height1080 ! \ appsink这种方式的好处是几乎不需要写 C 代码GStreamer 内部会自动协商 caps 并用 RGA 完成转换。缺点也很明显插件版本和 caps 支持范围完全取决于 SDK很多时候 BGR 这类格式不在默认 caps 列表里还需要自己调接而且如果后续要接 OpenCV 的cv::Mat仍然要面对 buffer 生命周期和拷贝问题省下的延迟可能在 Mat 包装时又花掉一部分。路线Bappsink拿到NV12后手动调用librga。这是我现在更推荐的做法。GStreamer 负责拉流OpenCV 负责算法中间的格式转换由 RGA 在应用层显式完成。控制的粒度最细也最容易排查问题。流程是从 appsink 拉到的 GstBuffer 中提取 dmabuf fd。用wrapbuffer_fd构造 NV12 的rga_buffer_t。调用imcvtcolor转成 BGR。把输出 fd 对应的内存映射到用户空间包装成cv::Mat。这样整条链路里CPU 只负责memcpy如果输出是普通内存或者干脆零拷贝如果输出也是 dmabuf像素计算全部交给 RGA。我实际接进项目后帧率和 CPU 占用都有了质的改善。5. 实测效果与其他容易被忽略的坑5.1 接入RGA后的帧率对比和CPU占用在 RK3588 上把原来的cv::cvtColor换成 RGA 转换后我做了几组对比测试数据比较直观处理方式分辨率/帧率CPU占用实测帧率OpenCV cvtColor1080p30单核满载约 11fpsRGA imcvtcolor1080p30约 5-8%29fpsOpenCV cvtColor4K30单核满载多核参与约 5fpsRGA imcvtcolor4K30约 10%30fps严格来说RGA 驱动的调用本身也会消耗一些 CPU用于提交队列和等待完成但比起纯像素计算来说小得多。最关键的是CPU 被释放出来后后端推理或其他图像分析功能可以正常运行整体帧率不再被某一环拖垮。如果你的 pipeline 里还要做缩放RGA 也能直接合并操作转换格式和缩放可以在同一个improcess调用里完成不需要像 OpenCV 那样先转格式再 resize。这一步也能省下不少时间。5.2 stride对齐问题不处理你拿到的是歪斜画面用 RGA 最容易踩的坑就是 stride 对齐。GStreamer 的 buffer 有可能在每行尾部填充额外的 padding 字节比如宽度 1920 的 NV12实际一行可能是 1920 或者 1936 字节取决于驱动和 pool 对齐策略。如果你直接传width给 RGA而实际 stride 比 width 大转换出来的画面会整体偏移出现斜纹或错位。正确的做法是使用wrapbuffer_fd时显式把行的“真实字节跨度”告诉 RGA。对 NV12 来说wstride的单位是像素但通常一次对齐是一个像素大小可以这样处理int stride_pixels width; // 实际可能需要对齐到 16/32 的倍数 if (stride_pixels % 16 ! 0) { stride_pixels (stride_pixels 15) ~15; } rga_buffer_t src wrapbuffer_fd(src_fd, width, height, stride_pixels, RK_FORMAT_YCbCr_420_SP);如果你的输入 buffer 本身 stride 已经固定比如 GStreamer 返回stride 1920那就直接把这个值传给 RGA如果不知道就先查询GstVideoFrame的info.stride[]。这一步没做对转化结果大概率是花屏或斜纹。5.3 零拷贝和fd复用让硬件加速的收益最大化RGA 本身是硬加速但如果我们每次转换前都申请一块新 dmabuf、转换后立刻释放性能仍然会打折扣。内存分配和回收在嵌入式平台上的开销同样不可忽视特别是高帧率场景下连续 30 次/秒的分配会让页分配器和驱动负担加重。我在项目里做的一个简单优化是用固定大小的dmabuf pool管理输出 buffer。线程启动时提前申请 2-4 块 BGR 输出 dmabuf每次 RGA 转换时轮流取用转换后只更新 buffer 的关联信息不再反复malloc/free。这样不仅减少了系统调用也方便后续直接把输出 dma-buf 给其他硬件单元使用尽量做到端到端零拷贝。还有一个细节是 appsink 的属性。拉流端最好设置g_object_set(sink, sync, FALSE, drop, TRUE, NULL);sync设为FALSE是为了让 appsink 不等待 pipeline 时钟drop设为TRUE是让 GStreamer 在应用处理不过来时主动丢帧。对实时视频分析来说少处理几帧是可以接受的但要保证处理的那一帧是最新的这比守着 30fps 一路卡到底体验好得多。6. 写在最后的经验这次排障最大的收获不是学会了调用 RGA而是养成了“先量化、再优化”的习惯。帧率下降这类问题表面原因可能有很多解码、线程同步、内存带宽、算法本身都可能是瓶颈。如果一开始就拿perf和对照 pipeline 做足量化我其实能省下大半天时间。只要在 GStreamer 管道的每一环都看到明确帧率你就能掌握优化的方向而不是盲猜。另外色彩空间转换在嵌入式平台上确实不应该用 CPU 硬扛。NV12 是视频链路的天然语言BGR 是 OpenCV 的舒适区中间的翻译工作交给 RGA 这种专用硬件是最省电、最高效的方案。如果你用的是 RK3588、RK3568 甚至更早的 RK3399SDK 里基本都已经带了 librga 和相关的 gstreamer 插件只需要花一个下午把接口调通后面的帧率收益是跨越式的。最后再分享一个大多数人容易忽略的小技巧如果你的后端推理框架直接支持 NV12 输入比如 RKNN 的mpp或rga输入通道那么你连 BGR 都不需要转直接把 NV12 送进模型就可以。把“格式转换”这件事从你的 pipeline 中彻底删掉才是真正的救赎。
返回列表