ARTICLE DETAIL

资讯详情

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

RK3588双路摄像头yolov5s检测:线程池并发隔离方案详解

RK3588双路摄像头yolov5s检测:线程池并发隔离方案详解 上一篇我们终于把单路yolov5s在香橙派RK3588上跑稳了后台收到最多的私信就是问双路视觉怎么做。单路能跑不算完很多实际项目要同时看两个方向比如一台AGV机器人前避障后跟随或者一个入口相机一个通道相机。直接复制两份单路代码怼上去帧率会掉得让你怀疑人生。这篇讲双路视觉方案一的核心思路两路摄像头各自独立采集线程负责抓帧推理线程池负责处理一路一个池互不干扰。适合已经把单路yolov5s跑通的读者想升级到双路并发场景直接看这篇。1. 双路视觉方案的整体思路与设计取舍1.1 双路视觉到底在解决什么问题先说个我实际踩过的场景。实验室里有个小项目一台移动底盘要同时看前后两个方向前面检测障碍物后面跟踪目标。我当时图省事直接起了两个Python进程每个进程跑一套独立完整的单路检测逻辑。跑起来发现两个问题非常明显第一个问题是进程间抢资源。两个进程都在不断地打开摄像头、读帧、推理、画框操作系统的调度器一会儿把CPU时间片切给A一会儿切给B结果就是两边帧率都不稳A画面跳帧B也跟着抖完全没法用。第二个问题更难察觉就是日志和异常互相干扰。某一路摄像头偶发掉帧那一堆报错刷屏另一路的实时性也被拖累。我当时最烦的就是A进程崩了B进程还在继续跑但没人知道A已经死了整个系统的状态变成半残废。所以双路视觉要解决的核心问题不是简单的两路都走一遍单路逻辑而是并发隔离。每一路要有自己独立的数据通路、独立的计算资源池、独立的出错边界。这就是一路一个线程池方案诞生的背景。1.2 为什么选择一路一池而不是共用一个池子设计双路方案时我认真对比过三种组织方式。第一种两路共用一个ThreadPoolExecutor总线程数固定为4。这个方案代码写起来最省只创建一个池子所有摄像头把任务丢进去。但用起来有个非常现实的毛病任务竞争不均衡。ThreadPoolExecutor内部就是一个公共任务队列哪个worker空闲就取哪个任务执行根本不关心任务是从哪路来的。假如第一路因为画面里目标突然变多预处理和后处理耗时暴增它就会往公共队列里塞大量任务把四五个worker全啃住第二路的任务就排队排到天荒地老。结果就是一路异常全系统陪葬。第二种每路独立线程池主线程只负责启动和收结果。路与路之间的任务队列、worker线程、锁全部物理隔离。第一路就算疯狂塞任务最多影响它自己池子里的两个worker第二路该多少帧还多少帧。这就是性能隔离的价值。代价是线程数翻倍内存和CPU占用略高但香橙派5是8核CPU两个池子各2个worker加起来4个推理线程加上2个采集线程总共6个完全扛得住。第三种直接上多进程。隔离最彻底但进程间通信要处理帧数据的传递共享内存和IPC都非常麻烦不适合一个入门教程就铺开讲。我会在后面单独的篇章里写多进程方案这篇先把线程池方案吃透。所以一路一池本质上是性能隔离优先的设计选择。它在工期和技术难度上的性价比是最高的。1.3 线程池内部的流水线配合确定一路一池之后还要想清楚池子里的worker到底干什么活。我一开始想得很简单worker就是负责推理结果写完发现预处理太慢worker被绑死在GPU前处理上推理反而排队。最后我把每个worker设计成跑一条完整的小流水线取帧 - 预处理 - 推理 - 后处理 - 更新结果。这里面有个容易被新手忽略的关键问题RKNN的Python接口并不是完全线程安全的。同一个RKNN runtime对象如果多个线程同时对它做inference短时间看不出毛病长时间高并发跑会出现偶发报错或者推理结果突然变得很奇怪。我的解决办法是给每一路单独加一把threading.Lock把推理那一步锁起来。注意是每一路一把锁绝对不能全局一把锁否则两路推理又被串行化了方案一的核心优势就没了。有人会问既然推理被锁住了那一个worker和两个worker有什么区别区别在预处理和后处理。yolov5s的letterbox、缩放、归一化这些步骤在CPU上跑NMS和坐标换算也在CPU上跑这些是可以并行计算的。两个worker配合起来一个在等锁推理的时候另一个已经把下一帧的预处理做完了。实测比单worker的吞吐量能高10%-15%。2. 部署前的基础准备接线、系统、模型转换2.1 硬件接线强烈建议先上双USB摄像头双路视觉的第一步是搞定两路图像输入。RK3588芯片本身是支持多个MIPI CSI接口的香橙派5板子上也引出了摄像头排线座但我必须说实话MIPI摄像头在RK3588上的适配是出了名的折腾。不同厂商的模组有不同的设备树配置、I2C地址、上电时序稍微对不上就是黑屏或者花屏。香橙派的Linux系统适配MIPI屏幕和摄像头本身就是一篇长文章的体量不适合在双路线程池方案里混着讲。所以这一步我的建议非常明确想要快速把双路方案跑起来用两个USB摄像头。USB摄像头走的是UVC标准协议插上去系统自动识别为/dev/video0、/dev/video1内核自带驱动不需要改设备树不需要动板级配置。先别管MIPI等双路逻辑彻底跑顺了再回头单独研究MIPI摄像头适配你会发现反而更有思路。接线时有个细节容易被忽略两个USB摄像头最好分别插在板子不同的USB控制器上而不是都插在同一个扩展hub。USB摄像头的数据带宽并不小如果两个都挤在一个hub上共享同一个USB通道高帧率下很容易丢包表现为画面卡顿或者花帧。我实测把两个摄像头顶在香橙派5的前后两个USB口比插同一个hub稳定很多。2.2 系统与NPU运行环境确认这篇默认你已经把单路教程跑通但环境这块我还是要花两分钟确认几个点因为这些是双路方案的基座系统是烧写的Ubuntu 20.04这个镜像是用烧录工具直接写入SD卡或者eMMC的没问题RKNN runtimelibrknnrt.so和rknn-toolkit2的版本必须匹配我用的1.6.0版本之前遇到过runtime版本不一致导致NPU推理报错的坑确认NPU设备节点正常。在终端执行ls /dev/dri/如果能看到renderD128说明NPU设备已被系统正常识别确认NPU频率是正常档位用cat /sys/kernel/debug/clk/clk_npu/clk_rate查看当前频率如果明显偏低大概率是散热问题或者电源问题这些点如果没确认双路跑起来出问题了排查范围会大一倍。单路能稳定跑是双路上手的前提条件。2.3 yolov5s模型转RKNN的三个关键点模型转换的完整流程前几篇写过这里只把双路场景最容易出问题的三个点拎出来说。第一yolov5s.pt先转ONNX时输出节点要对齐。锚点设置、640x640输入尺寸这些保持默认就行。很多人习惯在命令行直接导出我建议脚本里显式指定 opset12 和 simplify避免后面转RKNN时出现不支持的算子。第二量化数据集要贴近真实场景。我用的量化数据是从实际摄像头抓帧存下来的每个目标类别各准备了几十张图。有人直接拿COCO的原图做量化跑出来的模型在自己的应用场景里掉点严重。原因很简单量化校准集需要和部署场景的图像分布尽量一致。第三转出来的.rknn文件先单独写个小脚本加载测一下确认板子上能正常加载和推理再开始写双路代码。这一步能省掉后面一半的排查时间。不要在双路代码里才第一次加载模型一旦报错你根本分不清是模型的问题还是并发的问题。3. 双线程池方案的核心实现3.1 代码结构总览双路方案的代码封装我设计成CameraPipeline类每一路摄像头对应一个实例。这样做的好处是方案二、方案三切换时只需要换类的内部实现上层主程序基本不用动。整体结构是这样的dual_vision.py ├── CameraPipeline 类 │ ├── __init__ 摄像头初始化、线程池创建、模型加载 │ ├── _capture_loop 采集线程的while循环 │ ├── _work_loop 线程池worker的while循环 │ ├── start() 启动采集线程提交worker任务 │ ├── stop() 优雅退出 │ └── get_result() 获取当前路最新检测结果 └── 主程序 ├── 创建两个CameraPipeline ├── 启动 └── 主线程循环读取结果显示统计FPS类内部主要维护四个成员frame_queue帧队列、executor线程池、capture_thread采集线程、last_result最新结果。这个设计思路是生产者-消费者模型的经典变体采集线程是生产者池子里的worker是消费者。3.2 采集线程保证不漏帧但也不堵死摄像头采集我坚持用独立线程而不是在主循环里直接cap.read()。原因是cv2.VideoCapture.read()是阻塞式调用当摄像头底层没有新帧时read会一直等着。如果主线程被read卡住推理和后处理全部停摆。采集线程的代码看起来很短但每个细节都有讲究import cv2 import queue import threading import time class CameraPipeline: def __init__(self, cam_id, model_path, workers2, queue_size4): self.cam_id cam_id self.cam cv2.VideoCapture(cam_id) self.cam.set(cv2.CAP_PROP_FRAME_WIDTH, 640) self.cam.set(cv2.CAP_PROP_FRAME_HEIGHT, 640) self.cam.set(cv2.CAP_PROP_FPS, 30) self.frame_queue queue.Queue(maxsizequeue_size) self.stop_event threading.Event() self.capture_thread threading.Thread( targetself._capture_loop, namefcapture-{cam_id}, daemonTrue ) def _capture_loop(self): while not self.stop_event.is_set(): ok, frame self.cam.read() if not ok: time.sleep(0.01) continue if self.frame_queue.full(): try: self.frame_queue.get_nowait() except queue.Empty: pass self.frame_queue.put(frame)重点在于queue.Queue(maxsizequeue_size)加满时丢旧帧。为什么要给队列设上限如果不设上限推理一旦慢下来队列会无限堆积内存吃到爆不说更严重的是延迟会越来越大画面显示的永远是几十帧之前的内容这在实时视觉里是致命的。设置了上限以后满队列时的策略是弹出最旧的帧再放入新帧保证队列里始终是最新的几帧。代价是有可能跳过中间一两帧但在动态目标检测场景里偶尔丢一帧的影响微乎其微。这个策略我用英文术语概括就是latest-frame-wins很多实时视频管线都这么干。3.3 双线程池的创建与任务提交线程池部分用Python内置的concurrent.futures.ThreadPoolExecutor不需要额外安装第三方库。这个类在标准的线程池方案里非常合适因为它自带线程管理、任务队列和优雅关闭省去自己造轮子。from concurrent.futures import ThreadPoolExecutor class CameraPipeline: # ... 接上面 __init__ def _setup_pool(self): self.executor ThreadPoolExecutor( max_workersself.workers, thread_name_prefixfinfer-{self.cam_id} ) for i in range(self.workers): self.executor.submit(self._work_loop, i) def start(self): self.capture_thread.start() self._setup_pool()注意到一个问题我提交给线程池的不是一个任务处理一帧而是一个长期循环任务任务内部不停取帧处理。为什么要用长期循环而不是每帧提交一次两方面考虑。第一是调度开销。每帧都submit会让线程池频繁进行任务入队、出队、分配这些操作在嵌入式设备上都是有成本的。长期循环任务提交一次就一直在跑池子里的worker空转时会从内部队列等帧开销小很多。第二是天然限流。不管帧队列里积压了多少帧同一时刻只有max_workers个worker在处理多出来的帧老实排队。这等于自动实现了流量控制避免瞬时帧率暴增把系统打崩。thread_name_prefix这个参数很多人忽略但它极其有用。当多线程程序出现问题只能用threading.enumerate()或者抓线程栈来排查时如果线程名是infer-0和infer-1你能一眼看出是哪一路卡住了。不加这个前缀所有线程都叫ThreadPoolExecutor-0排查看起来想砸键盘。3.4 worker流水线取帧、预处理、推理、后处理_work_loop是整个双路方案的心跳每个worker都在跑这个循环。我把它拆开逐段讲import numpy as np import threading import time class CameraPipeline: def __init__(self, cam_id, model_path, workers2, queue_size4): # ... 前面代码省略 self.infer_lock threading.Lock() self.rknn self._load_rknn_model(model_path) self.last_result None self.last_boxed None def _work_loop(self, worker_id): while not self.stop_event.is_set(): try: frame self.frame_queue.get(timeout0.5) except queue.Empty: continue t0 time.time() # 预处理CPU上执行可以和另一worker的推理并行 rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) resized cv2.resize(rgb, (640, 640)) resized resized.astype(np.float32) / 255.0 input_tensor np.expand_dims(resized, 0).transpose(0, 3, 1, 2) # 推理需要持锁防止同一runtime并发 with self.infer_lock: outputs self.rknn.inference(inputs[input_tensor]) # 后处理CPU上执行 boxes, scores, class_ids self._post_process(outputs) # 更新结果主线程从这里读 self.last_result (boxes, scores, class_ids, time.time()) # 顺带把画好框的图也存下来方便显示 for box, score, cls in zip(boxes, scores, class_ids): x1, y1, x2, y2 [int(v) for v in box] cv2.rectangle(resized, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText( resized, f{cls}:{score:.2f}, (x1, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2 ) self.last_boxed resized我特别想展开讲讲为什么用self.infer_lock而不是让每个worker都new一个独立的RKNN runtime。RK3588的NPU算力就那么多6 TOPS的INT8性能。如果每路池子里每个worker都加载一份独立的runtime等于同时创建多个NPU上下文抢占NPU。yolov5s这种轻量模型单个推理本身只要十几到几十毫秒多个runtime分片反而会因为NPU调度的上下文切换增加额外开销。我实测的结果是一份runtime加锁串行推理比两份runtime并行推理总吞吐量和稳定性都要好。这个方案还有一个额外好处就是内存占用低。每个RKNN runtime都要占用一部分模型常驻内存3份runtime意味着模型常驻内存×3在8GB内存的板子上虽说不至于崩但完全没必要。3.5 主程序整合双池启动、双路显示与帧率统计主程序写起来反而简单因为它只做三件事创建两路、启动两路、循环展示结果。def main(): pipeline0 CameraPipeline(0, yolov5s.rknn, workers2, queue_size4) pipeline1 CameraPipeline(1, yolov5s.rknn, workers2, queue_size4) pipeline0.start() pipeline1.start() fps_list [0.0, 0.0] while True: for idx, pipe in enumerate([pipeline0, pipeline1]): if pipe.last_boxed is not None: # 左右拼接显示注意先统一高度 pass # 实际拼接 frame0 pipeline0.last_boxed frame1 pipeline1.last_boxed if frame0 is not None and frame1 is not None: if frame0.shape[0] ! frame1.shape[0]: frame1 cv2.resize(frame1, (640, frame0.shape[0])) combined np.hstack([frame0, frame1]) cv2.imshow(dual_vision, combined) key cv2.waitKey(1) 0xFF if key ord(q): break pipeline0.stop() pipeline1.stop() cv2.destroyAllWindows()这里有一个小细节要注意左右拼接要保证两路画面高度一致否则np.hstack会在维度检查时报错。我习惯统一resize到640宽高度按实际保持一致。如果两路摄像机安装位置或分辨率不同画出来之后一高一矮会很别扭最好在显示前强制对齐高度。另外stop()方法里要做的事情也不复杂设置stop_event为True然后等待采集线程结束再executor.shutdown(waitTrue)。这样退出时不会因为线程还活着而报错。4. 参数选择与性能实测线程池大小、队列长度怎么定4.1 线程池大小为什么每路选2个worker先说结论我每一路用2个worker两路共4个推理线程加上2个采集线程总共6个活跃线程。为什么不是1个因为worker不仅仅是推理还要做预处理和后处理。这两部分在CPU上跑是可以并行的。单worker意味着worker在推理的时候CPU是闲着的大部分状态等于浪费了一半以上的CPU算力。2个worker可以让一个worker在排队等锁推理的同时另一个worker完成下一帧的预处理把CPU的间隙时间填上。为什么不是3个或更多我在香橙派5上实测过。当worker数量超过2个时线程间的锁竞争和CPU调度开销急剧上升多个线程同时抢同一个RKNN推理锁排队时间变长很多CPU时间花在线程切换而不是实际计算上。帧率不升反降CPU温度倒是升得欢快。我试过3个worker一路结果是总帧率反而比2个worker时略低。香橙派5用的是RK35888核CPU我所用的Ubuntu 20.04操作系统本身也要占一些核。留几个核给系统、SSH、显示和其他后台服务是保证长期运行稳定性的必要代价。4.2 阻塞队列长度那个隐患最大的参数线程池的阻塞队列选择是个关键参数但很多人随手就填一个数字不在乎。我在双路方案里用的队列长度是4。队列长度本质上决定了两件事缓冲能力和延迟。队列越长缓冲越多短时的推理波动越不容易丢帧但代价是延迟变大处理的是更早的帧。队列越短延迟越可控但突发帧到来时丢帧率提高。怎么算出合理的队列长度我用的是最粗糙也最实用的估算方法。假设摄像头是30FPS帧间隔约33ms。推理峰值耗时会波动最短十几毫秒最长可能到60毫秒。当推理峰值超过帧间隔时队列里每帧到来会积压约0到1帧。取个余量队列长度4足够平滑掉绝大多数波动。如果你用的是60FPS的摄像头帧间隔只有16ms左右那么同样大小的缓冲区能缓冲的时间长度就减半了建议把队列长度调整到6-8。总原则永远是那句宁可丢帧不可延长延迟。实时视觉系统里延迟比丢帧更可怕。4.3 实测性能数据双路能跑多少帧放一组我在香橙派5RK3588, 8GB内存, Ubuntu 20.04, RKNN runtime 1.6.0上实测的数据。摄像头是两颗普通1080p USB摄像头模型处理分辨率640x640INT8量化。配置单路FPS双路合计FPS双路单路FPSCPU占用每路1个worker325427约45%每路2个worker356030约65%每路3个worker345829约75%这张表有三个信息值得品。第一2个worker确实比1个worker有提升但提升幅度不是翻倍只有10%-15%符合理论预期。第二3个worker开始倒退了因为锁竞争和调度开销超过了并行收益。第三两路同时跑时单路FPS掉到30左右几乎是单路独立运行帧率的一半还不到这个现象的原因是NPU本身只有一个两路共享算力加上CPU带宽、内存带宽也都被两路分掉了。如果你的实际环境跑出的帧率和这个差距比较大先检查两个东西一是摄像头是否真的输出640x640分辨率很多摄像头并不是标准的640x640比如初始输出是1280x720你代码里set了640x640摄像头可能根本不支持这个分辨率吞掉了设置内部还是在做缩放白白增加CPU负担二是看画面里目标数量目标多的时候NMS计算开销上涨明显。4.4 性能优化的优先顺序如果对帧率不满意我建议按下面的优先顺序排查优化。第一步是看预处理。yolov5s的letterbox和归一化如果用纯Python写会在每个像素循环上浪费大量时间。尽量全部用cv2和numpy向量化操作不要出现for循环遍历像素。我见过有人预处理要花40ms优化后只要5ms。第二步是看后处理。画面里目标很多时候选框数量暴增NMS成为瓶颈。可以适当降低conf阈值让进入NMS的候选框数量少一些或者改用更简洁的NMS实现比如直接用numpy写循环控制iou比cv2.dnn.NMSBoxes在某些场景下更快。第三步是看采集。检查摄像头的实际输出能力有的USB摄像头号称1080p 60FPS实际输出可能只有720p 15FPS。用v4l2-ctl --list-formats-ext看看摄像头真实支持的格式和帧率。最后再动线程池参数。不要一开始就调大worker数99%的情况瓶颈不在线程数。5. 常见问题与排查技巧实录5.1 问题一两路摄像头设备节点错乱症状很典型两个USB摄像头插上去代码里写死cv2.VideoCapture(0)和cv2.VideoCapture(1)但经常出现打不开、画面黑屏、或者两个都打开成了同一个摄像头的情况。原因也很明确Linux下USB设备的枚举顺序不固定每次插拔和重启/dev/video0和/dev/video1可能会对调。你代码里写死的索引有时候碰巧是对的有时候就是错的。排查方法先用ls /dev/video*看有哪些节点再用v4l2-ctl --list-devices查看每个物理设备对应哪几个video节点。v4l2-ctl是v4l-utils包里的工具Ubuntu下用apt install v4l-utils安装。最严谨的解决方案是不要硬编码设备索引而是通过摄像头的序列号或USB路径来识别设备。我写了一个简易的查找函数通过解析v4l2-ctl --list-devices的输出来匹配指定的摄像头品牌型号或者USB端口位置。这样无论设备节点怎么变代码都能找到正确的设备。如果时间紧不想写这个也有个笨办法每次开机后先看一下设备枚举顺序然后固定插拔顺序。但这是治标不治本一重启可能又乱了。5.2 问题二队列堆积导致延迟越来越大这个坑我自己踩得很惨。第一次写完双路代码跑起来发现画面显示的检测框比真实场景慢了大概一秒多我一开始还以为是摄像头本身的延迟折腾半天才发现是队列的问题。原因是我第一版写的队列满了之后不是丢旧帧而是直接put_nowait抛异常异常被捕获后不知道怎么处理结果是采集线程被卡住反复重试整条流水线被拖慢。后来改成队满丢旧帧的策略延迟立刻恢复正常。记住这个代码片段if self.frame_queue.full(): try: self.frame_queue.get_nowait() except queue.Empty: pass self.frame_queue.put(frame)这个丢旧帧逻辑一定要写在put之前。如果你写的顺序反了队列满了以后新帧就无法进入流水线等于断流。5.3 问题三RKNN推理偶尔报错或结果错乱症状是双路跑起来几分钟到几小时不等某一路的推理突然报错比如RuntimeError: Input timestamp invalid或者检测框的位置和置信度突然乱跳。第一排查方向是并发问题。确认是不是多线程同时调用同一个RKNN runtime导致的把self.infer_lock临时改成全局串行锁如果问题消失那就确定是runtime线程安全问题。然后在正式代码里保留每个实例独立的推理锁即可。第二个排查方向是温度。香橙派5的NPU满载时发热非常厉害不加散热片和风扇温度轻松飙到80度以上。温度过高时NPU会自动降频推理耗时拉长偶尔还会出现异常。用cat /sys/class/thermal/thermal_zone0/temp看温度单位是毫摄氏度。如果长时间超过75度建议装散热风扇或者至少加一块导热硅脂和散热铝片。第三个排查方向比较隐蔽是模型文件被换出到swap导致推理延迟爆表。如果你的系统内存紧张RKNN模型常驻内存可能被操作系统换到swap分区推理时重新从swap加载延迟会从十几毫秒飙升到几百毫秒。解决办法是限制双路系统的其他内存占用必要时关闭swap或者调高swapiness。5.4 问题四摄像头FPS参数设置无效cv2.VideoCapture.set(cv2.CAP_PROP_FPS, 30)这行代码在便宜的USB摄像头上经常是无效的。摄像头固件根本不理你这个设置依然按照自己默认的帧率输出。如果发现帧率和预期不符用v4l2-ctl看看摄像头实际支持的像素格式和帧率组合。有些摄像头在1080p下只能跑15FPS但切到720p就能稳定30FPS。这属于硬件能力限制代码绕不过去只能调整输入分辨率。在双路方案里如果发现某路摄像头FPS上不去我建议把该路的采集分辨率设为720p模型处理依然是640x640这样摄像头到模型之间的缩放开销也更小。5.5 双路方案避坑经验速查表把这次跑双路踩过的坑整理成一张表格方便以后快速对照坑现象解决方案线程名没加标识排查问题时分不清哪路卡住设置thread_name_prefix为infer-0/infer-1线程数开太多帧率不升反降CPU温度飙升每路保持2个worker全局推理锁两路全被串行化双路变两倍延迟每路独立infer_lock队列无上限延迟越来越大内存缓慢上涨使用maxsize4-8满时丢旧帧双路高度不一致np.hstack维度报错显示前先resize对齐高度MIPI摄像头强行上车设备树、上电时序一堆问题先用USB摄像头跑通逻辑最后再分享一个小经验。双路视觉方案一跑通之后你可以顺手做一个斜率检测每等一段时间统计两路的FPS和队列长度如果发现某一路队列长期接近满、FPS持续低于预期就打印一条告警。这个机制能帮你提前发现摄像头老化、场景复杂度突变、NPU降频等问题比等画面卡死再去排查要省时间得多。我自己后来把这段逻辑封装成了一个简单的HealthMonitor类稳定运行了两周都没出过岔子。这个方向后续还可以扩展有空我再写RK3588上双路视频流的推流方案配合ffmpeg把两路画面合成一路推出去很多远程监控项目的需求就是这个。
返回列表