news 2026/9/16 4:08:59

DirectShow实战:基于MFC实现摄像头采集与视频录制回放

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DirectShow实战:基于MFC实现摄像头采集与视频录制回放

简介:这是一份基于VC++与DirectShow实现的摄像头视频采集及回放源码工程,面向C/C++开发者和多媒体编程学习者,可用来理解Windows下的实时视频流处理、过滤图构建、视频解码渲染,以及MFC桌面应用中的多线程与事件驱动编程。压缩包共22个文件,主要包含4个h头文件、3个cpp实现文件,以及dsp/dsw工程配置、rc资源文件、txt说明和ico图标等,整体仅17KB,适合直接阅读核心代码与工程结构。已有250人学习该资源。通过分析源码可以掌握视频采集与回放的过滤器图搭建流程、AVI/MP4等媒体格式的处理思路,并学习Visual Studio调试技巧和错误处理机制,是巩固VC++多媒体开发能力的实用案例。

1. 从摄像头取流到回放,为什么还要自己写一遍?

很多人第一反应是 OpenCV 里VideoCapture(0)一行代码就能拿到摄像头画面,何必去翻一份 VC++ 的 DirectShow 工程。但实际做设备端采集程序的人很快会发现,OpenCV 的高层封装掩盖了太多关键细节:设备枚举、分辨率切换、YUV 格式协商、帧率控制、预览窗口与录像文件同步。一旦硬件换成工业相机或 USB 摄像头,或者需要把采集到的裸数据交给编码器,VideoCapture的抽象层往往成了最大的限制。这份源程序的底层是 DirectShow,通过 Filter Graph 把「采集 → 处理 → 预览」的链路显式地搭建出来,读代码的人能清楚看到每一帧数据在哪个环节被处理、被丢弃、被写入文件。适合刚接触 DirectShow 但已经会用 C++ 的开发者,也适合需要在 MFC 界面里嵌入视频预览窗口、处理摄像头热插拔和录像文件回放逻辑的人。

2. DirectShow Filter Graph 与 MFC 工程的骨架

2.1 Filter Graph 的组成方式与选型理由

DirectShow 的核心思想是「过滤器图」。采集摄像头画面不是调用某个 SDK 函数,而是把几个 COM 组件按顺序连接成一条流水线:最左边是「源过滤器」,负责从摄像头硬件拿数据;中间是各种「转换过滤器」,做格式转换、分辨率缩放;最右边是「渲染过滤器」,把视频画到窗口或者写入文件。这个工程里的VideoEXE目录通常就是那套可执行的测试程序,而源码中与图构建相关的代码集中在CaptureGraphBuilder的调用上。之所以用 DirectShow 而不是 DirectShow.NET 或 Media Foundation,是因为这个项目用 VC++ 6.0 时代的 MFC 写的,DirectShow 的 COM 接口与 MFC 的消息循环天然契合,而且 GraphEdit 工具可以直接把整个链路可视化,调试时能直观看到哪个过滤器之间连接失败。

// 典型的 Filter Graph 构建代码片段 IGraphBuilder* pGraph = NULL; ICaptureGraphBuilder2* pBuilder = NULL; IBaseFilter* pCapFilter = NULL; IBaseFilter* pMuxFilter = NULL; CoInitialize(NULL); CoCreateInstance(CLSID_FilterGraph, NULL, CLSCTX_INPROC_SERVER, IID_IGraphBuilder, (void**)&pGraph); CoCreateInstance(CLSID_CaptureGraphBuilder2, NULL, CLSCTX_INPROC_SERVER, IID_ICaptureGraphBuilder2, (void**)&pBuilder); pBuilder->SetFiltergraph(pGraph); // 枚举摄像头设备并绑定源过滤器 ICreateDevEnum* pDevEnum = NULL; CoCreateInstance(CLSID_SystemDeviceEnum, NULL, CLSCTX_INPROC_SERVER, IID_ICreateDevEnum, (void**)&pDevEnum); // 后续通过 IEnumMoniker 遍历设备,BindToObject 得到 pCapFilter

