拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

RK3588双路视觉方案:丢旧帧背压机制实现与性能调优

RK3588双路视觉方案:丢旧帧背压机制实现与性能调优

1. 双路视觉方案的核心痛点与背压机制引入

做双路视觉方案的人,迟早会撞上一堵墙:两路摄像头同时出流,NPU那边还在吭哧吭哧跑yolov5s,CPU还要兼顾编码和业务逻辑,结果就是帧率忽高忽低,延迟越堆越大,最后画面卡成幻灯片。我在香橙派RK3588上折腾这套东西的时候,前前后后试了三种架构,最后才落到“丢旧帧背压”这个方案上。这篇文章就是把这个阶段二的实现思路、代码细节和踩过的坑,从头到尾给你捋一遍。

先说清楚这个方案要解决什么问题。双路视觉,通常是一路做检测、一路做监控,或者两路都做检测但视角不同。RK3588的NPU算力虽然不错,但yolov5s跑两路1080p输入,单核NPU大概能到15-20 FPS左右,如果模型输入是640x640的话。问题在于,摄像头出帧是固定30 FPS的,NPU消费速度跟不上,中间如果没有合理的缓冲策略,要么丢帧丢得莫名其妙,要么队列越积越长导致延迟爆炸。

“丢旧帧背压”的核心思想很简单:当消费者(NPU推理线程)处理不过来的时候,不要让它去追最新的帧,而是主动把队列里积压的旧帧扔掉,只保留最新的一帧。同时,通过背压信号告诉生产者(摄像头采集线程)放慢速度,避免无效采集。这个策略在实时性要求高的场景下,比“缓冲所有帧”要实用得多,因为视觉任务通常只关心“当前发生了什么”,而不是“过去每一帧发生了什么”。

我选择这个方案还有一个原因:RK3588的MPP(媒体处理平台)和RGA(2D图形加速器)在帧队列管理上有一些硬件特性可以利用。比如RGA可以直接做格式转换和缩放,不需要CPU介入,这样在丢帧的时候,我们可以只丢原始帧,保留已经处理过的帧,减少重复计算。这个细节后面会展开讲。

适合谁来参考这篇文章?如果你已经在香橙派RK3588上跑通了单路yolov5s,现在想扩展到双路,或者你正在设计一个多路视觉系统,对延迟和帧率稳定性有要求,那这篇内容应该能帮你省下不少试错时间。如果你还没接触过RK3588的NPU开发,建议先去看我前面写的环境搭建和模型部署那几篇,不然直接看这个会有点跳。

2. 方案整体架构与线程模型设计

2.1 为什么不用简单的队列缓冲

最开始我用的方案很直接:两个摄像头采集线程各自往一个队列里塞帧,NPU推理线程从队列里取帧处理。队列用互斥锁保护,长度设成10。跑起来之后发现两个问题:第一,延迟越来越大,因为队列满了之后采集线程会阻塞,但阻塞期间摄像头还在出帧,驱动层的缓冲区也会积压,最后端到端延迟能到2秒以上;第二,两路之间的帧不同步,做双目或者多视角融合的时候根本对不齐。

后来改成队列满了就丢新帧,延迟是降下来了,但丢帧率太高,而且丢的都是最新的帧,导致推理线程拿到的永远是旧画面。这个逻辑反了——实时系统应该保证处理的是最新数据,旧数据该丢就丢。

2.2 丢旧帧背压的核心机制

最终方案的结构是这样的:每个摄像头对应一个采集线程,采集线程不直接往大队列里塞帧,而是先写入一个“单帧槽位”。这个槽位只保留最新的一帧,新帧来了就把旧帧覆盖掉。NPU推理线程从槽位里取帧,取走之后槽位变空,采集线程可以继续写。如果槽位里已经有帧没被取走,采集线程就等待一个条件变量,这就是背压信号。

这个设计的关键在于:槽位深度为1,天然实现了“丢旧帧”。采集线程在写槽位之前会检查槽位状态,如果非空,说明消费者还没处理完上一帧,这时候采集线程可以选择等待或者直接覆盖。我选择的是等待加超时,超时时间设成帧间隔的一半,这样既不会完全阻塞采集,又能给消费者施加背压。

两路之间用一个同步屏障来对齐时间戳。具体做法是:两路采集线程在写入槽位之前,先检查对方的时间戳,如果差距超过阈值(比如10ms),就等待对方追上。这个同步不是强制的,可以根据业务需求开关。

2.3 线程优先级与CPU亲和性设置

