☰
DirectShow视频采集实战:从Filter Graph到SampleGrabber抓帧与避坑指南
2026/10/7 6:33:01 网站建设 项目流程

简介:这份资源面向使用 Qt 开发 Windows 多媒体应用的开发者,尤其是需要在界面程序中接入摄像头与音频设备的初中级工程师。它把 DirectShow 的底层能力封装成 Qt 风格的接口,让开发者无需深入理解复杂的滤镜图模型,就能完成设备枚举与参数控制。压缩包共 7 个文件,约 7KB,包含 3 个 cpp 源文件、2 个 h 头文件、1 个 pro 工程文件和 1 个 ui 界面文件,分别承担接口实现、类声明、工程配置与界面布局,结构紧凑、便于直接嵌入现有项目。核心接口覆盖获取系统摄像头列表、枚举音频输入输出设备、读取与设置曝光、白平衡、焦距等摄像头参数,以及查询摄像头支持的分辨率,可用于视频聊天、在线教育、安全监控等实时音视频场景。目前已有 882 人学习下载,适合作为 Qt 集成 DirectShow 的入门参考与调试起点。

1. DirectShow 这套老框架,为什么还有人翻出来用

如果你手头有一个directshow.zip,大概率不是想研究什么新潮的 AI 推理框架,而是被一个很具体的问题卡住了:某个工业相机、采集卡、老式 USB 摄像头,或者一段本地视频文件,需要在自己的程序里稳定地拿到帧数据,而厂商给的 SDK 只提供了 DirectShow 的接口。DirectShow 是 Windows 平台上的一套多媒体流处理框架,核心思想是把采集、解码、渲染拆成一个个 Filter,用 Pin 连成一条 Filter Graph,数据从 Source Filter 一路流到 Renderer Filter。它不新,甚至可以说有点老,但在视频采集和本地回放这两个场景里,它依然是很多硬件厂商默认支持的底层通道。

这个标题对应的东西,通常是一个围绕 DirectShow 的封装库、示例工程或者工具集合,解决的是「怎么用 C++ 或 C# 快速把摄像头帧抓出来」「怎么枚举设备」「怎么把采集和编码串起来」这类问题。适合谁看:做 Windows 桌面端视频采集的工程师、需要对接工业相机的上位机开发者、以及被 WDM 驱动和 COM 组件折磨过一轮、想找一条能跑通的路的人。下面按「先跑通最小采集,再谈参数和坑」的顺序展开。

2. 把 DirectShow 采集链路拆开看:Filter、Pin 与 Graph 的协作方式

2.1 一条最小采集链路里到底有哪几个 Filter

DirectShow 的运行时模型不复杂,复杂的是每个 Filter 背后的 COM 接口和协商过程。一条典型的摄像头采集链路至少包含三个 Filter:Source Filter(通常是Capture Graph里的Video Capture Source)、中间可能有一个Smart Tee或解码 Filter、最后是Video Renderer或自定义的 Sample Grabber。Source Filter 负责从驱动拿原始数据,输出 Pin 上会暴露一组媒体类型(Media Type),比如MEDIASUBTYPE_YUY2、MEDIASUBTYPE_MJPG、MEDIASUBTYPE_RGB24。下游 Filter 的输入 Pin 会尝试和它做媒体类型协商,协商成功才能连接。

很多人第一次写 DirectShow 代码,卡在CoCreateInstance返回0x80040154(类未注册)或者Connect返回VFW_E_CANNOT_CONNECT。前者通常是没调用CoInitializeEx,后者多半是媒体类型不匹配。理解 Filter 和 Pin 的关系之后,这些错误码就不再是黑匣子,而是有明确指向的线索。

2.2 用 GraphBuilder 搭一条能出帧的链路

下面这段代码是常见的「枚举设备 + 构建 Graph + 挂 SampleGrabber 抓帧」的最小骨架。用 C++ 写,依赖strmiids.lib、quartz.lib和ole32.lib。