这段代码的逻辑是先把 Filter Graph 管理器和 Capture Graph Builder 两个 COM 对象建出来,前者管整条流水线的运行状态,后者负责直接连接「视频源」到「写入文件」这一段。实际调试时最常踩的坑是把CLSID_FilterGraph写成了CLSID_FilterGraphNoClock,后者没有时钟源,录像的时间戳会出现跳变。

2.2 MFC 界面与 DirectShow 窗口的桥接

源程序里视频预览窗口不是靠 DirectShow 自己弹出,而是用IVideoWindow接口把视频画面嵌入到 MFC 的控件区域。常见做法是拿到对话框上 Picture 控件的句柄,然后调用SetOwner把渲染窗口的父窗口设为该控件。这里有个关键参数:控件必须设置为SS_NOTIFY风格,否则窗口尺寸变化时 DirectShow 不会收到重绘通知,画面会花屏。

// 将视频窗口绑定到 MFC 控件 IVideoWindow* pVW = NULL; pGraph->QueryInterface(IID_IVideoWindow, (void**)&pVW); pVW->put_Owner((OAHWND)GetDlgItem(IDC_PREVIEW)->GetSafeHwnd()); pVW->put_WindowStyle(WS_CHILD | WS_CLIPSIBLINGS); pVW->put_MessageDrain((OAHWND)GetSafeHwnd()); pVW->SetWindowPosition(0, 0, width, height);

put_MessageDrain这一段容易被忽略,它的作用是把 DirectShow 窗口收到的鼠标消息转交给 MFC 对话框处理,否则你在视频画面上点击按钮、调整窗口位置都会失灵。尺寸参数在摄像头分辨率改变后要重新调用SetWindowPosition,类似视频旋转这类操作也需要先停掉 Graph 再重建,边运行边改窗口尺寸会导致渲染过滤器内部状态紊乱。

2.3 Filter Graph 的运行状态管理

源程序里对「开始采集」「停止采集」「单帧抓取」的控制是通过IMediaControlRunStopPause三个方法实现的。很多人误以为Pause就是暂停录像,实际上Pause的作用是让过滤器图进入就绪状态,摄像头仍然在输出帧,只是渲染被挂起。真正的录制暂停需要断开文件写入滤镜的数据流,或者用IMediaSeeking做时间控制。

方法状态变化常见用途
Run()整个图开始流动开始预览或录制
Pause()图就绪但数据不渲染准备首次画面显示,避免黑屏
Stop()所有过滤器复位,数据指针重置停止采集、释放设备

调用顺序也有讲究:首次Run之前先Pause一次,能让摄像头完成曝光初始化,避免第一帧画面过亮。Stop之后要紧接着调用IBaseFilterStop方法,否则部分 USB 摄像头在下次Run时会出现 30 秒以上的延迟。

3. 视频采集的核心:设备枚举与参数协商

3.1 枚举摄像头设备并排除集成摄像头干扰

这个工程最值得读的部分是设备枚举逻辑。ICreateDevEnum遍历系统设备时,每条Moniker的 friendly name 对应一个摄像头,但笔记本上通常有多个采集设备(内置摄像头、USB 外接),直接用第一个枚举结果容易踩坑。源程序里一般会按 "USB Video Device" 前缀做过滤,或者列出所有设备让用户选择。从技术演进角度,这个项目虽然是老代码,但枚举接口在现代 Windows 10/11 上仍然可用,只是部分摄像头驱动会把自身暴露成 UVC 兼容设备,旧代码的匹配字符串需要同步调整。

// 枚举摄像头设备并绑定源过滤器 ICreateDevEnum* pDevEnum = NULL; CoCreateInstance(CLSID_SystemDeviceEnum, NULL, CLSCTX_INPROC_SERVER, IID_ICreateDevEnum, (void**)&pDevEnum); IEnumMoniker* pEnum = NULL; pDevEnum->CreateClassEnumerator(CLSID_VideoInputDeviceCategory, &pEnum, 0); IMoniker* pMoniker = NULL; while (pEnum->Next(1, &pMoniker, NULL) == S_OK) { IPropertyBag* pPropBag = NULL; pMoniker->BindToStorage(0, 0, IID_IPropertyBag, (void**)&pPropBag); VARIANT varName; VariantInit(&varName); pPropBag->Read(L"FriendlyName", &varName, 0); // 在这里比对设备名,选择目标摄像头 pMoniker->BindToObject(0, 0, IID_IBaseFilter, (void**)&pCapFilter); VariantClear(&varName); pPropBag->Release(); pMoniker->Release(); }