RK3588是8核CPU,4个A76大核加4个A55小核。我把采集线程绑到A55小核上,NPU推理线程绑到A76大核上,这样避免采集线程和推理线程抢CPU。具体用pthread_setaffinity_np来设置,采集线程绑到CPU 4-7,推理线程绑到CPU 0-3。实测下来,这样绑核之后,推理线程的抖动明显减小,帧率更稳定。

线程优先级方面,推理线程设成SCHED_FIFO优先级80,采集线程设成SCHED_FIFO优先级60。注意,设置实时优先级需要root权限,或者用cap_sys_nice能力。如果你不想用实时调度,至少把推理线程的nice值设成-10,采集线程设成0。

// 绑核示例 cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(4, &cpuset); CPU_SET(5, &cpuset); CPU_SET(6, &cpuset); CPU_SET(7, &cpuset); pthread_setaffinity_np(thread, sizeof(cpu_set_t), &cpuset);

注意:绑核不是万能的,如果你的NPU驱动本身有线程调度,绑核可能会导致驱动内部线程和你的推理线程抢同一个核。建议先用htop观察一下NPU驱动线程的CPU占用分布,再决定绑哪个核。

3. 核心数据结构与背压信号实现

3.1 单帧槽位的设计细节

单帧槽位的数据结构不复杂,但有几个细节容易踩坑。我用的是pthread_mutex加pthread_cond的组合,槽位里存一个帧描述符,包含虚拟地址、物理地址、时间戳和序列号。虚拟地址给CPU访问用,物理地址给RGA和NPU用,这样避免每次都要做地址转换。

typedef struct { void *vaddr; int dma_fd; uint64_t timestamp; uint32_t seq; bool valid; } frame_slot_t; typedef struct { frame_slot_t slot; pthread_mutex_t lock; pthread_cond_t cond; bool consumer_waiting; } frame_buffer_t;

写入槽位的逻辑:先加锁,检查slot.valid,如果为true,说明消费者还没取走,这时候有两种策略。策略A是等待cond,直到消费者取走或者超时;策略B是直接覆盖,但记录一次丢帧计数。我两种都实现了,通过配置项切换。实测在双路1080p场景下,策略A的端到端延迟比策略B低大约30%,但策略B的帧率更稳定。如果你的业务对延迟极度敏感,选策略A;如果对帧率稳定性要求高,选策略B。

3.2 背压信号的传递路径

背压信号从消费者传到生产者,路径是这样的:消费者取走帧之后,会signal条件变量,唤醒可能在等待的生产者。生产者被唤醒后,检查槽位是否为空,如果为空就写入新帧,如果不为空就继续等待或者超时返回。

这里有个细节:pthread_cond_signal和pthread_cond_broadcast的选择。因为每个槽位只有一个生产者和一个消费者,用signal就够了。但如果你把两路合并到一个条件变量上,那就得用broadcast,否则可能唤醒错误的线程。我建议每路独立的条件变量,避免这种复杂性。

超时时间的设置也有讲究。如果设得太短,生产者频繁超时,背压效果不明显;如果设得太长,生产者阻塞太久,摄像头驱动层的缓冲区会积压,反而增加延迟。我的经验值是帧间隔的0.5到0.8倍。比如30 FPS的摄像头,帧间隔33ms,超时设20ms左右比较合适。

3.3 时间戳同步与帧对齐

双路视觉做对齐的时候,时间戳的精度很关键。RK3588的VI(视频输入)模块出来的帧自带硬件时间戳,单位是微秒。但两路摄像头的时钟源可能不同步,导致时间戳有偏差。我的做法是:在采集线程里记录第一帧的时间戳作为基准,后续帧的时间戳都减去这个基准,得到相对时间。然后两路之间用一个共享的原子变量来同步基准。

对齐逻辑是这样的:每路采集线程在写入槽位之前,先读取对方的相对时间戳,如果自己的时间戳比对方大超过阈值(比如15ms),就等待对方追上。等待的实现是用一个自旋锁加usleep,不要用条件变量,因为条件变量的唤醒延迟可能比等待时间还长。

提示:时间戳同步的阈值不要设得太小,否则两路会互相等待,导致整体帧率下降。我试过5ms的阈值,结果两路都卡在20 FPS左右。后来改成15ms,帧率恢复到28 FPS,对齐误差也在可接受范围内。

4. 与NPU推理线程的对接与优化

4.1 RKNN推理的输入预处理

