从 SDK 抽象到帧缓冲池,把"取流"这件事,做到能在产线上跑一整天
【产线真相】Demo 里 VideoCapture 好好的,装上工业相机就崩
先还原一个我踩过的现场。
本地调试,我图省事,用的是cv::VideoCapture读一段视频,跑得溜溜的。到了客户现场,换成真工业相机,一开机:
- 采集线程里
capture.read(frame)返回的帧,偶尔是空的,代码没判空,直接崩; - 界面预览一动,主线程就卡,操作员点按钮要等好几秒;
- 跑两个小时,任务管理器里内存一路爬到 3 个 G。
我当时的第一反应是"相机 SDK 有问题"。查了一整天,最后发现——三个问题全在我自己写的采集代码里。
这一篇,就把工业采集最常见的三种死法摊开讲:它们怎么发生、怎么认出来、怎么治。
死法一:每一帧都new,内存迟早爆
最常见的写法长这样:
// ❌ 每来一帧,就 new 一块图像voidonFrame(constunsignedchar*data,intw,inth){cv::Mat*img=newcv::Mat(h,w,CV_8UC3);// 每帧都申请memcpy(img->data,data,f.size());process(frame);// 用完……// delete frame; // ← 忘了,或者不知该谁删}两个致命问题:
- 内存泄漏:
new了没人delete,几小时就爆;
- 就算你记得删,每秒几十帧的高频分配/释放,也会把内存搅成碎片,卡顿、甚至分配失败。
死法的本质:工业采集的帧是"高频、等大、反复用"的东西,它需要的是复用,不是每次都申请。
正解:帧缓冲池(Frame Pool)
思路很朴素——预先分配一批固定大小的帧缓冲,循环使用:
// 帧缓冲池:预分配 N 块,循环复用,避免运行期 new/deleteclassFramePool{public:explicitFramePool(intcount,cv::Size size,inttype){for(inti=0;i<count;++i)pool_.emplace_back(size,type);// 一次性分配好}// 借一块空的(覆盖写入)cv::Mat*acquire(){std::lock_guard<std::mutex>lk(mtx_);if(free_.empty())returnnullptr;// 没得借 → 上层决定丢帧auto*m=free_.front();free_.pop_front();returnm;}// 用完还回来voidrelease(cv::Mat*m){std::lock_guard<std::mutex>lk(mtx_);free_.push_back(m);}private:std::vector<cv::Mat>pool_;std::deque<cv::Mat*>free_;std::mutex mtx_;};采集时从池里借一块写进新帧,下游用完再还回来。运行期几乎零分配,内存曲线是一条平线,不是爬坡。
【产线真相】第二种死法:在 UI 线程里做采集
第二个坑更隐蔽:把capture >> frame直接写在界面的按钮事件里。
因为它"能跑",所以你很难一开始就发现问题。直到运行起来:相机取一帧要几十毫秒,这一个操作就把 UI 线程占住了。于是——
- 预览卡、按钮点不动;
- 相机一旦超时,整个界面直接假死。
根因:采集是"连续、耗时、可能阻塞"的活,它天生就不该待在负责画界面的线程里。
正解:采集独立成线程,通过信号把帧"推"出来
用 Qt 的方式,让采集跑在自己的线程里,取到帧后发信号给主线程(第 10 篇会讲完整的三线程模型,这里先立住"采集不占 UI"这条线):
classCaptureWorker:publicQObject{Q_OBJECTpublicslots:voiddoWork(){// 在独立线程里循环while(!stop_.load()){cv::Mat*buf=pool_->acquire();if(!buf){emitdropped();continue;}// 池满,丢帧if(!camera_->grab(*buf)){// 采集一帧pool_->release(buf);emitframeLost();// 断流/超时,上报continue;}emitframeReady(buf);// 推给下游}}signals:voidframeReady(cv::Mat*buf);voidframeLost();// 丢帧/断流事件voiddropped();private:ICamera*camera_{};FramePool*pool_{};std::atomic_bool stop_{false};};注意两个细节:
frameReady抛出去的是"池里的缓冲指针",不是 Qt 值拷贝——零拷贝是工业采集的命;- 有了
frameLost()/dropped()事件,你才能知道"到底丢了多少帧",而不是靠猜。
第三种死法:没有帧队列,下游一忙,要么积压要么丢帧
采集很快(比如每秒 60 帧),但算法处理慢(比如每帧 30ms → 每秒只能 33 帧)。中间没有缓冲,结果只有两种:
- 丢帧:下游处理不过来,采集的帧直接被覆盖或丢弃;
- 积压:如果代码写成"每来一帧就排队等处理",队列越排越长,延迟越来越大,最后内存爆。
这正是"背压(back-pressure)"问题:上游和下游速度不匹配时,必须有明确的策略,而不是假装它们一样快。
正解:定长环形队列 + 明确的丢帧策略
// 定长环形队列:只保留"最新的 N 帧",满了就丢最旧的template<classT,size_t N>classRingBuffer{public:boolpush(constT&v){std::lock_guard<std::mutex>lk(mtx_);if(full()){// 满了 → 覆盖最旧(保最新)head_=(head_+1)%N;--size_;}buf_[(head_+size_)%N]=v;++size_;returntrue;}boolpop(T&out){std::lock_guard<std::mutex>lk(mtx_);if(empty())returnfalse;out=buf_[head_];head_=(head_+1)%N;--size_;returntrue;}boolempty()const{returnsize_==0;}boolfull()const{returnsize_==N;}private:std::array<T,N>buf_{};size_t head_{0},size_{0};mutablestd::mutex mtx_;};要不要丢、丢最旧的还是最新的,取决于你的工艺:
- 实时检测(结果只看当前帧)→保最新,旧帧毫无价值;
- 需要不丢任何一帧(如计数、追溯)→加大队列 + 反压采集(让采集慢下来)。
关键不是"永不丢帧",而是"你要清楚地知道自己在丢,并且知道为什么可以丢"。
别忘了硬件抽象层(HAL)
上面三个死法解决了,还有一个"架构级"的问题要提前埋好:别让采集代码直接绑死在某个品牌相机的 SDK 上。
在hal/里立一个接口(第 2 篇说的②层):
// hal/include/ICamera.h —— 所有相机都实现它classICamera{public:virtual~ICamera()=default;virtualboolopen()=0;// 连接virtualboolgrab(cv::Mat&out)=0;// 取一帧virtualvoidclose()=0;// 释放virtualQStringname()const=0;};以后无论接的是哪个牌子、走 GigE 还是 USB3,采集层只认ICamera。换相机 = 写一个新的实现类,不改采集逻辑,更不动 UI。
【能跑起来的最小实现】把三种死法一次性治好
把上面的帧池、环形队列、采集线程和 HAL 拼在一起,就是一个能稳定跑的采集模块:
// CaptureModule:HAL + 帧池 + 环形队列 + 独立线程 的组合classCaptureModule:publicQObject{Q_OBJECTpublic:explicitCaptureModule(ICamera*cam,intpoolSize=8):camera_(cam),pool_(poolSize,cv::Size(2448,2048),CV_8UC3),thread_(this){}voidstart(){if(!camera_->open()){emiterror("相机打开失败");return;}moveToThread(&thread_);// 采集跑在独立线程connect(&thread_,&QThread::started,this,&CaptureModule::loop);thread_.start();}voidstop(){stop_=true;thread_.quit();thread_.wait();camera_->close();}voidrelease(cv::Mat*b){pool_.release(b);}// 下游用完归还缓冲signals:voidframeReady(cv::Mat*buf);// 零拷贝把池里的缓冲递给下游voiderror(constQString&msg);// 断流/异常上报privateslots:voidloop(){while(!stop_){cv::Mat*buf=pool_.acquire();if(!buf)continue;// 池已借空:本帧跳过if(!camera_->grab(*buf)){// 采集失败pool_.release(buf);emiterror("断流,重试中");// 不崩,供上层重连continue;}queue_.push(buf);// 入环形队列emitframeReady(queue_.latest());// 保最新}}private:ICamera*camera_{};FramePool pool_;RingBuffer<cv::Mat*,4>queue_;QThread thread_;std::atomic_bool stop_{false};};这一屏,就是工业采集该有的样子:
HAL 屏蔽相机品牌 + 帧池做到零分配 + 环形队列控背压 + 独立线程不卡界面。
它一点都不复杂——但它正是"Demo 里能跑"和"能在车间跑一整天"之间的全部差距。
【避坑指南】
坑1:每帧new/cv::Mat拷贝。
- 现象:内存爬坡、越跑越慢。
- 原因:运行期反复分配。
- 正解:
FramePool预分配 + 复用;传递用指针,别值拷贝。
坑2:采集写在 UI 线程。
- 现象:界面卡、假死。
- 原因:采集阻塞了事件循环。
- 正解:采集独立线程,
frameReady信号把帧推给主线程。
坑3:没判空 / 没处理断流。
- 现象:偶发崩溃、莫名丢帧却查不出。
- 原因:
grab失败返回空帧/超时,代码没接住。 - 正解:判空 +
frameLost事件 + 重连(第 6 篇通信部分同款思路)。
坑4:全速采集不设上限。
- 现象:CPU 100%,其他线程饿死。
- 原因:采集无节制。
- 正解:按需限频(如定时触发或"下游空闲才取")。
坑5:颜色通道反了。
- 现象:预览里红的变蓝、蓝的变红。
- 原因:工业相机 SDK 常给 RGB,而 OpenCV 默认 BGR。
- 正解:统一在采集出口转成一种约定(建议内部统一 BGR),转换点只留一个。(这是 OpenCV 系列讲过的老坑,采集这里是它的高发地。)
下一篇:把图"洗干净"再送进算法
到这里,VisionStation 已经能稳定取流了。
下一篇,我们在采集和算法之间,加上第 4 篇的主角——图像预处理链:为什么产线上真正要用的算子就那么几个?一条预处理流水线该怎么排?参数怎么做成现场能调、不用重编译?
在那之前,先问你一句:
你的采集代码,现在丢帧吗?——如果丢,你能说出丢在采集、队列,还是算法吗?
说不清楚,就说明你的系统缺了那个frameLost事件。
评论区聊聊你的采集踩坑史。