CreateClassEnumerator的第三个参数如果是REGDB_E_CLASSNOTREG之外的错误码,说明当前设备管理器里没有任何可用的视频输入设备。这类错误在虚拟机里特别常见,源程序跑不起来往往不是代码问题,而是宿主机的摄像头没有重定向到虚拟机。真实调试场景中,我总是先在设备管理器里确认摄像头被识别为 UVC 设备,再用AMCAP.EXE工具验证摄像头默认输出格式,最后才跑工程里的测试程序。

3.2 分辨率与像素格式的协商机制

摄像头不是你说要 1920x1080 就给你 1920x1080。DirectShow 的IAMStreamConfig接口负责枚举并设置视频格式,源程序里通常封装了一个SetVideoFormat函数,内部遍历所有支持的媒体类型,把分辨率、帧率、像素格式都拿出来对比。这里有个容易忽视的参数:VIDEOINFOHEADER里的AvgTimePerFrame是单位 100ns 的帧间隔,设定 30fps 就是这个字段设为 333333。如果直接改BitmapInfoHeader.biWidth而不动AvgTimePerFrame,画面尺寸会变但帧率保持默认,预览时会看到明显的卡顿感。

// 设置分辨率与帧率,核心是匹配媒体类型 IAMStreamConfig* pStreamConfig = NULL; pBuilder->FindInterface(&PIN_CATEGORY_CAPTURE, &MEDIATYPE_Video, pCapFilter, IID_IAMStreamConfig, (void**)&pStreamConfig); int iCount = 0, iSize = 0; pStreamConfig->GetNumberOfCapabilities(&iCount, &iSize); for (int i = 0; i < iCount; i++) { BYTE* pParams = new BYTE[iSize]; AM_MEDIA_TYPE* pMediaType = NULL; pStreamConfig->GetStreamCaps(i, &pMediaType, pParams); if (pMediaType->formattype == FORMAT_VideoInfo) { VIDEOINFOHEADER* pVih = (VIDEOINFOHEADER*)pMediaType->pbFormat; if (pVih->bmiHeader.biWidth == 1280 && pVih->bmiHeader.biHeight == 720) { // 找到目标分辨率,应用该格式 pStreamConfig->SetFormat(pMediaType); break; } } // 释放 AM_MEDIA_TYPE 内部数据 // 注意 delete pMediaType->pbFormat 与 delete pMediaType }

这段逻辑里GetStreamCaps返回的AM_MEDIA_TYPE是从驱动层拷贝出来的数据,用完后必须手动释放pbFormatpMediaType本身,否则每次切换分辨率就会泄漏十几个字节。多频切换分辨率测试几次之后,进程内存涨得不多,但句柄数量会明显上升,最终导致CoCreateInstance失败。这就是典型的内存泄漏问题。

3.3 预览与录制的并行链路

工程里的视频回放不是直接从文件读,而是把「预览」和「采集」做成两条分支:摄像头源过滤器后面接一个Smart Tee过滤器,这个过滤器把一路数据拆成两路,一路走预览窗口,另一路走文件写入。Smart Tee在预览和录制并行时是必需的,但它的下游必须有一个渲染过滤器消耗数据,否则整条链路会停住。

// 用 Smart Tee 分流,捕获一路进文件,一路进预览 IBaseFilter* pSmartTee = NULL; CoCreateInstance(CLSID_SmartTee, NULL, CLSCTX_INPROC_SERVER, IID_IBaseFilter, (void**)&pSmartTee); pGraph->AddFilter(pSmartTee, L"SmartTee"); // 用 Capture Graph Builder 连接分流后的两个 PIN pBuilder->RenderStream(&PIN_CATEGORY_CAPTURE, &MEDIATYPE_Video, pCapFilter, NULL, pSmartTee); pBuilder->RenderStream(&PIN_CATEGORY_PREVIEW, &MEDIATYPE_Video, pSmartTee, NULL, NULL);