#include <dshow.h> #include <initguid.h> #include <qedit.h> // ISampleGrabber #pragma comment(lib, "strmiids.lib") #pragma comment(lib, "quartz.lib") #pragma comment(lib, "ole32.lib") // 回调类:每来一帧就拷贝出来 class SampleCB : public ISampleGrabberCB { public: STDMETHODIMP BufferCB(double, BYTE* pBuffer, long len) { // pBuffer 是当前帧数据,len 是字节数 // 注意:这里运行在 DirectShow 的流线程上,不要做耗时操作 m_frame.assign(pBuffer, pBuffer + len); return S_OK; } STDMETHODIMP SampleCB(double, IMediaSample*) { return S_OK; } STDMETHODIMP QueryInterface(REFIID riid, void** ppv) { if (riid == IID_IUnknown || riid == IID_ISampleGrabberCB) { *ppv = static_cast<ISampleGrabberCB*>(this); AddRef(); return S_OK; } return E_NOINTERFACE; } STDMETHODIMP_(ULONG) AddRef() { return InterlockedIncrement(&m_ref); } STDMETHODIMP_(ULONG) Release() { LONG r = InterlockedDecrement(&m_ref); if (r == 0) delete this; return r; } private: LONG m_ref = 1; std::vector<BYTE> m_frame; }; int main() { CoInitializeEx(nullptr, COINIT_MULTITHREADED); ICreateDevEnum* pDevEnum = nullptr; CoCreateInstance(CLSID_SystemDeviceEnum, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pDevEnum)); IEnumMoniker* pEnum = nullptr; pDevEnum->CreateClassEnumerator(CLSID_VideoInputDeviceCategory, &pEnum, 0); if (!pEnum) { /* 没有可用采集设备 */ return -1; } IMoniker* pMoniker = nullptr; pEnum->Next(1, &pMoniker, nullptr); // 取第一个设备 IBaseFilter* pCap = nullptr; pMoniker->BindToObject(nullptr, nullptr, IID_PPV_ARGS(&pCap)); IGraphBuilder* pGraph = nullptr; CoCreateInstance(CLSID_FilterGraph, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pGraph)); pGraph->AddFilter(pCap, L"Capture"); // 插入 SampleGrabber ISampleGrabber* pGrab = nullptr; CoCreateInstance(CLSID_SampleGrabber, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pGrab)); IBaseFilter* pGrabFilter = nullptr; pGrab->QueryInterface(IID_PPV_ARGS(&pGrabFilter)); pGraph->AddFilter(pGrabFilter, L"Grabber"); // 设置期望的媒体类型:RGB24,避免后续手动转换 AM_MEDIA_TYPE mt = {}; mt.majortype = MEDIATYPE_Video; mt.subtype = MEDIASUBTYPE_RGB24; mt.formattype = FORMAT_VideoInfo; pGrab->SetMediaType(&mt); // 连接 Capture -> Grabber IPin* pOut = nullptr; IPin* pIn = nullptr; // 实际工程里用 FindPin 按方向找,这里省略细节 pGraph->Connect(pOut, pIn); // 设置回调并启动 SampleCB* cb = new SampleCB(); pGrab->SetCallback(cb, 1); // 1 表示 BufferCB pGrab->SetBufferSamples(FALSE); // 不缓存,直接回调 IMediaControl* pCtrl = nullptr; pGraph->QueryInterface(IID_PPV_ARGS(&pCtrl)); pCtrl->Run(); Sleep(3000); // 采 3 秒 pCtrl->Stop(); CoUninitialize(); return 0; }

这段代码的逻辑说明:先通过CLSID_SystemDeviceEnum枚举视频输入设备,拿到第一个设备的 Moniker 并绑定成IBaseFilter;然后创建 Filter Graph,把采集 Filter 和 SampleGrabber 加进去;设置 SampleGrabber 的期望媒体类型为 RGB24,这样回调里拿到的就是已经转换好的 RGB 数据,省去手动处理 YUY2 的麻烦;最后设置回调并 Run。

参数说明:SetCallback(cb, 1)里的第二个参数决定回调方式,1 走BufferCB,0 走SampleCB。SetBufferSamples(FALSE)表示不让 SampleGrabber 自己缓存一份,直接回调,减少一次内存拷贝,但要求回调函数足够快。Sleep(3000)只是演示,真实工程里应该用事件或者消息循环控制生命周期。

提示:SampleGrabber 在 64 位下需要qedit.h的替代方案,很多新版 SDK 已经移除了这个头文件,常见做法是手动声明ISampleGrabber的接口定义,或者改用IMediaSample直接在下游 Filter 里取数据。