yolov5s在RK3588上跑,输入通常是640x640的RGB图像。摄像头出来的是1080p的NV12格式,需要做裁剪、缩放和格式转换。这一步如果用CPU做,耗时大概15-20ms,两路就是30-40ms,直接把帧率拖垮。所以必须用RGA来做。

RGA的用法是:创建一个RGA缓冲区,把摄像头的DMA fd传进去,设置源分辨率和目标分辨率,设置格式转换(NV12到RGB888),然后提交任务。RGA是异步的,提交之后可以继续做别的事情,等RGA完成中断或者轮询状态。我实测RGA做1080p到640x640的转换,耗时大概3-5ms,比CPU快4倍以上。

// RGA转换示例(伪代码) 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_rect src_rect = {0, 0, 1920, 1080}; im_rect dst_rect = {0, 0, 640, 640}; imcheck(src, dst, src_rect, dst_rect); improcess(src, dst, src_rect, dst_rect);

4.2 推理线程的取帧策略

推理线程从槽位取帧的时候,我用了非阻塞的方式:先尝试取帧,如果槽位为空,就等一个短超时(比如5ms),然后重试。这样避免推理线程长时间阻塞在条件变量上,导致NPU空闲。实测下来,推理线程的CPU占用率在30%左右,NPU利用率能到85%以上。

取到帧之后,先做RGA转换,然后调用RKNN的推理接口。RKNN的推理是同步的,调用rknn_run之后会阻塞直到推理完成。yolov5s在RK3588单核NPU上的推理时间大概是40-50ms,两路交替跑的话,单路帧率大概10-12 FPS。这个帧率对于很多场景已经够用了,如果你需要更高帧率,可以考虑用RK3588的双核NPU,或者把模型量化成int8。

4.3 后处理与结果输出

后处理包括解码yolov5s的输出、做NMS、画框。解码和NMS可以在CPU上做,但要注意优化。我用的方法是:把输出张量从NPU的内存里拷贝到CPU可访问的内存,然后用NEON指令加速解码。NMS用简单的IOU阈值过滤,阈值设0.45。画框用OpenCV的rectangle,但OpenCV的绘制比较慢,如果帧率要求高,可以用RGA直接画。

结果输出方面,我用了共享内存加信号量的方式,把检测结果传给业务线程。业务线程可以是编码推流、串口输出或者网络发送。共享内存的大小根据最大检测框数量来定,我设的是128个框,每个框16字节,总共2KB,足够用了。

注意:RKNN的输出张量格式和yolov5s的原始输出可能不一样,需要根据你用的RKNN工具链版本做调整。我用的RKNN Toolkit2 1.5.0,输出是三个张量,分别对应80x80、40x40、20x20的特征图。如果你用的是其他版本,先跑一下官方的yolov5示例,确认输出格式再改代码。

5. 实测性能与调优记录

5.1 延迟与帧率实测数据

我在香橙派5(RK3588,8GB内存)上跑了完整的双路方案,摄像头是两路1080p30的MIPI摄像头,模型是yolov5s int8量化版。实测数据如下:

指标单路双路(无背压)双路(丢旧帧背压)
端到端延迟65ms320ms85ms
单路帧率18 FPS8 FPS12 FPS
CPU占用45%78%62%
NPU占用70%95%88%
丢帧率0%15%8%

从数据可以看出,丢旧帧背压方案把端到端延迟从320ms降到了85ms,降幅超过70%。帧率虽然比单路低,但双路总共24 FPS,对于大多数实时检测场景已经够用了。CPU占用也降下来了,因为采集线程不再疯狂空转。

5.2 调优过程中的关键参数

调优过程中,有几个参数对性能影响很大。第一个是槽位等待超时时间,我试过5ms、10ms、20ms、30ms,最后发现20ms是最佳值。太小了背压效果不明显,太大了采集线程阻塞太久。第二个是RGA的任务队列深度,默认是1,我改成2之后,RGA的吞吐量提升了15%左右。第三个是NPU的核心数,RK3588有两个NPU核心,默认是单核跑,改成双核之后,推理时间从45ms降到了28ms。

# 查看NPU核心数 cat /sys/kernel/debug/rknpu/version # 设置NPU核心数(需要驱动支持) echo 2 > /sys/kernel/debug/rknpu/core_num

提示:双核NPU的功耗比单核高不少,如果你的设备是电池供电,建议还是用单核,或者根据负载动态切换。我实测双核跑的时候,整板功耗从5W涨到了7.5W。

5.3 内存与DMA缓冲区管理