RenderStream的第二个分支是预览路径,它的下游参数传NULL,让 DirectShow 自动选择合适的 Video Renderer。如果系统里有多个渲染器(VMR7、VMR9、EVR),老工程默认走 VMR7,在 Win10 上容易出现黑屏。遇到这种情况可以手动给预览链路加一个CLSID_VideoMixingRenderer9的过滤器,而不是改全局默认渲染器。

4. 视频回放与文件格式处理

4.1 用 DirectShow 回放视频文件

回放功能在工程里的实现路径和采集完全不同。采集走的是捕获图,回放走的是播放图。IGraphBuilder::RenderFile会根据文件扩展名找到匹配的源过滤器、解码器和渲染器。这个工程能放 AVI、MP4,但 MP4 能否播放取决于系统里是否装了对应解码器,DirectShow 本身不带 MP4 的 H.264 解码器,Win10 上通常依赖系统自带的 Media Foundation 组件,在 Win7 上就需要第三方解码器。

// 回放核心逻辑:RenderFile 后控制运行 IGraphBuilder* pPlayGraph = NULL; IMediaControl* pPlayControl = NULL; IMediaSeeking* pSeek = NULL; CoCreateInstance(CLSID_FilterGraph, NULL, CLSCTX_INPROC_SERVER, IID_IGraphBuilder, (void**)&pPlayGraph); pPlayGraph->QueryInterface(IID_IMediaControl, (void**)&pPlayControl); pPlayGraph->QueryInterface(IID_IMediaSeeking, (void**)&pSeek); WCHAR wszFile[MAX_PATH] = {0}; MultiByteToWideChar(CP_ACP, 0, strFilePath.c_str(), -1, wszFile, MAX_PATH); pPlayGraph->RenderFile(wszFile, NULL); pPlayControl->Run(); // 进度跳转,单位是 100ns LONGLONG llPos = 30LL * 10000000LL; pSeek->SetPositions(&llPos, AM_SEEKING_AbsolutePositioning, NULL, AM_SEEKING_NoPositioning);

RenderFile失败时常见错误是VFW_E_CANNOT_RENDER_FILE,这个错误提示的是「整个图里没有哪个过滤器组合能处理这个文件」,要么缺解码器,要么文件损坏。用从工程源码里找到的graphedt.exe打开同一个文件,能看到具体断在哪一环,排查效率比盲目装解码器快得多。

4.2 AVI Mux 与文件写入参数

采集录像写入文件时,工程里用的是AVI Mux过滤器,它把预览链路送来的数据封装成 AVI 容器。AVI 容器的特点是索引区在文件末尾,程序意外崩溃时录制文件会打不开,因为索引没有写入。源程序里如果有「停止录制后自动修复」的逻辑,大概率是通过IMediaDet重新扫描帧数据来恢复索引,但更稳妥的做法是在Stop之前让AVI Mux正常收到EC_VIDEO_SIZE_CHANGED事件结束通知,然后才断开图连接。

// 写入 AVI 文件:RenderStream 到文件写入链路 IBaseFilter* pMux = NULL; IFileSinkFilter* pSink = NULL; CoCreateInstance(CLSID_AviDest, NULL, CLSCTX_INPROC_SERVER, IID_IBaseFilter, (void**)&pMux); pGraph->AddFilter(pMux, L"AVI Mux"); pBuilder->SetOutputFileName(&MEDIASUBTYPE_Avi, L"capture.avi", &pSink, NULL); pBuilder->RenderStream(&PIN_CATEGORY_CAPTURE, &MEDIATYPE_Video, pCapFilter, NULL, pMux);

SetOutputFileName会自动把 AVI Mux 和 File Writer 过滤器串接起来,所以代码里不需要手动加 File Writer。文件路径需要绝对路径,相对路径会让File Writer基于当前工作目录解析,在调试器里跑和直接双击 exe 跑的工作目录不同,录制文件会落得到处都是。源程序里往往把路径写死在GetCurrentDirectory拼接的位置,这是老工程的一种坏习惯,拿到后建议改成GetModuleFileName获取 exe 所在目录再拼接录制文件名。