3. 媒体类型协商与格式转换:为什么你的回调拿不到 RGB 数据

3.1 媒体类型协商失败的三种典型表现

媒体类型协商是 DirectShow 里最容易翻车的环节。现象一:Connect直接返回VFW_E_CANNOT_CONNECT,Graph 根本搭不起来。原因通常是上游输出 Pin 支持的 subtype 和下游输入 Pin 支持的完全不重叠,比如摄像头只输出 MJPG,而 SampleGrabber 被设成了 RGB24 且没有解码 Filter 在中间。解决方式是在连接前先枚举上游 Pin 的IAMStreamConfig或IEnumMediaTypes,看看它到底支持哪些格式,然后选一个双方都认的。

现象二:Graph 能跑起来,但回调里拿到的数据花屏或者颜色不对。这多半是媒体类型协商成功了,但 stride(行跨距)没处理对。RGB24 的每行字节数不一定是width * 3,DirectShow 的VIDEOINFOHEADER.bmiHeader.biWidth可能是负数,表示图像是自下而上存储的。血泪经验是:拿到AM_MEDIA_TYPE之后,一定要读pbFormat里的VIDEOINFOHEADER,用abs(biWidth)算宽度,用biHeight的正负判断扫描方向。

现象三:回调频率远低于预期帧率。这通常是因为 SampleGrabber 的SetBufferSamples(TRUE)加上下游 Renderer 的阻塞,导致流线程被拖慢。如果不需要预览,直接把 Renderer 去掉,只保留 SampleGrabber,帧率会明显回升。

3.2 用 IAMStreamConfig 把摄像头切到目标分辨率

很多摄像头默认输出 640x480,但你需要 1920x1080。直接连接往往拿不到高分辨率,因为 Source Filter 的默认输出格式是它自己选的。正确做法是通过IAMStreamConfig接口主动设置。

// 假设 pCap 是采集 Filter,pOutPin 是它的输出 Pin IAMStreamConfig* pConfig = nullptr; pOutPin->QueryInterface(IID_PPV_ARGS(&pConfig)); int count = 0, size = 0; pConfig->GetNumberOfCapabilities(&count, &size); for (int i = 0; i < count; ++i) { VIDEO_STREAM_CONFIG_CAPS caps = {}; AM_MEDIA_TYPE* pmt = nullptr; pConfig->GetStreamCaps(i, &pmt, (BYTE*)&caps); if (pmt->formattype == FORMAT_VideoInfo) { VIDEOINFOHEADER* vih = (VIDEOINFOHEADER*)pmt->pbFormat; int w = abs(vih->bmiHeader.biWidth); int h = abs(vih->bmiHeader.biHeight); if (w == 1920 && h == 1080) { pConfig->SetFormat(pmt); // 切到 1080p break; } } // 释放 pmt,实际工程里用 CoTaskMemFree }

逻辑说明:GetNumberOfCapabilities拿到该 Pin 支持的格式数量,然后逐个GetStreamCaps检查分辨率和帧率范围。找到目标分辨率后调用SetFormat,这一步必须在 Graph 连接之前做,否则不生效。参数说明:VIDEO_STREAM_CONFIG_CAPS里包含MinFrameInterval和MaxFrameInterval,单位是 100 纳秒,可以用来限制帧率范围。如果摄像头不支持你要的分辨率,SetFormat会返回失败,这时候要么换设备,要么接受默认分辨率再做缩放。

注意:有些采集卡的IAMStreamConfig实现不完整,GetStreamCaps返回的格式列表和实际能出的格式不一致,这种情况只能靠试。我一般会写一个循环,把所有格式都试一遍SetFormat,记录哪些返回 S_OK,再从中选最接近的。

4. 避坑与排查:DirectShow 采集里最容易翻车的五件事

4.1 现象:程序退出时卡死或崩溃

原因:DirectShow 的 Filter Graph 释放顺序不对。常见错误是先CoUninitialize再释放 Graph,或者回调对象在 Graph 还在运行时就被 delete 了。流线程还在跑,回调指针已经失效,直接访问违例。

解决:严格按「Stop → 释放 Graph → 释放 Filter → 释放回调 → CoUninitialize」的顺序清理。回调对象的引用计数要正确实现,SampleGrabber 会 AddRef,释放 Graph 之后它才会 Release。如果用的是自己 new 出来的回调,记得在 Graph 完全停止后再 delete。

