
简介面向Windows10下使用VS2015与Qt进行视频监控开发的程序员这套资源针对多路USB摄像头并发采集、画面显示与记录存储的常见需求提供了一套可直接运行的工程示例。方案通过Qt界面实时呈现多路摄像头画面并基于DirectShow完成视频编码与保存特别设计了每隔30秒自动分段存储的机制避免因意外中断或程序崩溃导致整段录像损坏对长时间无人值守的监控场景十分实用。代码包含设备枚举、多画面布局、录制控制等关键模块便于按需裁剪集成。资源包共53个文件主要包含cpp/h源代码、ui界面文件、qrc资源以及sln/vcxproj/pro工程配置并附有exe可执行程序、pdb调试符号、obj中间文件和tlog构建日志可直接打开sln运行体验或对照工程结构进行二次开发。包体为20.41MB目前已有1588人学习下载适合希望在视频监控项目中快速落地多摄像头方案或深入研究DirectShow与Qt集成的中高级开发者。 老早之前接到一个需求Win10 环境下用 VS2015 Qt 搭一个界面通过 DirectShow 同时打开多路 USB 摄像头并且要能把画面录制下来。当时我第一反应是——都什么年代了还有人用 DirectShow 这种老古董可真把环境搭起来、让整个框架稳定跑完我才发现这套组合并没有想象中那么过时反而在工业视觉、教学录播、门店监控这类场景里它依然是最不容易踩坑的 Windows 本地采集方案。尤其是面对不同品牌的 UVC 摄像头、多路并发、格式可控这几件事DirectShow 的成熟度和稳定性还是值得拿来说一说的。这篇文章我直接把整个实现链路拆开讲从环境匹配、设备枚举、画面抓取、多路并发架构到最后的 AVI 录制和打包发布每一段我都会给出能落地的代码思路和实际踩过的坑适合用 Qt 做桌面采集工具、又不想被 Media Foundation 搞疯的开发者参考。1. 为什么 DirectShow 这套“老组合”反而省心很多朋友一听到“DirectShow”就皱眉觉得这是 2000 年左右的技术早该淘汰了。实际接触下来我的感受是它不是最好的但它是兼容性最稳的。尤其你手上有几个杂牌 USB 摄像头、厂家驱动又常年不更新的时候DirectShow 几乎是唯一能让它们乖乖出流的公共通道。对比 Media FoundationMF 在 Win8/Win10 上是微软主推的新框架但它对老设备的 UVC 协议实现要求比较高很多杂牌摄像头驱动只做了 DirectShow 那套接口用 MF 打开要么枚举不到要么出流后帧率异常。MF 的上手成本也明显更高回调线程、会话管理、拓扑结构一堆概念小项目不值得。对比 OpenCVOpenCV 的VideoCapture在单路上确实好用但多路并发时经常遇到“第二路打不开”或者“打开后自动改分辨率”的怪问题。录制方面 OpenCV 的 VideoWriter 封装也比较弱对格式和容器控制不灵活遇到需要自定义时间戳、多路同时录的场景就很憋屈。对比 V4L2 / 其它跨平台库在 Linux 上 V4L2 是正统但 Windows 上跨平台库往往在底层还是绕回 DirectShow 或 Media Foundation中间多一层转换就多一层丢帧风险。所以如果你的目标是快速做一套稳定、可控、能在 Windows 本地跑的摄像头工具那么 Qt 做界面、DirectShow 做采集、VS2015 负责编译这个组合非常合理。这套方案的关键价值在于DirectShow 把“设备枚举、格式协商、数据抓取、文件复用”全部封装成了 COM 组件我们开发者只需要像搭积木一样把 Filter 连起来剩下的交给系统去调度省心且可靠。2. 环境准备VS2015、Qt 与 DirectShow 的匹配关系2.1 Qt 版本选择别用 MinGW 编译包VS2015 对应的工具集是 v140也就是 MSVC 14.0。所以你在下载 Qt 时一定要选msvc2015或msvc2015_64的安装包千万别手滑下成 MinGW 版本。我用的组合是Qt 5.12.xmsvc2015_64这套组合在 VS2015 里直接能被识别Creator 和 Visual Studio 的 Qt 插件都能正常叠加。Qt 5.12 之后的版本对 VS2015 的官方预编译包支持逐渐变少如果你非要用更新版 Qt大概率得自己用源码编译实在没必要。Qt 5.12.9 msvc2015_64是现阶段 Win10 VS2015 下的稳妥选择。2.2 DirectShow 开发库与头文件DirectShow 本身不需要单独安装系统自带quartz.dll、strmiids.lib、ole32.lib这些核心库。在 VS2015 里你只需要做两件事在项目属性 - VC 目录 - 库目录里确认包含标准 SDK 的 lib 路径在代码里#pragma comment(lib, strmiids.lib)、#pragma comment(lib, ole32.lib)、#pragma comment(lib, quartz.lib)或者直接在链接器输入里写。这里有一个比较容易翻车的点新版 Windows SDK 里可能找不到qedit.h。ISampleGrabber接口的声明就定义在这个头文件里但 Win10 SDK 因为某些历史原因对 DirectShow Editing Services 的支持有所弱化。解决办法是把你机器上旧版 SDK比如 Windows 7 SDK里的qedit.h拷贝到工程目录或者在项目中直接下载一份随源码分发。提示qedit.dll这个运行库系统是自带的缺的只是声明文件不用慌。2.3 别忘了初始化 COM 环境DirectShow 的 API 大量依赖 COM所以应用启动时每个使用 DirectShow 的线程都需要调用CoInitializeEx(nullptr, COINIT_MULTITHREADED);对应线程退出时调用CoUninitialize()。这里建议用 RAII 封装或者至少确认每个工作线程各自初始化因为 COM 初始化是不能跨线程共享的。很多朋友在 Debug 下跑得好好的Release 下频繁崩溃多半就是 COM 初始化遗漏或者初始化时机不对。3. 摄像头设备枚举把系统手里的“设备名单”拿过来在打开任何摄像头之前第一步是枚举系统里所有的视频输入设备。DirectShow 提供了一个System Device Enumerator可以遍历CLSID_VideoInputDeviceCategory下的所有设备 Moniker。核心代码长这样#include dshow.h #include strmif.h #pragma comment(lib, strmiids.lib) #pragma comment(lib, ole32.lib) #pragma comment(lib, quartz.lib) void EnumCameras(std::vectorCameraInfo cams) { HRESULT hr CoInitializeEx(nullptr, COINIT_MULTITHREADED); if (FAILED(hr)) return; ICreateDevEnum* pDevEnum nullptr; hr CoCreateInstance(CLSID_SystemDeviceEnum, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(pDevEnum)); if (SUCCEEDED(hr)) { IEnumMoniker* pEnum nullptr; hr pDevEnum-CreateClassEnumerator(CLSID_VideoInputDeviceCategory, pEnum, 0); // 注意没有设备时返回 S_FALSE此时 pEnum 为 nullptr if (hr S_OK) { IMoniker* pMoniker nullptr; while (pEnum-Next(1, pMoniker, nullptr) S_OK) { IPropertyBag* pPropBag nullptr; hr pMoniker-BindToStorage(0, 0, IID_PPV_ARGS(pPropBag)); if (SUCCEEDED(hr)) { VARIANT varName, varPath; VariantInit(varName); VariantInit(varPath); pPropBag-Read(LFriendlyName, varName, 0); pPropBag-Read(LDevicePath, varPath, 0); // 保存设备名和设备路径到自定义结构体 cams.push_back({varName.bstrVal, varPath.bstrVal}); VariantClear(varName); VariantClear(varPath); pPropBag-Release(); } pMoniker-Release(); } pEnum-Release(); } pDevEnum-Release(); } CoUninitialize(); }这里要强调的一点FriendlyName是设备在系统里显示的友好名称比如“USB Camera”或“Integrated Camera”。但如果你插了两台一模一样的摄像头FriendlyName会完全一样界面上用户根本分不清。这时候你需要读DevicePath这是设备的实例路径能唯一定位物理设备。实测发现个别老设备驱动不一定返回 DevicePath稳妥做法是优先用 DevicePath 做 key读不到时退化为 FriendlyName 枚举序号的组合。枚举本身不会锁定设备所以你可以随时刷新列表、检测热插拔不影响其他程序使用摄像头。4. 单路画面采集Filter Graph 构建与 Sample Grabber 抓帧4.1 为什么要用 Sample Grabber而不是直接渲染到窗口DirectShow 最直接的播放方式是构建一条“Source Filter - Video Renderer”的链把画面直接渲染到窗口句柄上。但这么做有两个问题画面被渲染链路接管我们拿不到原始像素数据也就没法做分析、叠加文字、事后录制多路摄像头时多个 Video Renderer 窗口管理和事件回调会乱成一锅粥。更通用做法是接入Sample Grabber它在链路中充当“截胡者”会让数据流过它同时通过回调接口把每一帧的原数据交给你然后数据继续往下游走。下游用Null Renderer把帧丢弃因为我们自己已经拿到数据不需要重复渲染。4.2 构建一条完整的抓帧链路构建 Filter Graph 的典型步骤是创建IGraphBuilder和ICaptureGraphBuilder2把摄像头 Moniker 绑定成IBaseFilter加入 Graph创建Sample Grabber和Null Renderer加入 Graph调用CaptureGraphBuilder2::RenderStream把 source filter 的输出引脚连接到 Sample Grabber再连接到 Null Renderer。IGraphBuilder* pGraph nullptr; ICaptureGraphBuilder2* pBuilder nullptr; CoCreateInstance(CLSID_FilterGraph, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(pGraph)); CoCreateInstance(CLSID_CaptureGraphBuilder2, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(pBuilder)); pBuilder-SetFiltergraph(pGraph); // 添加摄像头 Filter IBaseFilter* pCameraFilter nullptr; pMoniker-BindToObject(nullptr, nullptr, IID_PPV_ARGS(pCameraFilter)); pGraph-AddFilter(pCameraFilter, LCameraSource); // 添加 Sample Grabber IBaseFilter* pGrabberFilter nullptr; ISampleGrabber* pGrabber nullptr; CoCreateInstance(CLSID_SampleGrabber, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(pGrabberFilter)); pGraph-AddFilter(pGrabberFilter, LSampleGrabber); pGrabberFilter-QueryInterface(IID_PPV_ARGS(pGrabber)); // 协商媒体类型为 RGB24 AM_MEDIA_TYPE mt {}; mt.majortype MEDIATYPE_Video; mt.subtype MEDIASUBTYPE_RGB24; pGrabber-SetMediaType(mt); // 添加 Null Renderer IBaseFilter* pNullRenderer nullptr; CoCreateInstance(CLSID_NullRenderer, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(pNullRenderer)); pGraph-AddFilter(pNullRenderer, LNullRenderer); // 连接三个 Filter pBuilder-RenderStream(PIN_CATEGORY_PREVIEW, MEDIATYPE_Video, pCameraFilter, pGrabberFilter, pNullRenderer);这里有个关键点媒体类型协商。摄像头原始输出往往是YUY2或MJPG这类压缩/格式化的数据而QImage显示时最方便的是 RGB24。让 Sample Grabber 去协商 RGB24DirectShow 会在链路里自动插入颜色空间转换器把数据转成 24 位 RGB 再交给你。这比你在代码里手动做 YUV 转 RGB 省太多事了。4.3 在回调里取帧然后搬运到 Qt 界面Sample Grabber 支持两种回调SampleCB和BufferCB。推荐用SampleCB它拿到的是IMediaSample指针内存管理相对安全。实现一个回调类class FrameGrabberCB : public ISampleGrabberCB { public: FrameGrabberCB(FrameQueue* queue) : m_queue(queue) {} STDMETHODIMP_(ULONG) AddRef() { return 1; } STDMETHODIMP_(ULONG) Release() { return 2; } STDMETHODIMP QueryInterface(REFIID riid, void** ppv) { if (riid IID_IUnknown || riid IID_ISampleGrabberCB) { *ppv static_castISampleGrabberCB*(this); return S_OK; } return E_NOINTERFACE; } STDMETHODIMP SampleCB(double SampleTime, IMediaSample* pSample) { BYTE* pBuffer nullptr; pSample-GetPointer(pBuffer); long nLen pSample-GetActualDataLength(); // 这里把 pBuffer 拷贝到自己的环形队列绝不能长时间阻塞 m_queue-Push(pBuffer, nLen); return S_OK; } STDMETHODIMP BufferCB(double SampleTime, BYTE* pBuffer, long BufferLen) { return E_NOTIMPL; } private: FrameQueue* m_queue; };然后通过SetCallback注册它。在 Qt 侧你可以用一个QTimer定时从环形队列里取最新一帧转成QImage刷新到QLabel或QWidget上。这里的铁律是回调线程是 DirectShow 的内部线程绝对不能在里面直接调用 Qt 的 UI 方法也不能做耗时操作。否则轻则掉帧重则直接把 Graph 卡死。我用了个简单粗暴的办法回调里只做memcpyUI 线程用 30ms 的定时器去消费队列实测 CPU 占用和画面流畅度都能接受。5. 多路并发每个摄像头一条独立线程注意设备独占5.1 为什么不能把多个摄像头塞在同一个 Graph 里有的朋友会想系统都支持多路摄像头了那我在一条 Filter Graph 里多加几个 Source Filter 不就行了答案是不行。DirectShow 的 Filter Graph 在设计上是一个“流媒体管道”它内部的时间基准、数据流调度是单线程模型多个视频源共享一条管道必然导致相互等待。实测下来两路摄像头放进同一个 Graph打开时看着没问题一旦跑起来第二路的帧率会被拖到惨不忍睹。正确做法是每个摄像头单独构建一套 Filter Graph跑在独立的工作线程里。每个线程有自己的 COM 初始化、自己的 CaptureGraphBuilder2、自己的 Sample Grabber相互之间只有“是否在录制”这类布尔状态共享不共享跨线程的 DirectShow 对象。5.2 设备被占用别让它默默失败多路并发时最常见的坑是设备被系统或其他进程占用。如果你先打开了一个摄像头再次尝试用同一个设备构建 GraphRenderStream会返回类似VFW_E_UNSUPPORTED_STREAM或E_FAIL的错误码。很多实现里这个错误会被当成“打开摄像头失败”但用户根本不知道为什么失败。我的建议是在界面上把“设备状态”做成动态更新的枚举模型空闲 / 占用 / 打开中 / 出流中 / 掉线。每次RenderStream失败后不要急着销毁 Graph先尝试用IFilterGraph2::AddSourceFilterForMoniker的方式做一次轻量探测把“设备被占用”和“设备已拔出”区分开。这能让你在排查多路并发问题时少掉一半头发。5.3 USB 带宽是隐形瓶颈多路 USB 摄像头同时工作CPU 不一定吃紧但USB 控制器的总带宽往往是限制因素。USB 2.0 的理论带宽是 480 Mbps实际可用大约只有 200~250 Mbps。一路 720P 30fps 的 YUY2 原始流就要占掉约 221 Mbps。也就是说如果你用的是 USB 2.0 口/集线器同时开 2 路 720P 就已经接近极限了。所以多路采集架构里分辨率、帧率和颜色格式之间的平衡要提前设计好。比如四路录制时把每路降到 640x48015fps 或使用 MJPG 压缩流会比强行跑四路 1080P 稳定得多。这些限制要在 UI 上提前告知用户比如按当前接入设备自动推荐“最大稳定解析度”别等画面卡成 PPT 了才被用户吐槽。5.4 掉线自动重连这个细节值钱USB 摄像头偶尔会被系统休眠唤醒、插拔、驱动重启等原因搞得掉线。掉线时 Graph 会收到EC_DEVICE_LOST事件。处理策略我推荐“两步走”收到设备丢失事件后先停止 Graph释放该设备相关的所有 DirectShow 对象启动一个 2~3 秒的重连定时器重新枚举设备、重新构建 Graph期间保留预览界面显示“正在重连”占位图。自动重连的频率不要太高否则多路同时掉线时重连风暴会让 USB 总线雪上加霜。实测下来错开重连时间每路延后 500ms能有效提升同时恢复成功率。6. 录制到 AVI编码器选型、时间戳与性能平衡6.1 用 Avi Mux File Writer 输出 AVI录制有多种方案Sample Grabber 回调里自己拼 AVI 文件、用第三方库写 MP4、或者用 DirectShow 的Avi MuxFilter 自动封装。我的建议是用Avi Mux File Writer因为它能自动维护 AVI 索引、时间戳你只需要负责接入数据封装细节完全交给系统。实现大致思路IBaseFilter* pAviMux nullptr; IBaseFilter* pFileWriter nullptr; IFileSinkFilter* pSink nullptr; // 创建 Avi Mux 并加入 Graph CoCreateInstance(CLSID_AviMux, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(pAviMux)); pGraph-AddFilter(pAviMux, LAviMux); // 创建 File Writer 并设置输出文件 CoCreateInstance(CLSID_FileWriter, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(pFileWriter)); pGraph-AddFilter(pFileWriter, LFileWriter); pFileWriter-QueryInterface(IID_PPV_ARGS(pSink)); pSink-SetFileName(Lcapture.avi, nullptr); // 从 Capture 引脚接到 AviMux pBuilder-RenderStream(PIN_CATEGORY_CAPTURE, MEDIATYPE_Video, pCameraFilter, pAviMux, pFileWriter);注意这里用的是PIN_CATEGORY_CAPTURE不是PIN_CATEGORY_PREVIEW。因为 Capture 引脚是高质量的数据通道适合进录制链路Preview 引脚往往经过中间处理数据量更大而且可能被 Sample Grabber 截走过。6.2 编码器怎么选无压缩太肥压缩太慢也不是好事AVI 封装只是容器真正的编码器需要选择。常见选择有三种编码方式文件大小CPU 占用适用场景无压缩 RGB24巨大1080P30 约 178MB/s极低短时录制、后期分析、需要逐帧无损Microsoft Video 1中等有损低测试性录制兼容性好x264vfwH.264小较高长时间录制、存档、网络传输如果项目对画面细节要求不高我推荐用x264vfw这类 H.264 VFW 编码器它可以在 AVI 容器里输出 H.264 视频兼容性不错文件体积能压缩到无压缩的几十分之一。但要注意Avi Mux 对编码器支持有问题时会出现打开文件后无法 seek 的情况基本和编码器的dwInterleaveEvery设置有关。真要追求稳低分辨率短录制直接无压缩长录制建议单独研究 mp4 封装换用 Media Foundation 或 FFmpeg。6.3 回调里千万别做磁盘 IO录制时最忌讳的做法是在SampleCB回调里直接写文件。因为回调线程是时间敏感的磁盘 IO 一旦抖动直接导致掉帧或者 Graph 内部超时。正确做法是回调里把帧数据推入一个有界队列录制线程从队列取数据写入编码器或文件队列满时丢掉最旧帧并记录 DropFrame 计数在 UI 上显示掉帧率。这个处理能保证录制时的预览依然流畅而且实测在低配机器上录制线程偶发卡顿不会连带影响其他摄像头的采集。6.4 时间戳和帧率录制默认值怎么设Avi Mux 依赖媒体样本上的时间戳来生成 AVI 的时间索引导航。如果你的样本来自 Sample Grabber 回调SampleTime是以 100 纳秒为单位的DirectShow 会帮你转换。但在 UI 层设置录制参数时我建议显式指定期望帧率比如 25fps 或 30fps并且采集帧率要高于录制帧率否则容易出现“视频时长比实际时间短”的问题。为了保证稳定性可以考虑在录制时把抓帧分辨率固定为640x48025fps这不光是为了文件体积更是为了规避各种杂牌摄像头在高帧率下的漂移现象。7. 打包发布Qt 插件缺失、依赖 DLL 和其它高频坑7.1 windeployqt 之后依然报 “no qt platform plugin could be initialized”这个报错几乎 80% 的 Qt 新手都会遇到。原因很简单发布目录下缺少platforms/qwindows.dll或者路径没被找到。解决办法是打开 Qt 的命令行工具进入编译输出目录执行windeployqt your_app.exe它会自动把 Qt 的依赖库、插件目录、翻译文件全部拷贝到 exe 旁边。打完包后确认platforms\qwindows.dll存在。如果还有问题多半是 system 环境变量里没有QT_QPA_PLATFORM_PLUGIN_PATH——一般来说只要platforms文件夹和 exe 同级就不会出问题。7.2 别忘了 qedit.dll 和 strmiids.lib 的运行时依赖很多程序在本机跑得好好的换台干净的机器就开不了摄像头大概率是缺 DirectShow 相关运行库。好消息是quartz.dll、qedit.dll在 Windows 10 上都是系统组件不需要额外安装。但如果你引用了第三方编码器比如 x264vfw就要在安装包里带上它的安装器或者在代码里检查系统是否已注册相应编码器。7.3 x86/x64 混淆是“摄像头打不开”的隐藏元凶VS2015 默认的解决方案平台是 Win32x86如果你 Qt 装的是msvc2015_64版本而项目配置管理器里还在用 Win32 编译运行时要么报 “无法加载 Qt5Cored.dll”要么 DirectShow 返回莫名其妙的错误。确认三件事项目平台 Qt 库平台 编译目标平台。64 位系统建议统一用 x64省得后面内存映射、大文件录制时再踩坑。7.4 摄像头被系统“相机”App 或微信/钉钉占用这个问题特别隐蔽尤其是第一次装好系统后用户可能开过自带的“相机”App或者浏览器、会议软件会常驻占用摄像头。DirectShow 打开设备失败时的报错信息通常不是“设备被占用”而是一个笼统的HRESULT错误码。建议在 UI 上增加“设备占用检测”功能打开失败时明确提示用户关闭相机类软件再试。我在实际项目里还被微信通话静默占用过摄像头排查了半个多小时才反应过来。另外发布时记得把strmiids.lib、quartz.lib这些静态库链接方式设置成/MT或/MD与 Qt 主程序一致否则会出现运行时 CRT 冲突倒不至于打不开摄像头但偶尔会在退出时崩一两次。结尾一点个人经验这套 DirectShow Qt 方案在我手头已经扛过了三个项目包括四路 USB 摄像头同时录制、工业检测抓拍、以及实验过程录像回放。回头看最核心的稳定秘诀其实就三条别在回调线程里做耗时操作、每个摄像头独立线程独立 Graph、对设备占用/掉线情况做好用户可感知的反馈。下次有人跟你讨论“DirectShow 是不是过时了”你可以直接拿这套可跑通的架构回他老归老但它能让你把精力花在业务逻辑上而不是天天跟驱动底层吵架。如果后续要扩展可以往 FFmpeg 推流、或者用 Media Foundation 录制 MP4 的方向演进但先把 DirectShow 这套座的架构打扎实你会受益很久。本文还有配套的精品资源点击获取