4.3 首次实现录像回放时遇到的若干典型场景

在回放与采集的切换过程中,最常见的问题是画面显示不连贯。这通常不是因为过滤器处理速度慢,而是视频帧的时间戳不正确。摄像头直接采集的帧没有时间戳,AVI Mux会按照「帧到达顺序」写入文件,如果摄像头输出的是 30fps 但AvgTimePerFrame写的是 15fps 对应的值,播放器就会以 15fps 的速度播放,视觉上看起来就是慢动作。排查这个问题时可以直接用GSpot之类工具查看录像文件的实际帧率,再与代码里VIDEOINFOHEADERAvgTimePerFrame对比。

// 检查 AVI 文件实际帧率的小工具写法 // 用 IMediaDet 读取文件视频流的帧率 IMediaDet* pDet = NULL; CoCreateInstance(CLSID_MediaDet, NULL, CLSCTX_INPROC_SERVER, IID_IMediaDet, (void**)&pDet); pDet->put_Filename(L"capture.avi"); AM_MEDIA_TYPE mt; pDet->get_StreamMediaType(&mt); VIDEOINFOHEADER* pVih = (VIDEOINFOHEADER*)mt.pbFormat; double dFps = 10000000.0 / pVih->AvgTimePerFrame; // 输出 dFps 与实际采集帧率对比,偏差大说明源过滤器时间戳有问题

这个检查手段在源程序里没有直接封装,但代码分析阶段用它能快速定位「录像文件时长与真实时间不符」的问题。还有一种情况是摄像头输出隔行扫描信号,DirectShow 采集到的帧是交错排列的,写入 AVI 后在普通播放器上看会有横纹,这是格式问题而非程序 bug,需要添加Video Mixing Renderer的 deinterlace 设置去处理。

5. GraphEdit 验证与录制参数打磨

5.1 用 GraphEdit 导出当前图结构,快速排查链路问题

拿到这套源码后,最先应该做的是把编译出的程序跑起来,然后在IMediaControl::Run之前加一行阻塞代码(或者直接断点停在Run调用处),用 GraphEdit 的「连接到远程图」功能查看当前已构建的过滤器图。Win10 系统自带 GraphEdit 已经被移除了,但可以从旧 SDK 里提取graphedt.exe,它仍然能连接 32 位进程里的 DirectShow 图。访问方式是用管理员权限启动 GraphEdit,然后在「File -> Connect to Remote Graph」里选择目标进程,选中之后能看到所有过滤器的连接状态。这是这个工程最重要的调试手段,因为RenderStream的返回值只能告诉你某条链路失败,而图结构能告诉你失败的具体原因:PIN 类型不匹配、缺少中间过滤器还是多个过滤器争抢同一个媒体类型。

// 在 Run 之前故意延时,留出时间用 GraphEdit 连接 pControl->Pause(); Sleep(8000); // 趁这段时间把 GraphEdit 挂到进程上 pControl->Run();

Pause之后图已经构建完成但还没有开始流动,此时连接 GraphEdit 能看到完整的图结构,并且可以手动在 GraphEdit 里断开某个连接、插入一个新的转换过滤器,实时测试是否能让链路走通,改完后再回到代码里做相应调整。这种方式相比在代码里打日志看HRESULT要直观得多。

5.2 录像文件时长不一致与帧率跳变的参数修正

源程序在采集场景里默认使用AvgTimePerFrame作为帧率基准,但在实际摄像头设备上,帧与帧之间的间隔并不是恒定的。USB 摄像头受到带宽和系统调度影响,单帧传输时间可能在 30ms 到 50ms 之间波动,如果录像段特别长,AVI 文件的播放时长与真实流逝时间之间的偏差就会累积。对于这个工程实现的录制功能,建议把AVI Mux的帧率字段显式固定为摄像头标称帧率,并开启IAMStreamConfig的帧率复制标志。

同时,在源程序的对话框资源中,录像按钮的状态机切换也要处理得干净:开始录制后立刻禁用分辨率下拉框,因为采样格式的中途变更会导致AVI Mux内部缓冲区的边界错乱,最终生成的文件会出现花屏或段错误。检查这种问题的办法是录制一段固定时长(比如 10 秒)的视频,然后对比播放器的进度条时间与秒表是否一致,偏差超过 0.5 秒就说明帧率基准需要手动修正。

