DirectShow+Qt5实现Windows摄像头拍照录像:源码设计与避坑指南
简介基于Windows平台的DirectShow与Qt5构建的C工程完整呈现了一套摄像头拍照录像软件实现方案面向具备C基础的多媒体开发者、高校学生及需要快速落地桌面视频应用的项目团队可满足实时预览、画面抓拍、录像保存等常见需求。资源压缩包约11MB共包含415个文件其中155个头文件用于声明类与接口140个C源文件和8个C源文件负责采集、界面与核心逻辑另有45个文本文件提供说明文档并辅以工程配置、图标等静态资源目录划分清晰便于按模块检索与二次开发。已有402人学习下载源码覆盖视频设备管理、拍照录像流程、窗口交互、日志记录、图像格式转换等关键环节并可通过头文件与源文件对照梳理具体调用链既可直接编译为可用的摄像头工具也可作为研究DirectShow流媒体采集机制与Qt5界面集成方式的参考在教育、安防、个人录制等场景中均有迁移复用价值。1. 基于 DirectShow 和 Qt5 的 Windows 摄像头拍照录像软件设计源码这个组合为什么还有必要自己写如果你接过“写一个 Windows 摄像头拍照录像工具”的需求大概率遇到过两个坑用 Qt Multimedia 自带的 QCamera 在部分 USB 摄像头和采集卡上要么打不开要么分辨率和帧率不受控而直接套 OpenCV 的 VideoCapture确实能跑但想精确控制曝光、帧率、设备热插拔和录像封装时又被它的高层封装卡住。这套“DirectShow 做采集、Qt5 做界面”的设计源码思路正好把两头都接住底层滤镜图交给你控制上层界面用 QWidget/QImage 随便折腾适合课程设计、产线工具和视频分析客户端的底子。它不是什么新东西但直到今天Windows 下做摄像头开发它依然是绕不开的一套骨架。2. 用 DirectShow 拿帧、用 Qt5 画界面核心链路与选型理由2.1 DirectShow 的最小采集链路Source Filter 到 Sample Grabber 再到 Null RendererDirectShow 的逻辑是一个滤镜图Filter Graph数据从上游滤镜流向下游。做摄像头采集时最简链路是摄像头 Source Filter - Sample Grabber抓帧- Null Renderer丢弃数据只是让数据流走完关键点在于 Sample Grabber 的用途它自己不处理数据只是在数据流过时触发你的回调让你把一帧拷贝出来。所以它必须插在输出端之前而且要配置成“抓 RGB24”否则回调里拿到的直接是 YUY2你还得自己做色彩转换。我一般会把整张图交给 IGraphBuilder 去自动连接但 Sample Grabber 必须手动指定媒体类型再 Connect。自动连接虽然方便但在摄像头分辨率、子类型不匹配时经常给你连出一条 YUY2 链路后面处理更麻烦。手动把 Sample Grabber 的输出端接到 Null Renderer输入端再和摄像头输出 Pin 连接整条链路就稳定了。还有一个细节Sample Grabber 的 SetOneShot 必须设为 FALSESetBufferSamples 建议设为 FALSE。前者是只要一帧就停止后者是让 Sample Grabber 自己缓冲一份数据缓冲那份数据在回调线程里再 memcpy 出来反而容易踩缓冲区释放的坑。2.2 为什么不用 Qt Multimedia / OpenCV VideoCapture选型不是越新越好这里有个实际的选型对比我做过多版摄像头工具评估维度基本是控制粒度、延迟、格式可控性、依赖体积、长期维护成本。方案控制粒度延迟分辨率/帧率控制依赖体积适用场景Qt Multimedia (QCamera)低中等部分支持不统一Qt 自带简单预览、跨平台OpenCV VideoCapture中中等有限驱动相关需引入 OpenCV快速验证、算法项目DirectShow高低完全可控Windows 自带产线工具、多路采集、格式定制QCamera 的问题在于它封装得过于“友好”摄像头的属性页、帧率区间、YUV 格式细则你都碰不到OpenCV 的问题是它内部有一套自己的采集逻辑在部分老的 USB 摄像头上会拿到错误的帧尺寸而且想在回调里同步叠加 UI 信息要做额外的线程桥接。所以我的倾向是如果这个软件要长期维护、要对接多种 USB 摄像头或采集卡DirectShow 是“底子”OpenCV 只做录像编码的辅助工具。这个选型在课程设计和毕业设计里也是常见的“标准答案”因为能体现通信和 COM 编程能力又不会引入太重的依赖。2.3 Qt5 侧怎么接住帧HWND 嵌入与 QImage 绘制两条路DirectShow 拿到画面后有两种方式让它在 Qt5 窗口里显示。第一种是让 DirectShow 自己渲染到窗口句柄也就是把视频渲染器的窗口设为某个 Qt 控件的 HWND。第二种是不用视频渲染器直接把帧拷贝成 QImage再用 QLabel 或自定义 paintEvent 绘制。第一种方式的核心是拿到控件句柄后调用 IVideoWindow::put_Owner 和 put_MessageDrain看起来省事但坑很多DirectShow 的字幕、右键菜单、全屏行为会直接盖在 Qt 控件上Z 序经常乱同时多屏缩放时窗口尺寸同步也麻烦。第二种方式是我更推荐的Null Renderer Sample Grabber 回调 QImage 绘制。虽然多一次内存拷贝但换来的是对画面的完全控制不管是加扫码框、加 FPS 显示、还是做移动侦测叠加都只是往 QImage 上画的事情。做法是回调线程里把数据拷入一个 QImage 缓冲然后用 QMetaObject::invokeMethod 把更新信号投递到 UI 线程的 QLabel::setPixmap避免跨线程操作界面。2.4 媒体类型协商RGB24 与 YUY2 的取舍摄像头输出的原始格式大多是 YUY2 或 MJPG少数支持 RGB24。Sample Grabber 的 SetMediaType 里写 MEDIASUBTYPE_RGB24DirectShow 会在内部插入色彩转换滤镜把 YUY2 转成 RGB24这是“最省心”的做法。代价是转换会占一点 CPU但 720p 以下基本无感。如果对帧率非常敏感或者要跑高分辨率比如 1080p 60fps我一般会直接抓 YUY2自己写一个 20 行左右的 YUY2 转 RGB 函数。这个函数并不复杂只是查表 位运算但能省掉一次内存拷贝和一次滤镜转换。要是摄像头支持 MJPG那就要注意MJPG 是 JPEG 压缩帧Sample Grabber 不会自动帮你解压需要额外接 MJPG Decompressor链路会变成 Source - Decompressor - Sample Grabber千万别直接靠 SetMediaType 里的 RGB24 去转否则报错或者拿到灰屏。3. 从枚举摄像头到保存照片/录像最小可跑骨架的实现步骤3.1 工程骨架与依赖准备Qt Widgets 加 DirectShow 头文件这类源码骨架通常拆成三个文件camera_managerDirectShow 采集与回调、camera_widgetQt5 界面与定时绘制、main启动入口。头文件依赖是 DShow.h 和 Qt5 的 Widgets不需要额外引入 DirectX SDKWindows SDK 自带。工程文件用 CMake 的话注意 Qt5 的路径问题。常见的报错是CMake Error at C:/Qt/qt5.9.4/5.9.4/msvc2017_64/lib/cmake/Qt5/Qt5Config.cmake这是 Qt5_DIR 没有指到对应的 arch 目录导致的跟代码没关系后面避坑章会专门说。依赖声明部分如下#include dshow.h #include QWidget #include QImage #include QTimer #pragma comment(lib, strmiids.lib) #pragma comment(lib, ole32.lib) #pragma comment(lib, uuid.lib)这里的关键是 strmiids.lib它提供 CLSID_FilterGraph、CLSID_SampleGrabber 这些 GUID 定义。漏掉它所有 DirectShow 相关的 CLSID 都是未解析外部符号。3.2 枚举摄像头并构建 Filter Graph核心代码采集的第一步是枚举系统里的摄像头用系统设备枚举器System Device Enumerator按设备类别筛选取回 FriendlyName 和 Moniker。设备名的解析要放在 Char 到 QString 的转换上注意宽字符与窄字符的内存生命周期。QStringList CameraManager::enumDevices() { QStringList names; ICreateDevEnum *pDevEnum nullptr; IEnumMoniker *pEnum nullptr; CoCreateInstance(CLSID_SystemDeviceEnum, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(pDevEnum)); pDevEnum-CreateClassEnumerator(CLSID_VideoInputDeviceCategory, pEnum, 0); IMoniker *pMoniker nullptr; while (pEnum-Next(1, pMoniker, nullptr) S_OK) { IPropertyBag *pBag nullptr; pMoniker-BindToStorage(0, 0, IID_PPV_ARGS(pBag)); VARIANT var; VariantInit(var); pBag-Read(LFriendlyName, var, nullptr); names QString::fromWCharArray(var.bstrVal); VariantClear(var); pBag-Release(); pMoniker-Release(); } pEnum-Release(); pDevEnum-Release(); return names; }这段代码做了三件事创建设备枚举器、按视频输入设备类别枚举、读出设备的 FriendlyName。逻辑上注意CoCreateInstance(CLSID_SystemDeviceEnum, ...)必须是CLSCTX_INPROC_SERVER不要随便改pEnum-Next返回 S_FALSE 表示枚举结束不能用 FAILED 判断退出否则会多一个空设备名。构建 Filter Graph 时先创建设备的 IBaseFilter再把 Sample Grabber 和 Null Renderer 加进图最后连接。核心代码如下HRESULT CameraManager::buildGraph(const QString devName) { ICreateDevEnum *pDevEnum nullptr; IEnumMoniker *pEnum nullptr; CoCreateInstance(CLSID_SystemDeviceEnum, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(pDevEnum)); pDevEnum-CreateClassEnumerator(CLSID_VideoInputDeviceCategory, pEnum, 0); IMoniker *pMoniker nullptr; IBaseFilter *pSrc nullptr; while (pEnum-Next(1, pMoniker, nullptr) S_OK) { IPropertyBag *pBag nullptr; pMoniker-BindToStorage(0, 0, IID_PPV_ARGS(pBag)); VARIANT var; VariantInit(var); pBag-Read(LFriendlyName, var, nullptr); if (devName QString::fromWCharArray(var.bstrVal)) { pMoniker-BindToObject(0, 0, IID_PPV_ARGS(pSrc)); VariantClear(var); pBag-Release(); pMoniker-Release(); break; } VariantClear(var); pBag-Release(); pMoniker-Release(); } pEnum-Release(); pDevEnum-Release(); if (!pSrc) return E_FAIL; CoCreateInstance(CLSID_FilterGraph, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(m_pGraph)); m_pGraph-QueryInterface(IID_PPV_ARGS(m_pControl)); m_pGraph-AddFilter(pSrc, LCameraSource); CoCreateInstance(CLSID_SampleGrabber, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(m_pGrabber)); CoCreateInstance(CLSID_NullRenderer, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(m_pNullRenderer)); m_pGraph-AddFilter(m_pGrabber, LGrabber); m_pGraph-AddFilter(m_pNullRenderer, LNullRenderer); AM_MEDIA_TYPE mt; ZeroMemory(mt, sizeof(mt)); mt.majortype MEDIATYPE_Video; mt.subtype MEDIASUBTYPE_RGB24; m_pGrabber-SetMediaType(mt); m_pGrabber-SetOneShot(FALSE); m_pGrabber-SetBufferSamples(FALSE); m_pGraph-Connect(m_pGrabber, m_pNullRenderer, 0); m_pGraph-Connect(pSrc, m_pGrabber, 0); return S_OK; }参数说明SetMediaType里的MEDIASUBTYPE_RGB24是让系统帮你做 YUV 到 RGB 的转换代价是占用一点 CPUSetOneShot(FALSE)是持续抓帧而不是只抓一帧SetBufferSamples(FALSE)是不让 Sample Grabber 内部再做一份缓冲数据直接走回调。连接顺序必须先 Connect Grabber 到 NullRenderer再 Connect Source 到 Grabber反过来经常因为输出 Pin 不兼容而失败。3.3 用 Sample Grabber 抓帧并在 Qt 窗口显示回调与缓冲Sample Grabber 需要一个回调对象它实现 ISampleGrabberCB 接口。回调运行在 DirectShow 的 Streaming 线程这里只能做数据拷贝不能碰 Qt 控件否则就是跨线程操作 UI后果是照片延迟、界面卡死甚至崩溃。class FrameCallback : public ISampleGrabberCB { public: STDMETHODIMP_(ULONG) AddRef() { return 1; } STDMETHODIMP_(ULONG) Release() { return 1; } STDMETHODIMP QueryInterface(REFIID riid, void **ppv) { if (riid IID_ISampleGrabberCB || riid IID_IUnknown) { *ppv this; return S_OK; } return E_NOINTERFACE; } STDMETHODIMP SampleCB(double, IMediaSample *pSample) { BYTE *pData nullptr; pSample-GetPointer(pData); LONG size pSample-GetActualDataLength(); QMutexLocker locker(mutex); memcpy(frameBuffer, pData, size); frameReady true; return S_OK; } STDMETHODIMP BufferCB(double, BYTE *, long) { return E_NOTIMPL; } };这里 BufferCB 一定返回 E_NOTIMPLSampleCB 才是你要用的。回调里只做 memcpy把数据拷到一块预分配的 QImage 缓冲里再让 Qt 的定时器以 30ms 周期读这块缓冲并更新界面。之所以用固定大小缓冲而不是 vector是为了避免在流线程里做内存分配防止页面抖动和缺页带来的掉帧。UI 侧的做法是在 QWidget 里放一个 QLabelQTimer 每 33ms 触发一次 updateFrame。帧率精度要求高的场景可以把 QTimer 换成 QElapsedTimer 计算实际 FPS而不是信任定时器精度。3.4 拍照保存与录像启动/停止代码与参数拍照逻辑很简单把当前显示的 QImage 直接保存为 PNG注意不要在回调线程里保存而是用定时器线程里已经复制出来的那帧。这样导出的照片和预览一致。void CameraWidget::onCapture() { QImage img m_frame.copy(); // m_frame 是定时器线程从缓冲中拷出的帧 if (m_bMirror) img img.mirrored(true, false); QString path QDateTime::currentDateTime().toString(yyyyMMdd_hhmmss) .png; img.save(path, PNG, 95); m_statusLabel-setText(QString(saved: %1).arg(path)); }录像方面我有两个习惯。一是用 DirectShow 原生链在 Capture 滤镜后面接 AVI Mux 和 File Writer输出 AVI二是用抓帧 编码器写文件。实践中我更常用第二种Sample Grabber 抓到 RGB24 后交给 OpenCV 的 VideoWriter 编码成 MP4。这样做的好处是文件格式可控可以直接写 H264 或 MJPG而且可以在录像的同时往帧上叠加频控信息和时间戳。void CameraWidget::toggleRecording() { if (!m_isRecording) { int w m_frame.width(), h m_frame.height(); m_writer.open(rec_ QDateTime::currentDateTime().toString(yyyyMMdd_hhmmss) .mp4, cv::VideoWriter::fourcc(m,p,4,v), 25, cv::Size(w, h)); m_isRecording true; } else { m_writer.release(); m_isRecording false; } }参数说明fourcc 用mp4v是 MPEG-4 Part 2兼容性好、编码快但文件偏大追求小体积可以换avc1H.264但需要 OpenCV 编译时带 ffmpeg。码率建议在 4-8 Mbps720p 25fps 这个区间画质和体积最平衡。帧率参数 25 要和实际抓帧帧率一致不一致会导致录像时间轴漂移一般我会在录像前统计最近 30 帧的实际帧率再写入。4. 摄像头开发避坑这 6 个问题我几乎每次都遇到4.1 摄像头被占用初始化直接失败 0x80070020现象程序启动后枚举设备正常但一构建 Filter Graph 就返回 0x80070020ERROR_SHARING_VIOLATION或者显示“视频设备正在被另一应用程序使用”。最常见的是微信、QQ、浏览器网页直播、OBS 占用了摄像头驱动只允许一个进程独占。原因Windows 的 USB 摄像头驱动默认不支持多进程共享DirectShow 初始化时 GetPin 失败。解决初始化失败后给用户一个明确的错误提示而不是直接崩溃。代码里用HRESULT hr m_pGraph-Connect(...)判断hr 0x80070020时提示“请关闭其他使用摄像头的软件”。还有一种情况是上次程序异常退出、驱动没释放重启机器或拔插 USB 能恢复在提示文案里加上这一点。4.2 在回调里保存图片照片延迟几秒还卡顿现象拍照按钮点了之后界面卡住 2-3 秒照片才弹出来严重时直接崩溃。原因我在 SampleCB 回调里直接调用了 img.save()这是流线程里的阻塞 IO 操作。DirectShow 的 Streaming 线程执行时间长了会丢帧阻塞 IO 会导致摄像头的帧缓冲区堆积继而触发整个 Graph 的暂停。解决回调里只 memcpyUI 里保存。用 QMutex 保护缓冲UI 线程的槽函数拿到数据后再做文件写入。内存拷贝和磁盘写入是两回事别混在一条线程里做。4.3 程序退出后摄像头灯还亮着别的软件打不开摄像头现象关闭程序后摄像头的指示灯还亮着或者第二次启动程序时枚举不到设备。原因析构函数里没有按顺序释放 DirectShow 接口。我最初只调用了m_pControl-Release()但 Source Filter 还挂在 Graph 上Graph 都没停设备自然没释放。解决析构顺序必须是先m_pControl-Stop()暂停 Graph再m_pGraph-RemoveFilter(pSrc)把摄像头滤镜从图里拆下来最后逐个 Release 所有 COM 接口。记得加CoUninitialize()。这个顺序不能乱Release 引用计数归零后系统才真正关闭设备驱动句柄。4.4 视频画面在 Qt 窗口里黑屏渲染器选型问题现象用 IVideoWindow 把 DirectShow 的视频输出嵌到 QWidget 的 HWND 里程序运行后控件区域黑屏但独立窗口能显示。原因Qt 5 的窗口是自绘的DirectShow 的视频渲染器窗口盖在 Qt 控件上时Z 序和刷新时机都不可控。特别是用了 VMR-7/9 之后它会拦截鼠标消息、绘制菜单和 Qt 的事件循环打架。解决直接用 Null Renderer QImage 绘制方案让 DirectShow 只负责“把帧给我”显示完全交给 Qt。这个方案虽然要多一次拷贝但胜在稳定而且后期加 OSD 叠加、抓图、缩放都顺手很多。4.5 切分辨率无效SetFormat 没停 Graph现象调用 IAMStreamConfig::SetFormat 设置了高分辨率但画面没变化或者返回失败。原因摄像头在 Graph 运行状态下是锁定格式的直接 SetFormat 会因为 Pin 忙碌而失败。而且部分 USB 摄像头需要先释放旧格式的资源才能切到新格式。解决先用m_pControl-Stop()停流调用IAMStreamConfig::SetFormat再重建 Graph 或 Restart。顺序是停流 - 设置格式 - 重新Run()。另外注意设置的结构体是VIDEO_STREAM_CONFIG_CAPS还是VIDEOINFOHEADER很多摄像头的输出头是VIDEOINFOHEADER2字段偏移差一点就设置不成功。4.6 Qt 5 的 CMake 配置错误Qt5Config.cmake 路径不对现象构建时报CMake Error at C:/Qt/qt5.9.4/5.9.4/msvc2017_64/lib/cmake/Qt5/Qt5Config.cmake或者提示找不到 Qt5Widgets。原因CMAKE_PREFIX_PATH 指到了 Qt 安装根目录但根目录下有多个编译套件子目录比如 msvc2017_64 和 msvc2017路径对不上。解决明确指定到具体套件目录cmake .. -DCMAKE_PREFIX_PATHC:/Qt/5.9.4/msvc2017_64 cmake --build . --config Release如果项目是 qmake 的注意 msvc 套件对应 MSVC 版本MinGW 套件对应 MinGW 编译器交叉混用会链接失败。这个问题和 DirectShow 无关但只要是 Qt5 摄像头项目十个里有三个卡在这里。5. 验证与进阶FPS 统计、录像质量检查、再往上怎么走做完这套源码骨架第一件事不是看界面好不好看而是验证帧率和延迟是否达标。我一般会在定时器更新里做一个 FPS 统计每 30 帧记一次时间戳用实际经过的时间算平均帧率显示在标题栏或状态栏里。肉眼对比摄像头画面和真实动作的延迟如果超过 150ms优先检查是否开了垂直同步、是否有人在回调里做了重活。录像质量检查不要只靠眼睛看我习惯把录像文件拖进 ffprobe 看三样东西编码格式、真实帧率、B 帧数。mp4v编码下 B 帧太多会导致播放器拖动卡顿必要时用avc1并关闭 B 帧选项。如果文件时间戳漂移回到 3.4 节核对写入码率与实际抓帧帧率。这套骨架的扩展空间很明确。需要多路摄像头时为每一路创建一个独立的 Graph在 Qt 里用多个 Widget 容器并行显示想对接树莓派摄像头、智能车摄像头这类方案时这个骨架不适用但如果你想接入 YOLO 推理或 OpenPilot 的标定思路可以让 Sample Grabber 的帧经过 resize 后直接送入推理线程预览线程和推理线程互不阻塞还想做动目标检测直接在 QImage 绘制的步骤里叠加框和置信度就行。我自己做摄像头工具这些年最大的教训就是“DirectShow 的可用性不要靠赌”。现在每写一个采集模块我都会先做一遍枚举不存在的设备、占用设备、断线重连三类异常测试再把 Graph 的构建和释放封装成 RAII 类省掉了后面很多翻车现场。这套基于 DirectShow 和 Qt5 的源码思路对课程设计和产线工具都足够扎实值得按上面的步骤复现一版希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →