简介:基于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 Renderer
DirectShow 的逻辑是一个滤镜图(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 必须设为 FALSE,SetBufferSamples 建议设为 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_RGB24,DirectShow 会在内部插入色彩转换滤镜把 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_manager(DirectShow 采集与回调)、camera_widget(Qt5 界面与定时绘制)、main(启动入口)。头文件依赖是 DShow.h 和 Qt5 的 Widgets,不需要额外引入 DirectX SDK,Windows 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(L"FriendlyName", &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(L"FriendlyName", &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, L"CameraSource"); 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, L"Grabber"); m_pGraph->AddFilter(m_pNullRenderer, L"NullRenderer"); 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 的转换,代价是占用一点 CPU;SetOneShot(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_NOTIMPL,SampleCB 才是你要用的。回调里只做 memcpy,把数据拷到一块预分配的 QImage 缓冲里,再让 Qt 的定时器以 30ms 周期读这块缓冲并更新界面。之所以用固定大小缓冲而不是 vector,是为了避免在流线程里做内存分配,防止页面抖动和缺页带来的掉帧。
UI 侧的做法是在 QWidget 里放一个 QLabel,QTimer 每 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,兼容性好、编码快,但文件偏大;追求小体积可以换avc1(H.264),但需要 OpenCV 编译时带 ffmpeg。码率建议在 4-8 Mbps,720p 25fps 这个区间画质和体积最平衡。帧率参数 25 要和实际抓帧帧率一致,不一致会导致录像时间轴漂移,一般我会在录像前统计最近 30 帧的实际帧率再写入。
4. 摄像头开发避坑:这 6 个问题我几乎每次都遇到
4.1 摄像头被占用,初始化直接失败 0x80070020
现象:程序启动后枚举设备正常,但一构建 Filter Graph 就返回 0x80070020(ERROR_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 的暂停。
解决:回调里只 memcpy,UI 里保存。用 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_PATH=C:/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 的源码思路对课程设计和产线工具都足够扎实,值得按上面的步骤复现一版,希望帮到你。
本文还有配套的精品资源,点击获取