4.2 现象:回调里拿到的帧数比预期少很多

原因:SampleGrabber 默认会在回调返回后才释放 Sample,如果回调里做了耗时操作(比如写文件、发网络请求),流线程会被阻塞,帧率下降甚至丢帧。另一个原因是SetBufferSamples(TRUE)让 SampleGrabber 自己缓存,缓存满了之后新帧会被丢弃。

解决:回调里只做内存拷贝,把数据丢到无锁队列或者环形缓冲区,由另一个线程消费。如果不需要每一帧都处理,可以在回调里做丢帧判断,比如每隔一帧取一次。SetBufferSamples(FALSE)配合快速回调是延迟最低的方案。

4.3 现象:枚举设备时找不到摄像头

原因:CreateClassEnumerator返回的IEnumMoniker为空,或者Next返回S_FALSE。常见原因是权限问题(某些系统下需要管理员权限才能访问采集设备),或者设备被其他程序独占(比如另一个进程正在用同一个摄像头)。

解决:先确认设备管理器里摄像头正常,然后用GraphEdit或者AMCap这类工具测试能否打开。如果工具能打开而你的代码不行,检查CoInitializeEx的线程模型,COINIT_MULTITHREADED和COINIT_APARTMENTTHREADED在某些设备上表现不同。独占问题只能通过关闭其他占用程序或者使用支持共享模式的设备来解决。

4.4 现象:SetFormat 返回 S_OK 但实际分辨率没变

原因:部分驱动在 Graph 连接之后会重新协商格式,覆盖你之前SetFormat的设置。或者SetFormat调用时机不对,在Connect之后调用可能不生效。

解决:把SetFormat放在AddFilter之后、Connect之前。如果还是不行,尝试在 Graph 运行后通过IAMStreamConfig::GetFormat读回当前格式,确认是否真的切换成功。有些设备需要先Stop再SetFormat再Run。

4.5 现象:编译时报 qedit.h 找不到或 ISampleGrabber 未定义

原因:Windows SDK 从某个版本开始移除了qedit.h,但strmiids.lib里还有 SampleGrabber 的 CLSID。这是典型的「库还在,头没了」的情况。

解决:手动声明需要的接口和 GUID。常见做法是定义一个qedit.h的替代头文件,把ISampleGrabber、ISampleGrabberCB的接口声明和CLSID_SampleGrabber、IID_ISampleGrabber的 GUID 定义补上。GUID 可以从旧版 SDK 或者网上公开的 DirectShow 文档里找到。另一种做法是改用IMediaSample在下游自定义 Filter 里取数据,彻底绕开 SampleGrabber。

5. 从能跑到好用:用 IMediaSample 替代 SampleGrabber 的进阶写法

SampleGrabber 适合快速验证,但它的回调机制在高帧率下会成为瓶颈。更可控的做法是写一个自定义的 Transform Filter 或者直接在下游 Renderer 之前插入一个取帧 Filter,通过IMediaSample拿数据。IMediaSample::GetPointer返回当前帧的缓冲区指针,GetActualDataLength返回有效字节数,GetMediaTime返回时间戳。这种方式没有额外的内存拷贝,延迟更低。

一个具体的技巧:在自定义 Filter 的Receive方法里,先调用IMediaSample::AddRef把 Sample 留住,然后丢到队列里异步处理,处理完再Release。这样流线程不会被阻塞,同时也不会丢帧。队列深度建议控制在 3 到 5 帧,太深会增加延迟,太浅容易丢帧。

验证方法:用GraphEdit或者自己写一个简单的 Graph 查看器,把自定义 Filter 加进去,观察 Pin 连接状态和媒体类型。运行后用性能计数器看 CPU 占用和帧率是否稳定。如果帧率波动超过 10%,检查队列深度和回调里的锁竞争。

我自己的习惯是:任何 DirectShow 工程,先用 SampleGrabber 跑通最小链路,确认设备能出帧、格式正确,然后再替换成自定义 Filter 做性能优化。这样出问题时能快速定位是设备层、协商层还是回调层的问题。另外,永远不要在流线程里做文件 IO 或者网络请求,这个坑我踩过不止一次,每次都是程序看起来在跑,实际帧率已经掉到个位数了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询