ARTICLE DETAIL

资讯详情

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

RK3588双路视觉线程池拆分与yolov5s推理实践

RK3588双路视觉线程池拆分与yolov5s推理实践 1. 为什么双路方案要拆成两个线程池而不是一个池子通吃做双路视觉系统时大多数人脑子里冒出的第一个方案都是把两路摄像头的视频帧丢进同一个线程池统一调度、统一推理。这个方案单路跑的时候是真没问题线程池里的线程被均匀地用起来检测结果也稳定。但一旦两路画面同时进来问题就开始暴露而且是那种非常隐蔽、不跑上几个小时根本察觉不到的问题。1.1 单线程池方案的三个痛点第一个痛点是任务互相抢占。两路摄像头的帧到达节奏几乎不可能完全一致尤其是混用MIPI摄像头和USB摄像头的时候帧率、分辨率、驱动延迟全都不一样。共用一个线程池的话帧率高、任务生成快的那一路会不停地把任务塞进队列另一路的任务就被压到队列后面出现“前二十秒全是A路画面B路画面隔三秒才蹦出一帧”的怪象。放在视觉检测场景里这个问题是致命的——B路如果是出入口监控画面漏一帧可能就漏掉一个目标轻则数据无效重则错过关键事件。第二个痛点是异常隔离极差。摄像头驱动偶发异常、帧采集卡住、内存申请失败、某个推理任务抛出运行时错误这些异常事件在共享线程池里不会乖乖待在一个角落它们会顺着线程池的调度机制扩散。轻则该路线程短暂卡滞重则拖垮整个进程。双路视觉本身就是长时间运行的程序最怕的就是某个定时出现的异常导致全程崩溃你还得在两路代码里反复排查。第三个痛点是调参极其别扭。两路摄像头如果分辨率不同、检测目标不同推理耗时自然也不同。放同一个线程池你没法针对每一路单独设置并发度、队列容量、超时时间和丢帧策略。想给重要的一路分更多算力池子里却只有一个统一参数改A影响B改B影响A怎么调都不自在。1.2 一路一个线程池的边界划分实际跑过一轮之后我彻底转向了“两路各一个线程池”的方案。每个线程池独立负责自己那一路的帧采集、预处理、推理调度和结果回调池与池之间不共享任何可变状态。好处是立竿见影的一是天然规避了任务抢占。A路的帧永远进A路自己的队列B路的帧永远进B路自己的队列两个池子各干各的。A路冲到30fpsB路也完全不受影响两路的帧率曲线稳定得像单路运行时一样。二是把异常范围框在了单路以内。A路线程池里某个推理任务炸了最多影响A路自己的工作线程B路的链路干干净净。排查故障的时候直接把目标锁定到对应的池子和对应的代码路径上再也不用面对“两路一起崩到底是谁的锅”这种扯皮问题。从RK3588这颗芯片的角度再深挖一层它采用的是4颗Cortex-A76大核加4颗Cortex-A55小核的组合外加独立的NPU算力单元。两路各开线程池之后你可以很自然地把A路线程绑定到大核簇、B路线程也分配到对应的核心上运行。有人一听到Python多线程就开始念叨GIL但在这个场景里GIL的影响并没有想象中那么大——yolov5s推理的核心计算已经通过RKNN运行时卸载到NPU执行CPU端主要做图像缩放、色彩空间转换、任务调度和结果解析这些操作在CPython解释器里会适时释放GIL实测并行的收益是实实在在的。2. 硬件清单与环境搭建从摄像头接入到RK3588系统准备2.1 摄像头接入方式的选择MIPI还是USB双路视觉第一道坎是图像输入。香橙派5做双路视觉主流的摄像头接入方式就两种MIPI CSI接口和USB摄像头。MIPI口的优势是低延迟、高帧率工业场景默认首选。香橙派5板载的MIPI CSI接口支持摄像头模组输入配合官方适配的模组可以稳定跑1080p30fps。但MIPI有它固有的麻烦接口不是通用的不同摄像头模组的排线定义、数据通道数、供电电压都可能不一样板卡出厂后基本只能选官方适配列表里的型号换模组就意味着重新适配驱动和设备树。USB摄像头走的就是另一条路线了。UVC协议是标准协议插上就能用开发调试的幸福感直线上升。缺点是延迟相对高USB总线带宽是共享的同时挂多路摄像头时容易互相挤占带宽分辨率越高、帧率越高带宽压力越大。我在这套方案里用的是“一路MIPI 一路USB”的混搭接法。这样做的核心考量是让两条图像采集通道在物理上分离避免两路USB同时抢总线导致帧率联动下跌。如果你手头的摄像头恰好都是USB的也不是不行但务必确认主控的USB控制器支持足够多的带宽或者干脆把一路挪到PCIe转USB的扩展方案上去。2.2 RK3588系统镜像与Python环境准备系统层面香橙派5既支持官方自家的镜像也支持烧写Ubuntu 20.04/22.04版本。我选择的是Ubuntu 20.04的server镜像直接去掉桌面环境把省下来的内存和CPU全部让给推理任务。烧写镜像后第一件事是确认NPU驱动已经正确加载。RK3588的NPU设备节点是/dev/rknpu如果系统内核里没有带RKNPU驱动后续rknn-toolkit2统统白搭。检查方式非常简单ls -l /dev/rknpu能看到设备节点说明NPU驱动设备已经就绪。如果看不到大概率是系统镜像不对或者内核版本太老需要重新烧写一个带NPU驱动的新版本镜像。Python环境我建议用虚拟环境隔离别直接在系统环境里装一堆依赖否则后续升级和维护都是给自己挖坑python3 -m venv yolo_env source yolo_env/bin/activate pip install numpy opencv-python-headless pip install rknn-toolkit2这里有个隐藏较深的坑rknn-toolkit2对Python版本有硬性要求不是所有版本的Python都能装上。安装前先查一下官方发布的whl包对应的Python版本范围避免装完之后import直接报错。我遇到过多次“pip install成功了import rknn却抛找不到共享库”的情况最后发现是Python版本不匹配导致的编译期接口差异。内存方面我强烈建议直接用16GB版本。双路图像流加模型推理加推流8GB勉强够用但一旦系统里有其他服务或者内存泄漏整机会变得非常僵硬。16GB版本让人安心不少尤其当你后面还想叠加ffmpeg推流和Web服务的时候。3. 线程池的核心设计ThreadPoolExecutor参数与阻塞队列取舍3.1 阻塞队列到底选哪种线程池底层是一个任务队列Python的concurrent.futures.ThreadPoolExecutor默认用无界队列。无界队列听起来很方便实际上是定时炸弹任务提交速度超过处理速度时队列无限膨胀内存迟早被吃干抹净。双路视觉的场景里摄像头帧是持续不断的流一旦某路推理变慢任务堆积速度会非常快几分钟就能吃出几个G的占用。我最终的做法是把默认队列替换为有界队列。ThreadPoolExecutor的队列在接口层面不直接暴露所以需要自定义executor或者在submit阶段做防护。我选择的是自定义一个BoundedThreadPoolExecutor继承ThreadPoolExecutor并覆写submit方法先检查队列长度超过阈值直接丢弃新帧并记录丢帧计数。至于队列类型queue.Queue、queue.LifoQueue和queue.PriorityQueue三种都能选。视觉任务场景里优先级几乎没有价值——每一帧都需要处理丢掉哪一帧也不是靠优先级能定夺的。FIFO队列queue.Queue反而是最合适的保证任务严格按照帧顺序处理避免后提交的帧先被推理导致画面顺序错乱这在视频输出场景里非常关键。3.2 线程池参数怎么定线程池最重要的参数是最大线程数。很多人一看到“多线程”就兴奋地想把线程数顶到CPU核心数甚至翻倍这在纯Python计算场景里往往适得其反。在RK3588的yolov5s项目里CPU端的工作是图像预处理resize、BGR转换、letterbox和结果后处理NMS、坐标框解析这些操作会释放GIL理论上可并行但实际的瓶颈在NPU推理单次耗时的节奏上。我给每路线程池设置的线程数是3到4个。为什么是3因为一个线程池里的工作线程通常要承担不同的职责一个线程做帧采集和快速预处理一个线程提交NPU推理任务并等待结果第三个线程做结果后处理和回调。再多加线程并不会提升吞吐量因为瓶颈已经转移到NPU推理延迟上了。多加线程只会增加线程切换开销和内存占用。线程池的参数千万不要写死在代码里用配置文件管理是更理智的做法class DualStreamConfig: stream_a { max_workers: 3, queue_size: 4, model_path: models/yolov5s_a.rknn, score_threshold: 0.45, } stream_b { max_workers: 3, queue_size: 4, model_path: models/yolov5s_b.rknn, score_threshold: 0.5, }这样每路独占参数调优就是改配置文件重启的事不污染代码逻辑。后面如果重要的一路需要更高吞吐把它的max_workers拉到4、queue_size拉到8另一路保持原样互不影响。3.3 两路线程池与NPU并行调度的配合有读者肯定要问RK3588的三核NPU能同时跑两个模型吗答案是能而且设计思路就是要让它能。RKNN运行时在多线程环境下会把任务分派给不同NPU核心执行前提是每个模型实例独立加载、独立初始化。我在代码里为两路各自创建了独立的RKNN实例分别加载各自的rknn模型互不干扰。这里有一个容易被忽视的细节ThreadPoolExecutor默认创建的线程是非daemon的程序退出时如果池子里还有任务没跑完进程会卡在等待线程结束上。双路视觉程序本来就是长时间运行的守护类程序退出逻辑必须显式处理。正确姿势是配合signal handler捕获CtrlC调用executor.shutdown(waitFalse)同时设置队列get超时让工作线程在可预期的时间内退出循环。4. 核心代码实现一个线程池跑一路视频流的完整拆解4.1 视频流采集封装与预处理思路双路视觉程序“一个线程池跑一路”的最小闭环代码我可以完整走一遍。每路用一个VideoStream类封装摄像头采集class VideoStream: def __init__(self, device_id, width, height): self.cap cv2.VideoCapture(device_id) self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) self.cap.set(cv2.CAP_PROP_FPS, 30) def read(self): ok, frame self.cap.read() if not ok: return None return frame视频采集本身是阻塞操作如果读不到帧read会一直卡住或返回False。实际操作中建议让专门的采集线程单独存在读到的帧放入一个很小的buffer推理线程从buffer取帧这样即便某帧推理耗时超过帧间隔采集侧也不会受阻。letterbox预处理是yolov5系列的标准操作保持宽高比将图像缩放到640x640剩余区域填充灰边。这一步千万不要省略或者用直接resize替代否则检测精度会明显下降尤其对长宽比差距大的图像影响很大。预处理阶段的核心代码不外乎就是img letterbox(frame, (640, 640)) img img[:, :, ::-1].transpose(2, 0, 1) # BGR转RGB并调整维度 img np.ascontiguousarray(img)4.2 推理任务提交与结果回收接下来是线程池推理的核心。我把每路流封装成一个独立的worker函数由主线程启动两个守护线程分别运行def stream_worker(stream_cfg): stream VideoStream(stream_cfg.device_id, 640, 640) rknn RKNN() rknn.load_rknn(stream_cfg.model_path) rknn.init_runtime() executor BoundedThreadPoolExecutor( max_workersstream_cfg.max_workers, queue_sizestream_cfg.queue_size ) def process_frame(frame, frame_id): img letterbox(frame, (640, 640)) outputs rknn.inference(inputs[img]) boxes post_process(outputs, stream_cfg.score_threshold) return frame_id, boxes frame_id 0 while True: frame stream.read() if frame is None: time.sleep(0.5) continue frame_id 1 future executor.submit(process_frame, frame, frame_id) future.add_done_callback(on_detect_result)这段代码的运行逻辑是主循环持续读帧并提交任务executor里的工作线程负责执行process_frame完成预处理、NPU推理和后处理推理结果通过Future回调返回到on_detect_result。有界队列在积压超过容量时会让submit抛异常我在BoundedThreadPoolExecutor实现里做了容错处理改成了返回None并丢帧计数确保程序不会因队列堆积而OOM。新手最容易犯的错是在add_done_callback里直接操作共享变量比如更新一个跨线程共用的results列表。回调函数运行在ThreadPoolExecutor的工作线程里和主线程的修改会形成数据竞争。必须加锁或者用queue.Queue把结果传回主线程再统一处理。4.3 帧率控制与背压策略的选择双路视觉真正跑起来之后帧率控制是最考验耐心的环节。摄像头采集是30fpsNPU推理yolov5s单次大约20到40毫秒理想情况能跑到25fps但双路并行后NPU算力被分摊单路帧率会掉到15到20fps左右。这时候必须明确业务逻辑是每帧都必须处理还是允许丢帧如果要求每帧都处理那就接受15fps把采集线程的buffer调大保证推理慢的期间帧不丢用内存换帧率。如果只要求“尽量不漏检重要目标”那就对采集层丢帧——队列满时丢弃新帧保留旧帧先推理。这个取舍在工程里叫背压策略。对于实时性敏感的场景比如无人机避障应当优先处理最新帧丢弃积压的旧帧对于完整性敏感的场景比如安防周界应尽量保留所有帧慢一点没关系但不能漏。我把两套策略都预置在代码里通过一个output_stale_frames参数切换实测效果都很稳定。5. yolov5s模型轻量化从PyTorch到RKNN的转换与NPU加速5.1 为什么yolov5s在RK3588上还要继续瘦身yolov5s在目标检测模型里算轻量级的但放到RK3588的边缘场景里依然有继续瘦身的空间。标准yolov5s的参数量约7.2MFP32模型大小接近30MB转成RKNN并做INT8量化后能压到10MB以内。模型体积小了加载速度更快NPU推理时访存带宽压力更小双路并行时优势更明显。轻量化主要做三件事一是裁剪检测头如果你只关心COCO 80类中的少数几类可以压缩输出层的类别数二是INT8量化这是最主要的体积和速度优化手段三是降低输入分辨率640x640降到512x512或416x416。分辨率的选择要结合场景认真权衡。我的实际经验是近距离大目标检测用416x416没问题检测速度能提升30%以上但如果你要做远处小目标检测别盲目降分辨率小目标在低分辨率下直接消失精度惨不忍睹。本方案我保留640x640好在双路并行的情况下帧率依然可接受没有抠这点性能。5.2 ONNX导出与RKNN-Toolkit2转换链路官方的yolov5仓库自带export.py直接导出ONNXpython export.py --weights yolov5s.pt --include onnx --opset 12然后使用rknn-toolkit2转换from rknn.api import RKNN rknn RKNN() rknn.config( mean_values[[0, 0, 0]], std_values[[255, 255, 255]], target_platformrk3588 ) rknn.load_onnx(modelyolov5s.onnx) rknn.build(do_quantizationTrue, dataset./dataset.txt) rknn.export_rknn(yolov5s.rknn)这条转换链路里最容易被忽略的是dataset.txt这是量化校准数据集清单每一行是一张图片路径。校准集最好用真实业务场景里采集的图片而不是随便找的通用数据集。图片数量通常二三十张就够但内容要覆盖典型场景——不同光照、不同角度、不同目标数量。校准集不合格会导致量化后精度大幅下降最典型的现象是某些类别召回率骤降你还在那疯狂调阈值找原因其实是校准集的锅。5.3 NPU推理调用与后处理适配RKNN-Toolkit2的Python推理API非常简洁outputs rknn.inference(inputs[img])yolov5模型的输出是三个尺度的检测结果shape分别是(1, 255, 20, 20)、(1, 255, 40, 40)和(1, 255, 80, 80)。255等于3乘5加80对应3个anchor、4个坐标偏移、1个目标置信度、80个类别概率。后处理在CPU上做是双路方案里耗时的大头尤其是NMS部分。这里分享一个性能技巧ONNX转RKNN之前在PyTorch端把输出层做个预处理把三个尺度的输出reshape成更易处理的形状甚至可以把一些后处理算子直接塞进模型图里。RKNN转换器能识别部分yolov5的后处理算子并放到NPU上执行这部分若能被NPU吃掉CPU端的耗时能明显降下来。我在实际转换过程中对比过带上部分后处理算子的RKNN模型和纯原始输出的模型相比CPU占用降低约15%。6. 实测数据与踩坑清单帧率、资源占用与故障排查6.1 两路同时跑的实际表现我最终跑通的配置是一路MIPI摄像头1080p一路USB摄像头720p输入统一缩放到640x640模型用INT8量化后的yolov5s.rknn每路线程池3个线程、队列容量4。实测数据如下指标单路独立双路并行单路推理延迟约18ms约30ms单路实际帧率约24fps约15fpsCPU总占用约35%约65%内存占用约1.2GB约2.3GB程序稳定性连续运行稳定连续运行一周无崩溃双路并行时帧率下降是预期内的RK3588的NPU虽然有三核但两路同时跑相当于算力对半分。如果不做INT8量化帧率会更难看甚至掉到个位数。所以再次建议RK3588部署yolov5s系列量化这步真的别省。6.2 双路方案里的几个经典坑第一个坑是线程池任务错误被静默吞掉。ThreadPoolExecutor的Future异常不会立即传播submit之后不做add_done_callback也不调用future.result()异常会被静默吞掉。我当时排查了半天发现一路画面全黑的原因居然是rknn.inference在某个特定输入尺寸下报错异常被线程池吃了主程序完全不知情。修复方式是在回调函数里加try/except并写详细的日志。第二个坑是MIPI输入1080p信号时画面花屏。这个问题在官方文档里也很难查到实际原因是MIPI CSI接入1080p时如果时钟配置不对采样画面会有撕裂和颜色异常。解决办法是严格按照sensor模组提供的驱动配置去设置不要试图用通用模式硬扛。另外香橙派5的MIPI通道对输入信号的行场时序比较敏感1080i和1080p的读写时序差异会导致奇怪的问题能用1080p就别用1080i。第三个坑是线程池队列的“伪阻塞”。用queue.Queue时如果get的超时时间设置过长线程池shutdown时工作线程会陷在get里迟迟不退出导致shutdown(waitTrue)卡住十几秒甚至更久。我的解法是把get的超时设为0.1秒循环检查线程池的shutdown事件保证退出逻辑干净利落。第四个坑是模型量化后精度下降集中在特定类别。我发现INT8量化后“人”这个类别的检测率明显下降起初以为是模型过拟合某个场景后来排查发现是校准集里人的图片占比太少。补充了一批不同姿态、不同距离的人物图片之后问题明显改善。量化校准不是走个过场它直接决定模型在你业务场景里的真实价值。6.3 这套方案的扩展方向双路视觉方案一旦跑通后续扩展空间很大。一是加到四路这时候四个线程池需要考虑RK3588的CPU核数分配建议用两个池子各管两路配合CPU亲和性让池子分别跑在不同的大核簇上。二是集成ffmpeg推流把每路的检测结果画框后通过H264编码推到局域网RK3588自带硬件编码器这个操作对CPU的额外开销很小。三是接入rknn-toolkit2的零拷贝接口减少数据搬运的开销能进一步把双路帧率往上提几帧。从我个人实测体验来看双路视觉方案的稳定性八成看线程池设计两成看模型部署细节。线程池设计里最关键的就是这两个点独立池子和有界队列。它们决定了程序在持续运行几天几夜的过程中是稳定输出还是悄悄崩溃。现在这套“两路各一个线程池”的方案我已经挂在实验室跑了一周多中间经历了多次摄像头断连重连、网络波动和模型热切换均未出现崩溃或者内存暴涨。下一步我打算在这个框架上继续叠加四路支持和远程推流这篇就先把双路的架构细节沉淀下来给后面要走上同一条路的伙伴们做个参考。
返回列表