ARTICLE DETAIL

资讯详情

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

Qt下用DirectShow采集多路UVC摄像头:设备枚举、回调区分与避坑指南

Qt下用DirectShow采集多路UVC摄像头:设备枚举、回调区分与避坑指南 简介directshow.zip 是一份面向 Qt 开发者的 DirectShow 封装示例源码解决在 Windows 平台用 Qt 快速访问摄像头与音频设备的问题尤其适合需要集成视频捕捉、设备切换和参数调整功能的应用。压缩包共七个文件包括三个 C 源文件、两个头文件、一个 Qt 工程文件和一个界面文件源文件实现主要逻辑头文件暴露设备枚举与参数控制接口界面文件用于演示操作。整个包仅 7 千字节代码量很小、结构清楚从零搭建视频采集模块时很有参考价值适合已有 Qt 界面基础、希望补充多媒体采集能力的初、中级开发者。这个示例已有 882 人学习重点实现了摄像头列表获取、音频输入输出设备枚举、摄像头参数值读取以及分辨率查询等关键能力例如摄像头列表可做启动时的默认设备选择音频枚举便于在语音通话中切换麦克风分辨率查询帮助匹配最佳输出规格。开发者直接参考这些接口即可快速接入视频聊天、在线教育、安全监控等实时应用省去直接编写底层 DirectShow 滤镜和组件对象模型代码的繁琐后续还能基于示例扩展曝光、白平衡等参数调节以及多设备切换和视频性能调试具有较好的实用价值。1. 在 Qt 里调 dshow camera这个 zip 解决的三个问题接手一个 Windows 下的 Qt 采集项目要同时读两路 USB 摄像头第一反应是用 OpenCV 的 VideoCapture。出图是快但等到要改曝光、切分辨率、在回调里区分“哪一帧是哪个摄像头”时整套方案就卡住了。这个 directshow.zip 就是把 Qt 和 DirectShow 接起来的一套封装与示例核心解决三件事枚举系统里所有 UVC 摄像头并拿到稳定唯一标识、用 Filter Graph 拉流并取原始帧、多路摄像头在同一进程里互不串扰地工作。适合正在 Qt Windows 上做视频采集或准备从 OpenCV 迁移到原生采集层的开发者尤其是设备数量超过一台、需要按物理设备区分画面的场景。2. DirectShow 与 Qt 的接法Filter Graph 概念和选型理由2.1 先搞懂 Filter Graph 和 SampleGrabberDirectShow 对新手来说是个黑匣子。你不用背全部接口但要抓住它的核心架构一条采集链路被拆成三个角色——Source Filter 负责从 UVC 设备读流Transform Filter 负责格式转换或抓帧Renderer Filter 负责消费或丢弃数据。三者首尾相连组成一个 Filter GraphGraph 跑起来后数据就沿着这条链路流动。Qt 这边要取帧关键是把 SampleGrabber 作为 Transform Filter 塞进链路中间。SampleGrabber 不会改变数据内容它只是把经过的每一帧“复制一份”交给你的回调函数同时把原始数据继续往下传。这样做的意义在于你拿到的是设备输出的原始帧而不是像 QMediaPlayer 那样被封装好的 QVideoFrame后续做算法、保存、推流都有完全的控制权。Graph 的启动和暂停由 IMediaControl 控制管它叫 Run、Pause、Stop。整个连接过程会牵涉到 IPin、AM_MEDIA_TYPE 这些结构但实际写代码时你只需要记住一个核心流程创建 Graph 对象 → 把 Source Filter 和 SampleGrabber 加进去 → 找到输出的 Pin 和输入的 Pin → 用 Connect 接上。剩下的类型协商和缓冲管理都由 DirectShow 内部完成。2.2 为什么是 DirectShow 而不是 V4L2 / OpenCV VideoCapture在 Windows 上做 Qt 摄像头采集有几种方案摆在面前。Qt Multimedia 的 Windows 后端底层用的其实也是 DirectShow 或 Media Foundation但它把接口封装得太干净反而把一些关键能力藏掉了拿不到设备唯一的物理标识改不了 UVC 扩展单元参数也无法在回调里精确控制设备上下文。OpenCV 的 VideoCapture 上手快它的 VideoCapture 内部帮你建好了 Graph但它有几个硬伤分辨率切换经常失败读取 UVC 属性靠 V4L2 那套在 Windows 上不通用多路摄像头同时打开时偶发资源冲突而且很难在帧回调里区分当前帧来自哪一路。V4L2 是 Linux 的方案在 Windows 上根本没法用。这里选 DirectShow是因为它直接暴露了设备枚举、Graph 连接、回调接口是 Windows 下最能“摸到硬件”的方案。下面用表格把几个方案的关键能力做对比选型时可以直接参考。技术选型枚举多个 UVC 设备获取原始帧控制 UVC 曝光/对焦回调里区分设备跨平台Qt Multimedia部分支持间接获取不支持不支持支持OpenCV VideoCapture支持但标识不稳定可以基本不行需要额外 hack支持DirectShow支持DevicePath 唯一可以可以可以仅 WindowsMedia Foundation支持可以可以可以仅 WindowsDirectShow 的缺点是不跨平台、API 老、文档少但在这类纯 Windows 的工控或安防项目里它是最稳的方案。Media Foundation 是后来的替代品但网上现成封装少遇到问题排查成本更高。directshow.zip 选择 DirectShow 而不是 Media Foundation大概率是考虑兼容老系统和对代码可控性要求更高我实际用下来这个判断是对的。3. 环境准备与设备枚举从 COM 到 Moniker 的唯一标识3.1 编译环境与两个必须处理好的头文件在 Qt 工程里引入 DirectShow第一步不是写代码而是把环境弄干净。DirectShow 的头文件是 DirectShow 那套 SDK 提供的关键的有 dshow.h、qedit.h同时需要链接 strmiids.lib。在 Qt 的 .pro 文件里加 LIBS 时strmiids.lib 要和 uuid.lib 一起带上否则链接阶段会报一堆未解析的 GUID。物理上每个摄像头是一个 COM 组件所以首先要初始化 COM 环境。Qt 的 GUI 线程默认已经初始化过 STA但为了保险建议在专门的采集线程里显式调用 CoInitializeEx用 COINIT_MULTITHREADED 让回调能在任意线程跑。注意采集线程退出时要配 CoUninitialize否则下一次创建 Filter 时会报 0x8001010E 之类的 COM 错误。#include dshow.h #include qedit.h // 如果报错 C2065: GUID_NULL 未声明把下面这行放到 dshow.h 之前 // #include basetyps.h HRESULT hr CoInitializeEx(nullptr, COINIT_MULTITHREADED); if (FAILED(hr)) { // 常见错误RPC_E_CHANGED_MODE 说明线程已经是 STA换个线程或改用 COINIT_APARTMENTTHREADED return; }这里有两个细节值得注意。一是 COINIT_MULTITHREADED 决定的是你回调的执行模型多路摄像头都希望回调并行那初始化用 MTA 最直接二是不管是对象创建还是后续 COM 调用都要判 FAILED别只看 hr 是不是 S_OK。3.2 枚举系统摄像头FriendlyName 会撞车DevicePath 不会枚举设备是 DirectShow 里最套路化的一段代码通过系统设备枚举器拿到视频输入设备类别的 Moniker 列表每个 Moniker 代表一个摄像头。但这里我强烈建议把这个步骤做厚不仅读 FriendlyName还要读 DevicePath。FriendlyName 是给人看的名字比如 “USB Camera”两台同型号摄像头插上去 FriendlyName 完全一样DevicePath 是给系统定位设备用的物理路径里面带有 vid、pid、mi 接口号和序列号不同物理设备一定不同。CComPtrICreateDevEnum pDevEnum; hr CoCreateInstance(CLSID_SystemDeviceEnum, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(pDevEnum)); CComPtrIEnumMoniker pEnum; hr pDevEnum-CreateClassEnumerator(CLSID_VideoInputDeviceCategory, pEnum, 0); // 注意没有摄像头时返回的不是 S_OK 而是 S_FALSE直接返回空列表即可 if (hr ! S_OK) { return; } std::setstd::wstring seen; IMoniker* pMoniker nullptr; while (pEnum-Next(1, pMoniker, nullptr) S_OK) { CComPtrIPropertyBag pBag; pMoniker-BindToStorage(0, 0, IID_PPV_ARGS(pBag)); VARIANT varName, varPath; VariantInit(varName); VariantInit(varPath); pBag-Read(LFriendlyName, varName, nullptr); pBag-Read(LDevicePath, varPath, nullptr); std::wstring friendly varName.bstrVal ? varName.bstrVal : L; std::wstring path varPath.bstrVal ? varPath.bstrVal : L; // 同型号摄像头 FriendlyName 会重复但 DevicePath 里 vid/pid/serial 是唯一的 if (seen.find(path) ! seen.end()) { pMoniker-Release(); continue; } seen.insert(path); // 这里的 friendly 显示给用户path 存配置不要再拿下标当设备标识 pMoniker-Release(); VariantClear(varName); VariantClear(varPath); }这段代码有几个参数需要解释。CreateClassEnumerator 的第三个参数必须是 0这是 DirectShow 的历史遗留规定传其他值会导致枚举不到设备Read 方法的第三个参数是 LCID传 nullptr 用默认即可去重逻辑用 std::set 来容错因为某些驱动会把同一物理设备报到两次。还有一个细节是返回 S_FALSE 并不代表失败它只是表示没有设备枚举出来很多新手在这里用 SUCCEEDED 判断然后拿空指针去遍历白屏一上午。实际的 DevicePath 长这样\\?\usb#vid_0c45pid_62c0mi_00#62a6bcb1e00000#{65e8773d-...}前面 vid 和 pid 是厂商和设备号mi_00 是这个设备下的接口号中间那串 62a6bcb1e 是端口路径。同一台复合设备可能有 mi_00 和 mi_01 两个接口一个是采集、一个是其他功能这也是在多摄像头场景里需要用完整 DevicePath 做去重的原因。这一节做完你手里就有一份“每个摄像头的永久身份证”清单它是后续所有多路区分的基石。4. 多摄像头采集与回调区分一路 Graph 一路回调对象4.1 打开一路摄像头的标准接线拿到 Moniker 之后接下来就是把它变成一个能出帧的 Graph。这里的套路是固定的我直接给出一路摄像头的完整打开流程多路就重复多次。bool OpenCamera(IMoniker* pMoniker, CComPtrISampleGrabber pGrabber, CComPtrIGraphBuilder pGraph, CComPtrIMediaControl pControl) { HRESULT hr CoCreateInstance(CLSID_FilterGraph, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(pGraph)); FAIL_RETURN(hr); pGraph-QueryInterface(IID_PPV_ARGS(pControl)); // 1. 把 Moniker 绑定成 Source Filter CComPtrIBaseFilter pSrc; hr pMoniker-BindToObject(0, 0, IID_IBaseFilter, (void**)pSrc); FAIL_RETURN(hr); pGraph-AddFilter(pSrc, LSource); // 2. 创建 SampleGrabber并锁死媒体类型 hr CoCreateInstance(CLSID_SampleGrabber, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(pGrabber)); FAIL_RETURN(hr); AM_MEDIA_TYPE mt {}; mt.majortype MEDIATYPE_Video; mt.subtype MEDIASUBTYPE_RGB24; // 强制 RGB24后面回调直接按位图处理 pGrabber-SetMediaType(mt); pGraph-AddFilter(pGrabber, LGrabber); // 3. 创建 Null Renderer让 Graph 有出口 CComPtrIBaseFilter pNull; CoCreateInstance(CLSID_NullRenderer, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(pNull)); pGraph-AddFilter(pNull, LNullRenderer); // 4. 手工找 Pin 并连接 CComPtrIPin pOutPin; pSrc-FindPin(LCapture, pOutPin); CComPtrIPin pGrabInPin; pGrabber-FindPin(LInput, pGrabInPin); hr pGraph-Connect(pOutPin, pGrabInPin); FAIL_RETURN(hr); CComPtrIPin pGrabOutPin; pGrabber-FindPin(LOutput, pGrabOutPin); CComPtrIPin pNullInPin; pNull-FindPin(LInput, pNullInPin); hr pGraph-Connect(pGrabOutPin, pNullInPin); FAIL_RETURN(hr); // 5. 跑起来 pControl-Run(); return true; }这里的几个参数要重点说。BindToObject 用 IID_IBaseFilter 拿到的就是设备对应的 Filter 实例这是整个链路的数据源头AM_MEDIA_TYPE 里的 subtype 建议固定用 MEDIASUBTYPE_RGB24虽然设备原生输出可能是 MJPG 或 YUY2但 DirectShow 会自动在中间插入颜色空间转换器让回调永远拿到 24 位 RGBFindPin 的引脚名 “Capture” 和 “Input” 是 DirectShow 标准命名个别驱动会叫别的名字如果 Connect 返回 VFW_E_CANNOT_CONNECT可以用 IEnumPins 遍历打印所有引脚名来排查。整套接线我一般封装成一个 CaptureDevice 类内部持有 pGraph、pGrabber、pControl 三个 COM 指针。多路摄像头就实例化多个 CaptureDevice每路独立 Graph、独立 SampleGrabber。不要试图把所有 Filter 塞进同一个 Graph省事但会出现帧错乱和设备互锁这个在后面避坑章详细说。4.2 回调里怎么区分是哪一路摄像头多路摄像头同时采集最头疼的是回调里不知道当前这帧是从哪台设备来的。这个问题在 C# 的 DirectShow 封装里也一样存在核心解决办法是给每路回调对象绑定一个设备上下文而不是在全局变量里猜。class FrameCallback : public ISampleGrabberCB { public: long cameraIndex; // 这里可以继续扩分辨率、设备路径、帧计数 HRESULT STDMETHODCALLTYPE BufferCB(double SampleTime, BYTE* pBuffer, long BufferLen) override { // 这个回调跑在 streaming thread不是 UI 线程 // 用 cameraIndex 就能确定当前帧来自哪一路 // 处理前先拷贝或者投递到业务线程千万别在这里做耗时操作 return S_OK; } HRESULT STDMETHODCALLTYPE SampleCB(double SampleTime, IMediaSample* pSample) override { return E_NOTIMPL; } // IUnknown 三件套QueryInterface / AddRef / Release // 用 CComObjectFrameCallback 实例化时AddRef/Release 实现为空即可 ULONG STDMETHODCALLTYPE AddRef() override { return 1; } ULONG STDMETHODCALLTYPE Release() override { return 1; } HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override { if (riid IID_ISampleGrabberCB || riid IID_IUnknown) { *ppv this; return S_OK; } *ppv nullptr; return E_NOINTERFACE; } };使用时的关键动作是打开第 N 路摄像头时单独 new 一个 FrameCallback 实例并设置 cameraIndex N然后把这个实例通过pGrabber-SetCallback(cb, 0)挂到对应的 SampleGrabber 上。这样每个回调对象天然知道自己属于哪一路完全不需要额外的映射表。第二个参数 0 表示只在 BufferCB 里拿数据如果传 1 则会走 SampleCBSampleCB 拿到的 IMediaSample 里面还有时间戳和格式信息按需选择。从这节开始所有的帧分发逻辑都建立在“回调对象 设备”这个等式上。UI 层要显示哪一路就给它一个带着对应 cameraIndex 的回调算法模块要处理哪一路也只要认 cameraIndex。C 这边用对象持有设备上下文C# 的 DirectShow 封装一般是闭包或者继承回调类本质是一个道理都是让回调能反向定位设备身份。5. 四个必踩的坑qedit.h 缺失、媒体类型黑屏、跨线程闪退、枚举顺序变5.1 qedit.h 在 64 位编译环境直接缺失现象是编译时报fatal error C1083: Cannot open include file: qedit.h: No such file or directory整个工程瞬间红了一片。原因是 qedit.h 只在旧版 Windows SDK 里提供VS2012 以后的新 SDK 不再生成这个文件但 DirectShow 的 SampleGrabber 接口定义却还在里面。Qt 配合 MSVC 编译时头文件搜索路径默认指向新版 SDK自然就找不到了。解决方法是绕过 qedit.h。你其实不需要这个头的全部内容只需要手写 SampleGrabber 相关的接口定义或者直接把 qedit.h 从旧 SDK 目录里拷进工程。我一般会选择后者因为更省事把旧文件放进 3rdparty 目录然后 .pro 里加 INCLUDEPATH。另一个可行方案是改用 Media Foundation但那个改动面太大为了一个头文件而重写采集层不划算。5.2 RGB24 媒体类型没锁定回调出来是 YUV 花屏现象是回调里能拿到数据但拼出来的 QImage 颜色完全不对偏绿、偏暗、有的地方还有条纹典型的 YUV 数据被当成 RGB 使用的结果。原因是不管你有没有调用 SetMediaTypeSampleGrabber 都有可能直接接受源 Filter 的默认输出类型而 UVC 设备默认输出往往是 MJPG 或 YUY2。SDL 跟 YUY2 的排列完全不一样按 RGB 去解析当然花屏。解决是在 SetMediaType 之后等 Graph 跑通了立刻再调一次 GetConnectedMediaType打印实际连接的类型。不要用默认值锁死 RGB24然后校验 subtype 是否为 MEDIASUBTYPE_RGB24不是的话计算一下连接的 resolution 和 stride 再做转换。这个校验动作我建议放进工程里的调试窗口输出每次接入新摄像头先看这个值能少走很多弯路。5.3 MJPEG 帧不能当位图直接用现象是预览窗口显示正常但只要你在回调里尝试保存截图或做逐帧分析保存下来的图片就全黑或者撕裂。原因是设备输出的实际媒体类型是 MJPEG预览窗口正常是因为播放器内部自带了 MJPG Decoder但 SampleGrabber 截获到的是压缩后的 JPEG 帧它不是位图按 RGB 内存布局去解析自然拿不到有效图像数据甚至连 YUY2 都不是。解决方法是提前在 Graph 里插入 MJPG Decompressor或者让 Source Filter 输出动态切换到 YUY2 模式。常见做法是在连接的 Source Filter 上调用 IAMStreamConfig 的 SetFormat把视频格式改成 YUY2分辨率按你实际需要的值。MJPEG 的优势是带宽小适合传输但要做本地分析、截图、推流建议直接放弃它用 YUY2 或 RGB24 换取回调数据的可读性。这个取舍要在采集前定好不然到了回调里面再想转格式性能会很亏。5.4 在回调线程里操作 QWidget 闪退现象是程序不定时闪退有时候跑几分钟崩一次有时候一打开就崩用调试器抓不到规律。原因是 SampleGrabber 的 BufferCB 是在 streaming thread 里被调用的跟 Qt 的 UI 线程完全不搭边。你在回调里直接给 QLabel 设置图片、更新进度条、甚至访问任何 QWidget都是跨线程操作轻则偶发崩溃重则一启动就段错误。DirectShow 不会帮你做任何线程亲和性处理。解决方法是跨线程投递把帧数据拷贝一份通过 QMetaObject::invokeMethod 或 Qt 信号槽投递到 UI 线程。我这里更推荐信号槽因为 Qt 的 QueuedConnection 本身就是跨线程安全的。// 回调里不要碰任何 UI 对象只发信号 QMetaObject::invokeMethod(this, frameReady, Qt::QueuedConnection, Q_ARG(int, m_cameraIndex), Q_ARG(QByteArray, QByteArray((const char*)pBuffer, BufferLen)));m_cameraIndex 要作为参数一起传UI 侧根据这个值决定刷新哪一个显示控件。血泪经验是回调里绝对不要做耗时操作Best 是只做内存拷贝剩下的交给另一个线程。QImage 的创建、缩放、贴图全部放到 UI 线程里做。如果你把这段代码放在 UI 线程来访问,大概率还是闪退因为回调的线程环境不受你控制。5.5 同型号摄像头拔插后枚举顺序互换现象是设备 1 和设备 2 是同型号程序刚启动时识别正常但只要拔掉其中一个重新插上程序里对应的采集通道就互换了用户那边看到的画面错位。原因是枚举顺序由系统驱动分配不保证跟物理插口顺序一致也不保证拔插后保持。FriendlyName 完全相同你靠下标去区分设备本质是在赌枚举顺序不变化这个前提在 Windows 上根本不成立。解决方法是布局阶段彻底弃用下标改用 DevicePath 作为设备的持久化 key。在程序第一次运行时把每个物理摄像头的 DevicePath 和用户定义的逻辑通道映射关系存到配置文件里每次启动先枚举拿着 DevicePath 去查配置匹配通道。这样拔插顺序随便变程序都能正确对号入座。具体实现时只要在枚举代码里把 DevicePath 打印出来存下来后面打开时用匹配到的 Moniker 操作即可。从那以后我再也不相信枚举顺序这个玄学问题直接用配置表杀死了。6. 进阶UVC 参数控制与多摄像头验证清单6.1 IAMVideoProcAmp 控制亮度、曝光、聚焦多摄像头里经常需要统一画面亮度直接改摄像头寄存器比后期做图像处理更省事。UVC 标准提供了 IAMVideoProcAmp 接口来控制亮度、对比度、饱和度IAMCameraControl 控制对焦、曝光、缩放。注意这两个接口都是从 Source Filter 上 QueryInterface 出来的不是在 SampleGrabber 上。CComQIPtrIAMVideoProcAmp pProc(pSrcFilter); long minVal 0, maxVal 0, stepVal 0, defaultVal 0, flags 0; pProc-GetRange(VideoProcAmp_Brightness, minVal, maxVal, stepVal, defaultVal, flags); // 把亮度设成范围的中上值比如 70% long target static_castlong((maxVal - minVal) * 0.7 minVal); pProc-Set(VideoProcAmp_Brightness, target, VideoProcAmp_Flags_Manual);反射到多个摄像头就是循环 Set。重点是 flags 参数VideoProcAmp_Flags_Manual 表示手动控制如果设备支持自动要先用 VideoProcAmp_Flags_Auto 关掉自动再切手动。有些摄像头对 flags 的响应比较迟钝设置后需要重新 Run 一下 Graph 才生效我会在 Set 之后再调一次 pControl-Pause 再 Run确保参数刷进驱动。对焦控制走 IAMCameraControlCameraControl_Focus 的取值区间在 0 到 100 附近设备不同范围也不同依旧先 GetRange 再 Set。曾经遇到一个摄像头 Set 对焦后白平衡跟着乱无解最后在配置文件里做成设备私有的参数映射每台设备一套值不做统一的预设。6.2 多摄像头稳定性验证清单换了新摄像头型号或者改了采集线程逻辑之后我会强制走一遍下面的验证表防止上线后出前缀问题。每个验证项都有一个明确的通过标准不达标的直接打回。验证项操作通过标准设备唯一性拔插其中一路重启程序DevicePath 的 vid/pid/序列号不变多路并发稳定同时开启全部分辨率档位连续运行 2 小时无掉帧、无人机交互卡顿回调线程安全回调里只拷贝不强转 UI无 QWidget 相关崩溃、无跨线程警告参数控制持久化用配置表恢复各通道曝光/亮度重启后各通道画面亮度一致我后来接手的第一个多摄像头项目正是因为没在回调里区分设备两路画面互相串成了一锅粥。现在每个采集线程里强制走一遍完整流程先打印 DevicePath再锁 RGB24 媒体类型再单独 new 回调对象并绑定索引最后再跑参数验证表。这个流程跑完项目不会有大翻车希望对你能有帮助。本文还有配套的精品资源点击获取
返回列表