采集與顯示實(shí)戰(zhàn))
簡介面向初學(xué)OpenCV和Qt的開發(fā)者這份代碼包演示了通過Qt多線程結(jié)合OpenCV同時(shí)讀取多路USB攝像頭并實(shí)時(shí)顯示到用戶界面的完整方案。需要留意的是多個(gè)攝像頭應(yīng)單獨(dú)接入PC不宜共用USB Hub以避免帶寬干擾。資源共269個(gè)文件以197個(gè)hpp頭文件和63個(gè)h頭文件為主配合3個(gè)cpp源文件、pro工程文件、ui界面文件以及l(fā)ib庫文件便于快速理清工程結(jié)構(gòu)與鏈接配置壓縮包整體約2.24MB輕量精煉。camera模塊負(fù)責(zé)設(shè)備采集mainwindow相關(guān)代碼處理多線程調(diào)度與界面刷新三個(gè)源文件串聯(lián)起線程創(chuàng)建、圖像抓取和顯示更新等關(guān)鍵步驟并保留工程注釋適合作為入門模板。目前已有3156人學(xué)習(xí)下載適合希望快速上手Qt與OpenCV聯(lián)合開發(fā)、實(shí)現(xiàn)多路視頻并行預(yù)覽和界面集成等可視化應(yīng)用的讀者。 做視覺相關(guān)項(xiàng)目或者設(shè)備端上位機(jī)開發(fā)的話多路USB攝像頭同時(shí)顯示幾乎是個(gè)繞不開的硬需求。我最近在QtOpenCV這套技術(shù)棧上踩了一遍坑把多路USB攝像頭的畫面采集拆到QThread線程里再通過信號槽實(shí)時(shí)回傳并顯示到UI界面踩完發(fā)現(xiàn)這個(gè)需求看起來不復(fù)雜但真正落地時(shí)處處是細(xì)節(jié)稍不留神就會(huì)踩到線程模型、數(shù)據(jù)生命周期這些坑。這篇文章會(huì)把從架構(gòu)選型到實(shí)際編碼的完整思路記錄下來特別是里面涉及到的采集并發(fā)、跨線程數(shù)據(jù)傳輸、UI渲染節(jié)奏控制我把跑通的代碼和踩過的坑都放出來。內(nèi)容以Qt 5.15.2 OpenCV 4.8.0為例環(huán)境是Windows 10邏輯上在Linux下一樣適用適合正在做多目視覺、安防監(jiān)控、質(zhì)檢抓拍、機(jī)器人巡線這類項(xiàng)目的讀者參考。1. 項(xiàng)目背景與整體架構(gòu)選型1.1 為什么不能用單線程硬懟直觀做法是把OpenCV的VideoCapture放在主線程里循環(huán)讀取攝像頭數(shù)據(jù)然后直接顯示到窗口上聽起來很簡單實(shí)際上問題很大。單路1080P的USB攝像頭在普通USB2.0通道下采集一幀畫面的耗時(shí)可能達(dá)到30到50毫秒如果同時(shí)開三路四路主線程的while循環(huán)里全被讀幀操作占滿UI基本就廢了——按鈕點(diǎn)不動(dòng)、窗口拖不動(dòng)、鼠標(biāo)事件沒響應(yīng)體驗(yàn)非常糟糕。更麻煩的是USB控制器帶寬在物理層面是共享的多路設(shè)備同時(shí)read會(huì)互相拖垮最終出現(xiàn)幀率暴跌和畫面撕裂。這個(gè)問題的根因在于VideoCapture::read()是阻塞式IO它會(huì)一直等到攝像頭返回一幀數(shù)據(jù)才返回。把這個(gè)阻塞操作放進(jìn)UI線程等于把整個(gè)程序的響應(yīng)能力綁定在攝像頭的出幀速度上。攝像頭稍有延遲UI就得跟著卡頓攝像頭驅(qū)動(dòng)一旦出問題UI直接死等。1.2 架構(gòu)設(shè)計(jì)采集與渲染解耦所以最終采用的是一個(gè)典型的“生產(chǎn)者-消費(fèi)者”架構(gòu)。每個(gè)攝像頭對應(yīng)一個(gè)采集線程生產(chǎn)者線程里不斷用VideoCapture讀取新幀拿到后立刻把cv::Mat轉(zhuǎn)成QImage通過Qt信號槽發(fā)給UI主線程主線程只負(fù)責(zé)把收到的QImage繪制到對應(yīng)的QLabel上完全不做耗時(shí)操作。選擇QThread而不是直接用std::thread核心原因是Qt的信號槽機(jī)制天然支持跨線程的隊(duì)列連接。信號槽在隊(duì)列連接方式下會(huì)把事件投遞到接收者所在線程的消息循環(huán)中數(shù)據(jù)傳遞是線程安全的不需要自己維護(hù)互斥鎖來保護(hù)幀隊(duì)列。如果硬要用std::thread幀數(shù)據(jù)回傳只能靠拋事件、管道或者共享內(nèi)存加鎖實(shí)現(xiàn)起來繞了一大圈還沒有信號槽這種開箱即用的線程安全通道。架構(gòu)確定下來之后代碼層面需要三類關(guān)鍵對象CameraWorker負(fù)責(zé)具體的采集循環(huán)QThread負(fù)責(zé)提供獨(dú)立的線程環(huán)境CameraManager負(fù)責(zé)統(tǒng)一管理各路采集線程的啟停。這三個(gè)類配合起來就形成了一套可以橫向擴(kuò)展的多路采集框架后面加一路攝像頭只需要多new一組Worker和Thread。2. 環(huán)境準(zhǔn)備與依賴配置2.1 開發(fā)環(huán)境選型建議很多人在環(huán)境配置上浪費(fèi)了大量時(shí)間我直接說結(jié)論。Qt版本不建議追新選5.15 LTS最穩(wěn)資料多、坑基本都被踩平了OpenCV選4.x系列4.5以上對USB相機(jī)的兼容性更好支持的后端也更全。我這里實(shí)測的是Qt 5.15.2 msvc2019_64套件加OpenCV 4.8.0編譯器是MSVC 2019 64位。這里必須強(qiáng)調(diào)一個(gè)鐵律OpenCV的預(yù)編譯庫必須和你的編譯器嚴(yán)格匹配。你在VS里用MSVC編譯的OpenCV庫在Qt里就不能鏈接MinGW編譯出來的版本否則一編譯直接冒出上千個(gè)LNK2019未解析外部符號。Qt安裝時(shí)如果默認(rèn)帶的是MinGW套件建議額外裝一個(gè)MSVC套件或者在Qt Creator里直接切換使用VS的工具鏈能省去后續(xù)無數(shù)煩惱。2.2 OpenCV與Qt項(xiàng)目的CMake集成OpenCV官方預(yù)編譯包解壓后包含build/x64/vc16/lib目錄和include目錄。在CMakeLists.txt中核心配置總共沒幾行但每行的作用都要清楚cmake_minimum_required(VERSION 3.16) project(CameraDemo) set(CMAKE_CXX_STANDARD 11) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) find_package(Qt5 COMPONENTS Widgets REQUIRED) set(OpenCV_DIR D:/opencv/build) find_package(OpenCV REQUIRED) add_executable(CameraDemo main.cpp mainwindow.cpp camera_manager.cpp) target_link_libraries(CameraDemo PRIVATE Qt5::Widgets ${OpenCV_LIBS})一個(gè)重點(diǎn)提醒OpenCV的Debug和Release庫是分開的opencv_world480d.lib對應(yīng)Debugopencv_world480.lib對應(yīng)Release。CMake的find_package通常會(huì)根據(jù)構(gòu)建類型自動(dòng)選但如果你是手動(dòng)添加庫路徑一定要區(qū)分?;煊肈ebug庫和Release庫編譯時(shí)不一定報(bào)錯(cuò)運(yùn)行時(shí)卻可能莫名崩潰比如在cv::Mat析構(gòu)時(shí)訪問違例這種問題排查起來特別費(fèi)時(shí)間所以從一開始就規(guī)范環(huán)境變量。3. 核心實(shí)現(xiàn)采集類設(shè)計(jì)與多路管理3.1 QThread的兩種用法我選了moveToThreadQThread使用上存在兩種主流姿勢網(wǎng)上吵了很多年。一種是繼承QThread并重寫run()把采集循環(huán)寫進(jìn)run()里另一種是創(chuàng)建一個(gè)普通QObject工作對象調(diào)用moveToThread()把這個(gè)對象遷移到子線程再通過信號觸發(fā)槽函數(shù)在線程中執(zhí)行。官方文檔長期推薦第二種方式實(shí)際項(xiàng)目中我也推薦第二種。原因是QThread本身也是一個(gè)QObject直接把業(yè)務(wù)邏輯塞進(jìn)run()會(huì)破壞它的生命周期管理而且線程的退出、對象的銷毀時(shí)機(jī)都需要自己小心處理很容易出現(xiàn)“線程停止了但對象還在跑”這種詭異狀態(tài)。moveToThread寫法更符合Qt的事件驅(qū)動(dòng)模型槽函數(shù)的執(zhí)行可以通過信號精確觸發(fā)退出時(shí)也更可控。采集工作類的寫法如下注意所有耗時(shí)邏輯都放在槽函數(shù)中這樣它們才會(huì)在子線程中執(zhí)行class CameraWorker : public QObject { Q_OBJECT public: explicit CameraWorker(QObject *parent nullptr); ~CameraWorker() override; public slots: void startCapture(int cameraIndex); void stopCapture(); signals: void frameReady(const QImage image); void errorOccurred(const QString message); private: cv::VideoCapture m_capture; bool m_running false; }; void CameraWorker::startCapture(int cameraIndex) { m_capture.open(cameraIndex); if (!m_capture.isOpened()) { emit errorOccurred(QString(無法打開攝像頭: %1).arg(cameraIndex)); return; } m_running true; cv::Mat frame; while (m_running) { m_capture frame; if (frame.empty()) { continue; } cv::Mat rgb; cv::cvtColor(frame, rgb, cv::COLOR_BGR2RGB); QImage image(rgb.data, rgb.cols, rgb.rows, rgb.step, QImage::Format_RGB888); emit frameReady(image.copy()); QThread::msleep(10); } } void CameraWorker::stopCapture() { m_running false; if (m_capture.isOpened()) { m_capture.release(); } }主線程中的創(chuàng)建和管理部分是這樣CameraWorker *worker new CameraWorker; QThread *thread new QThread; worker-moveToThread(thread); connect(thread, QThread::started, worker, CameraWorker::startCapture); connect(worker, CameraWorker::frameReady, this, MainWindow::updateFrame); connect(worker, CameraWorker::errorOccurred, this, MainWindow::onCameraError); thread-start();這里有個(gè)很關(guān)鍵的點(diǎn)startCapture是槽函數(shù)而非普通成員函數(shù)。這樣當(dāng)thread發(fā)出started信號時(shí)由于AutoConnection的判定機(jī)制槽函數(shù)會(huì)在接收者所在的線程即子線程中執(zhí)行采集循環(huán)就跑在子線程里了。如果在主線程中直接調(diào)用worker-startCapture()那么采集循環(huán)會(huì)運(yùn)行在主線程中導(dǎo)致UI假死這是很常見的誤用場景。3.2 cv::Mat跨線程傳遞要轉(zhuǎn)QImageOpenCV的cv::Mat不能直接emit給主線程原因很隱蔽。Qt的隊(duì)列連接在跨線程傳遞參數(shù)時(shí)會(huì)觸發(fā)參數(shù)類型的拷貝構(gòu)造而QImage是Qt原生的可拷貝類型能夠被信號槽系統(tǒng)正確管理生命周期但cv::Mat是基于引用計(jì)數(shù)的信號槽系統(tǒng)并不認(rèn)識(shí)它強(qiáng)行傳遞會(huì)走Qt的元類型系統(tǒng)編譯可能能過運(yùn)行時(shí)卻容易崩潰或者數(shù)據(jù)錯(cuò)亂。所以在采集線程里我直接把Mat轉(zhuǎn)成QImage再發(fā)射。轉(zhuǎn)換過程中的一個(gè)最容易出錯(cuò)的點(diǎn)就是最后對QImage調(diào)用copy()。很多讀者會(huì)漏掉這一步結(jié)果畫面顯示一段時(shí)間后開始花屏或黑屏。原因是雖然Mat的data和QImage共享了同一塊內(nèi)存但信號發(fā)送到主線程后Mat對象已經(jīng)被銷毀QImage持有的內(nèi)存就成了懸空指針。加一個(gè)copy()做深拷貝讓QImage自己管理數(shù)據(jù)內(nèi)存才是安全做法。轉(zhuǎn)換時(shí)還要注意一個(gè)細(xì)節(jié)不要在每次循環(huán)里都重新對rgb變量賦值而是提前定義好cv::Mat復(fù)用內(nèi)存。雖然這只是少了一次分配但在高幀率多路場景下減少堆分配對幀率穩(wěn)定性有明顯幫助。3.3 多路采集的統(tǒng)一管理多路攝像頭管理有一個(gè)容易踩的雷不能所有線程共享同一個(gè)cv::VideoCapture對象。多線程同時(shí)讀同一臺(tái)設(shè)備會(huì)相互干擾特別是一些免驅(qū)UVC攝像頭會(huì)直接報(bào)錯(cuò)或者輸出錯(cuò)亂畫面。所以每個(gè)攝像頭都要有獨(dú)立的VideoCapture實(shí)例并且各自運(yùn)行在自己的QThread中。為了方便統(tǒng)一維護(hù)這些實(shí)例我寫了一個(gè)CameraManager單例class CameraManager : public QObject { Q_OBJECT public: static CameraManager *instance(); bool openCamera(int index); void closeCamera(int index); void closeAll(); signals: void frameReady(int index, const QImage image); void errorOccurred(int index, const QString message); private: QMapint, QThread * m_threads; QMapint, CameraWorker * m_workers; };每個(gè)攝像頭的開啟流程都是一套模板new QThread、new Worker、moveToThread、連接信號、start。關(guān)閉時(shí)不能直接delete要經(jīng)過quit和wait安全退出void CameraManager::closeCamera(int index) { if (!m_threads.contains(index)) { return; } m_workers[index]-stopCapture(); m_threads[index]-quit(); m_threads[index]-wait(); delete m_workers[index]; delete m_threads[index]; m_threads.remove(index); m_workers.remove(index); }這里有一個(gè)順序問題要注意先發(fā)stopCapture讓worker退出循環(huán)再quit線程事件循環(huán)最后wait等待線程真正結(jié)束然后才能安全delete。如果順序反了線程可能還在執(zhí)行采集循環(huán)對象就被釋放了直接宕機(jī)。UI界面布局方面我在主窗口里放了一個(gè)QGridLayout根據(jù)攝像頭數(shù)量動(dòng)態(tài)生成QLabel每個(gè)QLabel設(shè)置固定尺寸和黑色背景。哪一路信號沒來網(wǎng)絡(luò)上那一路就保持黑色并疊加一個(gè)“攝像頭未連接”的文字提示這樣用戶能直觀判斷每一路的狀態(tài)。4. 常見問題與調(diào)試經(jīng)驗(yàn)4.1 典型問題排查速查我整理了一份調(diào)試過程中最容易遇到問題的速查表基本覆蓋了這類項(xiàng)目的絕大多數(shù)故障點(diǎn)現(xiàn)象根因解決方案UI卡死窗口無法拖動(dòng)VideoCapture或采集循環(huán)跑在主線程檢查是否用了moveToThread確認(rèn)槽函數(shù)執(zhí)行線程ID畫面閃爍、經(jīng)常黑屏發(fā)射信號時(shí)沒有深拷貝QImageemit前調(diào)用image.copy()確保QImage擁有獨(dú)立內(nèi)存多路同時(shí)打開失敗USB帶寬不足或供電不足降低分辨率到640x480或者將幀率降到15fps編譯報(bào)大量LNK2019OpenCV庫與編譯器不匹配換用MSVC庫或切換Qt工具鏈統(tǒng)一Debug/Release關(guān)閉程序時(shí)崩潰QThread未正確退出刪除順序錯(cuò)誤先stopCapture再quit再wait最后delete攝像頭圖像顏色偏藍(lán)或偏綠BGR和RGB通道順序錯(cuò)誤用cvtColor將BGR轉(zhuǎn)RGB后再構(gòu)造QImage4.2 調(diào)試工具與排查思路多線程程序最怕“看起來沒崩潰但結(jié)果不對”單純靠眼睛看代碼很難定位。我最常用的手段是打印線程ID驗(yàn)證執(zhí)行上下文。在CameraWorker的startCapture開頭加上qDebug() capture thread: QThread::currentThreadId();在MainWindow的updateFrame里也打印當(dāng)前線程ID。如果發(fā)現(xiàn)VideoCapture的read和UI刷新在同一個(gè)線程ID下執(zhí)行說明moveToThread調(diào)用時(shí)機(jī)不對或者信號連接類型被強(qiáng)制改成了DirectConnection。還有一個(gè)容易被忽視的坑在Windows下多個(gè)攝像頭索引并不總是從0開始連續(xù)排列。有些攝像頭驅(qū)動(dòng)會(huì)占用多個(gè)索引比如筆記本自帶攝像頭在索引0而USB攝像頭在索引2。如果固定從0到N排查可能會(huì)發(fā)現(xiàn)某一路死活打不開。建議在初始化時(shí)遍歷0到10逐個(gè)嘗試打開并記錄成功列表再將可用的攝像頭顯示到界面上這樣對用戶更友好也避免硬編碼索引導(dǎo)致程序在別的機(jī)器上無法工作。4.3 性能優(yōu)化方向多路攝像頭同時(shí)跑CPU占用是繞不開的話題。實(shí)測四路640x48015fps的畫面在i5處理器上CPU占用大約在20%到30%之間隨著分辨率提升占用還會(huì)線性上升。幾個(gè)優(yōu)化手段值得優(yōu)先嘗試。第一是攝像頭輸出格式。很多USB攝像頭默認(rèn)輸出YUYV未壓縮格式帶寬占用極大??梢栽趏pen之后嘗試切換為MJPG壓縮格式m_capture.set(cv::CAP_PROP_FOURCC, cv::VideoWriter::fourcc(M, J, P, G)); m_capture.set(cv::CAP_PROP_FRAME_WIDTH, 640); m_capture.set(cv::CAP_PROP_FRAME_HEIGHT, 480); m_capture.set(cv::CAP_PROP_FPS, 15);我實(shí)測在相同分辨率下從YUYV切換到MJPG后USB帶寬占用下降了不止一個(gè)量級多路同時(shí)跑時(shí)穩(wěn)定性明顯提升。注意set操作要在open之后盡量早地調(diào)用且要檢查返回值因?yàn)椴糠謹(jǐn)z像頭驅(qū)動(dòng)對格式切換支持不完整設(shè)置可能靜默失敗。第二是UI刷新節(jié)流。攝像頭的采集幀率通常是30fps但UI線程的刷新能力不一定需要吞下每一幀。如果直接用frameReady信號驅(qū)動(dòng)QLabel更新頻繁的paintEvent會(huì)占掉主線程大量時(shí)間。我建議在MainWindow中對刷新做定時(shí)器節(jié)流比如每50毫秒更新一次畫面也就是20fps的顯示幀率視覺上基本沒有區(qū)別但CPU占用能降下來不少。第三是圖像格式轉(zhuǎn)換的取舍。如果只是顯示畫面不需要對圖像做復(fù)雜處理可以將原始Mat直接傳遞給QImage并加上深拷貝減少一次cvtColor的耗時(shí)。如果你的業(yè)務(wù)需要做算法分析可以把算法模塊獨(dú)立出來放到OpenCV的并行處理中避免阻塞采集線程。最后再分享一點(diǎn)體會(huì)。多路USB攝像頭顯示這個(gè)需求網(wǎng)上資料比較零散每一塊看似都不難但串起來才發(fā)現(xiàn)坑全在細(xì)節(jié)。本質(zhì)上只要抓住兩條主線一是采集阻塞不能出現(xiàn)在UI線程二是幀數(shù)據(jù)的內(nèi)存所有權(quán)必須清晰。把這兩點(diǎn)理清楚QtOpenCVQThread這套組合幾乎可以應(yīng)對所有多路相機(jī)展示場景。希望這份記錄能幫你少走一些彎路。本文還有配套的精品資源點(diǎn)擊獲取