5.3 当前硬件环境下对老代码的三处修饰

如果这台机器的摄像头只支持 YUY2 裸格式,录制文件体积会很大,压缩选项需要在AVI Mux之前手动挂一个编码过滤器。常见做法是用CLSID_MJPGEnc(Motion JPEG 编码器)处理静态画面为主的场景,文件体积能缩小到原来的五分之一,画质损失肉眼几乎不可见。工程源码里一般不会预置编码器过滤器的构建代码,需要在RenderStream之前手动AddFilter并指定编码器的输出媒体类型。另外,在回调函数里处理EC_VIDEO_SIZE_CHANGED事件时,应在Run状态下等待事件到达后再更新窗口尺寸,别在消息循环里同步刷新。最后,如果你要用这个源程序实现「录像的同时做运动检测」,不要尝试在渲染线程里直接访问帧数据,要把帧拷贝这一动作封装进一个独立线程,在 DirectShow 的SampleCB回调里只做队列写入,由后台线程完成检测和标记。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 4:08:44

物联网架构与协议:面向MCU的端到端通信系统设计

1. 什么是“物联网架构与协议”——一个硬件工程师每天都在打交道&#xff0c;却很少被讲透的底层逻辑“物联网架构与协议”这六个字&#xff0c;听起来像教科书里的章节标题&#xff0c;但其实它就是你手头那块ESP32开发板连不上云平台时弹出的错误提示背后的原因&#xff1b;…

作者头像 李华
网站建设 2026/9/16 4:08:12

Cat 1 bis模块深度解析:GC02S1-EU2与R7KA8D2KFLCAC工程实践指南

1. 这不是普通4G模块&#xff1a;GC02S1-EU2与R7KA8D2KFLCAC组合的底层逻辑你手头拿到的GC02S1-EU2和R7KA8D2KFLCAC&#xff0c;绝不是淘宝上标着“4G模块”就完事的通用货。前者是移远通信&#xff08;Quectel&#xff09;面向欧洲市场推出的LTE Cat 1 bis工业级模组&#xff…

作者头像 李华
网站建设 2026/9/16 4:06:49

VMware Workstation Pro 安装 Ubuntu 虚拟机详细教程

VMware Workstation Pro 安装 Ubuntu 详细教程做开发这么多年&#xff0c;虚拟机一直是我工作流里离不开的东西。尤其是需要在 Windows 和 Linux 环境之间来回切换的时候&#xff0c;VMware Workstation Pro 配合 Ubuntu 的组合可以说是最稳、最省心的方案之一。网上相关的教程…

作者头像 李华
网站建设 2026/9/16 4:06:47

短剧后台管理系统技术选型与避坑实战指南

1. 项目概述&#xff1a;为什么短剧后台管理系统不是“买个源码就能上线”的简单买卖短剧后台管理系统&#xff0c;这六个字背后藏着一个正在高速运转的商业引擎。它不是传统影视CMS的简单翻版&#xff0c;也不是通用内容管理系统的套壳改造——它是为“单集1-3分钟、日更2-5集…

作者头像 李华
网站建设 2026/9/16 4:06:06

网站制作的设计思路:5步避坑指南让报价透明不踩雷

网站制作的设计思路:5步避坑指南让报价透明不踩雷 找建站公司最怕什么?不是技术不行,而是报价单上那些看不懂的术语,最后发现花了定制开发的钱,买了个套壳模板。这份网站制作的设计思路避坑指南,专治各种“被坑”焦虑。…

作者头像 李华
网站建设 2026/9/16 4:05:41

Arthas动态追踪接入OpenTelemetry:EasyTelemetry桥接方案实践解析

Arthas 的trace命令有多好用&#xff0c;用过的人都知道。线上接口慢、第三方 jar 内部逻辑诡异、某个方法调用耗时突然飙高&#xff0c;一条trace com.example.OrderService createOrder发出去&#xff0c;调用树、每层耗时、异常位置全部打在控制台上&#xff0c;整个过程不用…

作者头像 李华