双路1080p的NV12帧,每帧大小是1920x1080x1.5,约3MB。如果队列深度是10,两路就是60MB。加上RGA和NPU的缓冲区,总内存占用大概在150MB左右。香橙派5的8GB内存完全够用,但如果你用的是4GB版本,就要注意控制队列深度和缓冲区数量。

DMA缓冲区的分配我用的是RK3588的DMA heap,通过ioctl来分配和释放。注意,DMA缓冲区在进程退出的时候一定要释放,否则会泄漏。我写了一个简单的引用计数机制,每个缓冲区分配的时候计数加1,释放的时候减1,减到0才真正free。这样可以避免多线程环境下重复释放或者提前释放。

6. 常见问题与排查技巧实录

6.1 帧率不稳定,忽高忽低

这个问题我遇到过好几次,原因有好几种。最常见的是CPU频率调节器在作怪,默认是ondemand,会根据负载动态调频。跑视觉任务的时候,CPU频率频繁切换,导致帧率抖动。解决办法是把调节器设成performance,锁定最高频率。

# 查看当前调节器 cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor # 设置成performance echo performance > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor

另一个原因是NPU驱动的电源管理策略,默认会在空闲的时候降频。如果你发现NPU推理时间忽长忽短,可以试试关闭NPU的自动降频。具体方法因驱动版本而异,我用的驱动是在/sys/kernel/debug/rknpu/下面有个power_save节点,写0关闭。

6.2 两路时间戳不同步

时间戳不同步的表现是:两路检测结果在时间上对不齐,做融合的时候框位置偏差大。排查方法是:在采集线程里打印每帧的时间戳,观察两路的差值。如果差值稳定在某个值附近,说明是时钟源偏差,可以通过软件补偿。如果差值跳变,说明是采集线程调度延迟,需要调整线程优先级或者绑核。

我遇到过一次,两路时间戳差值在0到50ms之间跳变,后来发现是其中一路的采集线程被NPU驱动线程抢占了CPU。把采集线程绑到A55小核之后,差值稳定在5ms以内。

6.3 内存泄漏导致跑一段时间后崩溃

内存泄漏是双路方案最容易踩的坑。每帧都要分配DMA缓冲区,如果释放不及时,跑几个小时之后内存就满了。排查方法是:用valgrind或者AddressSanitizer跑一遍,看看有没有未释放的缓冲区。另外,RKNN的上下文也要注意释放,每次推理完之后要调用rknn_outputs_release释放输出张量。

我写了一个简单的内存监控脚本,每10秒打印一次DMA heap的使用量,超过阈值就报警。这个脚本帮我抓到了好几次泄漏,都是因为异常路径下忘记释放缓冲区。

# 查看DMA heap使用情况 cat /sys/kernel/debug/dma_buf/bufinfo | grep -c "size"

6.4 常见问题速查表

现象可能原因排查方法解决方案
帧率低于预期CPU降频查看scaling_governor设为performance
延迟越来越大队列积压打印队列长度启用丢旧帧背压
两路不同步线程调度延迟打印时间戳差值绑核+提高优先级
内存持续增长缓冲区未释放监控DMA heap检查异常路径释放
NPU利用率低推理线程阻塞查看线程状态改非阻塞取帧
RGA转换慢任务队列满查看RGA状态增加队列深度

7. 后续扩展与个人经验分享

这套方案跑通之后,我又做了几个扩展。一个是把yolov5s换成yolov8n,模型更小,推理更快,单路能到20 FPS以上。另一个是加了RTSP推流,用RK3588的硬件编码器做H.264编码,两路1080p推流CPU占用不到10%。还有一个是加了串口输出,把检测结果发给下位机做控制。

如果你也想做类似的项目,我的建议是先把单路跑稳,再上双路。单路的时候把延迟、帧率、CPU占用都调到一个满意的水平,然后复制一份改成双路。双路的核心难点不在NPU,而在帧管理和线程同步。丢旧帧背压这个方案,本质上是用一点丢帧率换延迟和稳定性,对于大多数实时视觉任务来说,这个 trade-off 是值得的。

最后分享一个小技巧:如果你发现两路帧率差异很大,比如一路15 FPS一路8 FPS,先检查两路摄像头的曝光时间是不是一样。曝光时间长的摄像头,出帧间隔会变大,导致采集线程的节奏不一致。把两路的曝光时间设成一样,帧率差异会小很多。这个坑我踩过,调了半天代码才发现是摄像头参数的问题。

返回列表