简介面向DirectShow开发者的视频捕获示例工程演示了视频采集与多种像素格式转换的典型用法适合初学DirectShow或需要将格式转换功能集成到自有项目的开发者。压缩包为RAR格式共20个文件主体为6个头文件与4个C源文件配合工程解决方案sln、vcproj及资源文件rc/ico等结构清晰可直接编译查看。其中cscc.lib封装了RGB24_to_YV12、YV12_to_RGB24、YVU9_to_YV12、YUY2_to_YV12、YV12_to_YUY2等常用转换函数涵盖YUV与RGB系列互转便于直接调用或扩展。包体仅55KB轻量易部署已有246人学习下载。这套示例为视频处理开发提供了可复用的转换工具和捕获流程参照尤其适合在图像采集、视频编码前处理等场景下快速核对格式转换细节。1. 直接上手 DirectShow 捕获为什么这个老技术现在还值得用做桌面端视频工具时你迟早会撞上 DirectShow——哪怕你已经习惯了 OpenCV 的VideoCapture一把梭。某个开发者前阵子接了个项目要给一台工控机接四路 USB 摄像头做实时采集OpenCV 在 Windows 下枚举摄像头经常丢设备帧率也压不住换用 DirectShow 的Dshow Capture这套封装后设备枚举、格式协商、帧回调全部可控CPU 占用还降了三分之一。这个技术是 2000 年前后的产物但 Windows 至今原声支持摄像头、采集卡、虚拟设备都能统一接进来。适合谁写桌面安防、医学影像、工业视觉、视频会议调试工具的人——只要你的目标平台是 Windows且 camera 型号杂、需要精细控制DirectShow 就不是过时方案而是最稳的底牌。这份资源把 DirectShow 捕获流程做成了直接可跑的工程省掉你从 MSDN 缝里抠接口的时间。2. 设备枚举与源码结构先看懂这份资源怎么组织2.1 从 ICreateDevEnum 到 IMoniker抓出系统里的每一路摄像头DirectShow 的设备枚举对新手是个黑匣子为什么我capGetDriverDescription只能拿到旧版 VFW 驱动的名字因为 DirectShow 走的是 COM 接口ICreateDevEnum它按类别枚举系统里的 Filter视频输入设备属于CLSID_VideoInputDeviceCategory。这一步绕不过去所有上层封装包括 OpenCV 的MSMF后端最终都归到这套枚举逻辑上。在这份Dshow Capture资源里枚举部分是一个独立类核心代码大致长这样// EnumerateDevices.cpp HRESULT EnumerateVideoDevices(std::vectorDeviceInfo outDevices) { HRESULT hr S_OK; ICreateDevEnum* pDevEnum nullptr; IEnumMoniker* pEnumMon nullptr; // 创建系统设备枚举器 hr CoCreateInstance(CLSID_SystemDeviceEnum, NULL, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(pDevEnum)); if (FAILED(hr)) return hr; // 指定视频输入设备类别拿到枚举器 hr pDevEnum-CreateClassEnumerator( CLSID_VideoInputDeviceCategory, pEnumMon, 0); if (hr S_FALSE) { // 返回 S_FALSE 表示该类设备不存在不是失败 pEnumMon nullptr; return hr; } IMoniker* pMoniker nullptr; while (pEnumMon-Next(1, pMoniker, nullptr) S_OK) { IPropertyBag* pPropBag nullptr; hr pMoniker-BindToStorage(0, 0, IID_PPV_ARGS(pPropBag)); if (SUCCEEDED(hr)) { VARIANT varName; VariantInit(varName); // 读设备的 FriendlyName这就是你在设备管理器里看到的名称 hr pPropBag-Read(LFriendlyName, varName, 0); if (SUCCEEDED(hr)) { DeviceInfo info; info.friendlyName varName.bstrVal; outDevices.push_back(info); VariantClear(varName); } pPropBag-Release(); } pMoniker-Release(); } pEnumMon-Release(); pDevEnum-Release(); return S_OK; }这段代码有两个细节值得记第一CreateClassEnumerator返回S_FALSE不代表失败而是表示这台机器上压根没有视频设备这时候直接返回空列表就行别当成异常去弹错误框。第二BindToStorage拿IPropertyBag读取的是设备属性包除了FriendlyName你还能读DevicePath那是设备唯一标识用来区分两个同型号摄像头特别有用——这个后面多路采集时会讲。2.2 资源包目录结构与关键类说明Dshow Capture不是单文件脚本它是一套完整的 Visual Studio 工程。解压后顶层目录大概是这样的结构DshowCapture/ ├── src/ │ ├── DeviceEnumerator.cpp/.h // 设备枚举 │ ├── CaptureGraph.cpp/.h // Filter Graph 构建 │ ├── SampleGrabberCallback.cpp/.h // 帧回调 │ ├── MediaTypeHelper.cpp/.h // 格式协商 │ └── FrameConverter.cpp/.h // YUY2/RGB24 - RGB32 ├── demo/ │ ├── ConsoleDemo/ // 命令行采集测试 │ └── OpenCVDemo/ // 集成 OpenCV 显示 ├── include/ // 对外头文件 ├── lib/ // 预编译静态库 └── docs/ └── usage.md // 使用说明与 API 清单在我实际跑过的工程里最值得优先读的是CaptureGraph.cpp它把IGraphBuilder、ICaptureGraphBuilder2、IMediaControl这几张核心接口的关系理顺了。收到这份资源后第一步不是急着编译 Demo而是按这个顺序读先看DeviceEnumerator确认设备枚举逻辑再看CaptureGraph理解管线搭建方式最后看SampleGrabberCallback弄懂帧是怎么一条路走到你的回调函数里的。顺序对了后面改参数你才知道去哪一层动刀。3. 采集管线搭建把摄像头画面拉进内存缓冲3.1 Filter Graph 的三种构建方式与选型DirectShow 的采集管线本质是一张 Filter Graph源 Filter摄像头→ 中间 Filter格式转换、Sample Grabber→ 渲染 Filter显示或写入文件。构建这张图有三种常见姿势第一种是直接用IGraphBuilder::AddFilter手动把每个 Filter 加进去然后找到 Pin 手动连接。这种姿势最啰嗦但控制力最强适合采集卡这种需要精确指定物理通道的场景。第二种是ICaptureGraphBuilder2::RenderStream它只需要你告诉它源 Filter、需要连接的 Pin 类型PIN_CATEGORY_PREVIEW或PIN_CATEGORY_CAPTURE、以及中间 Filter它会自动挑合适的 Pin 并把整条链路搭好。这是最容易上手的姿势资源里的 ConsoleDemo 默认就用这个。第三种是结合ICaptureGraphBuilder2::FindPin先查找指定 Pin再做部分手动连接。这种方式适合你既要自动协商又要在某个节点强制插入自定义 Filter 的场景。我在自己的项目里通常选第二种起步因为它能避开「Pin 类型不对导致连接失败」这种经典翻车点。选型原则很简单纯摄像头采集用RenderStream足够涉及采集卡多路输入或需要插第三方滤镜时再升级到 FindPin 手动连。3.2 用 Sample Grabber 拉流回调里收帧RenderStream的链路默认会把视频送到 VMR 或 EVR 渲染器去显示但我们通常只需要拿到原始帧数据。解决办法是在源 Filter 和渲染器之间插入一个Sample Grabber让它把每一帧截下来交给你的回调函数。这段代码是资源里最核心的部分// BuildGraph.cpp 片段 hr CoCreateInstance(CLSID_SampleGrabber, NULL, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(m_pSampleGrabber)); if (FAILED(hr)) return hr; // 必须先把媒体类型设为 NULL允许 SampleGrabber 自动协商 AM_MEDIA_TYPE mt; ZeroMemory(mt, sizeof(mt)); mt.majortype MEDIATYPE_Video; hr m_pSampleGrabber-SetMediaType(mt); // 注册回调 hr m_pSampleGrabber-SetCallback(m_callback, 0);这里有个隐藏约束SetMediaType(mt)时如果你把subtype设置成具体格式比如MEDIASUBTYPE_RGB24那么 Sample Grabber 会强制要求上游输出的格式必须匹配否则连接失败。所以没有明确要求时只指定主类型MEDIATYPE_Video其他字段留空让 Filter Graph 自己去协商。回调接口ISampleGrabberCB有两个方法实模式SampleCB传的是媒体样本指针缓冲模式BufferCB传的是内存指针。资源默认用BufferCB因为它直接给你数据缓冲少一次拷贝性能更好。3.3 帧率与分辨率参数设置很多人误以为 DirectShow 不需要设置采集分辨率摄像头默认输出什么就是什么。实际上对于 UVC 摄像头你可以在IAMStreamConfig接口上设置VIDEO_STREAM_CONFIG_CAPS里的分辨率、帧率和颜色格式。下面是直接在 Capture Graph 上操作的代码// SetCaptureParams.cpp CComPtrIAMStreamConfig pStreamConfig; hr m_pCaptureGraphBuilder-FindInterface( PIN_CATEGORY_CAPTURE, MEDIATYPE_Video, m_pSourceFilter, IID_IAMStreamConfig, (void**)pStreamConfig); int iCount 0, iSize 0; pStreamConfig-GetNumberOfCapabilities(iCount, iSize); VIDEO_STREAM_CONFIG_CAPS caps; pStreamConfig-GetStreamCaps(0, m_mediaType, (BYTE*)caps); // 经典参数分辨率、帧率、颜色格式 m_mediaType.formattype FORMAT_VideoInfo; VIDEOINFOHEADER* vih (VIDEOINFOHEADER*)m_mediaType.pbFormat; vih-bmiHeader.biWidth 1920; vih-bmiHeader.biHeight 1080; vih-bmiHeader.biBitCount 24; // 设置帧率 30fps vih-AvgTimePerFrame 333333; // 单位是 100ns hr pStreamConfig-SetFormat(m_mediaType);AvgTimePerFrame的单位是 100 纳秒333333就是 1/30 秒写成10000000 / fps更容易维护。GetStreamCaps返回的是设备支持的能力列表索引从 0 开始。如果直接SetFormat一个设备不支持的格式返回会是VFW_E_INVALIDMEDIATYPE。这就是为什么必须先把GetNumberOfCapabilities和GetStreamCaps打一遍看清设备到底支持哪些组合再去设你要的值。玄学的是有些虚拟摄像头驱动会假装支持高分辨率但SetFormat成功了实际出来的帧还是低分辨率——后面避坑章会细说。4. 参数协商与格式转换别让摄像头给你乱出牌4.1 枚举媒体类型看清支持的分辨率和帧率拿到IAMStreamConfig接口后不要直接猜格式。一定要把它支持的AM_MEDIA_TYPE全部打出来存成一个候选列表。这份资源里MediaTypeHelper.cpp有一个函数封装了这件事// MediaTypeHelper.cpp std::vectorAM_MEDIA_TYPE GetSupportedMediaTypes( CComPtrIAMStreamConfig pConfig) { std::vectorAM_MEDIA_TYPE types; int iCount 0, iSize 0; HRESULT hr pConfig-GetNumberOfCapabilities(iCount, iSize); if (FAILED(hr) || iCount 0) return types; for (int i 0; i iCount; i) { AM_MEDIA_TYPE mt; BYTE* pCaps new BYTE[iSize]; hr pConfig-GetStreamCaps(i, mt, pCaps); if (SUCCEEDED(hr)) { types.push_back(mt); // 注意调用方需要 DeleteMediaType // pCaps 由 GetStreamCaps 内部填充用完即弃 } delete[] pCaps; } return types; }这里有个内存坑GetStreamCaps返回的AM_MEDIA_TYPE是 COM 分配的内存你必须用DeleteMediaType(mt)释放否则每次调用泄漏一份。我见过最离谱的泄漏是在一个长期运行的服务里枚举能力列表每分钟一次两周后内存涨到三个 G。这个函数里故意用push_back做浅拷贝注释提醒调用方释放就是为了防止用户直接把本地变量丢给CoTaskMemFree那种张冠李戴的写法。4.2 RGB24 / YUY2 / MJPG颜色格式怎么选枚举出来的列表里你会看到MEDIASUBTYPE_YUY2、MEDIASUBTYPE_UYVY、MEDIASUBTYPE_RGB24、MEDIASUBTYPE_MJPG等。选择原则要看你拿帧之后的用途。如果只是显示或送到编码器尽量保持设备的原生格式。大多数 USB 摄像头原生输出是 YUY2这是一种 4:2:2 的 YUV 格式每像素占 16 位。如果你直接要求 RGB24驱动会内部转换CPU 占用上升而且有些廉价摄像头在 RGB24 下的最大分辨率会被砍半。MJPG 则是摄像头内部压缩成 JPEG 帧帧率可以做得非常高部分摄像头 1080p 能到 60fps但你的程序要自己解压解压的 CPU 开销不一定比 YUY2 低。我的习惯是这样做实时预览或做图像识别都先问一下设备原生支持什么。如果设备原生给 YUY2我就接 YUY2到FrameConverter里再转 RGB24如果设备只给 MJPG我就接收 MJPG 再jpeg_decode。不要一上来就SetFormat(RGB24)把驱动硬件转换这条路堵死尤其是多路采集的时候转换杀手锏反而是 YUY2 转 RGB24 那个 SSE2 优化版本。4.3 把抓到的帧转成 OpenCV MatSampleGrabber回调里拿到的是BYTE* pBuffer长度取决于格式。在最常见的 YUY2 格式下一帧大小是width * height * 2字节每个像素由 Y 和交错的 UV 组成。要把这份数据变成 OpenCV 能用的Mat不能直接cv::Mat(h, w, CV_8UC3, pBuffer)因为那是 RGB 布局的。正确的做法是先转格式资源里FrameConverter.cpp有一段手工转换逻辑// FrameConverter.cpp void YUY2ToRGB24(const BYTE* yuy2, int width, int height, BYTE* rgb) { for (int i 0; i width * height / 2; i) { int y0 yuy2[i * 4 0]; int u yuy2[i * 4 1] - 128; int y1 yuy2[i * 4 2]; int v yuy2[i * 4 3] - 128; // 第一个像素 int r (int)(y0 1.402f * v); int g (int)(y0 - 0.344f * u - 0.714f * v); int b (int)(y0 1.772f * u); // 溢出截断 r r 0 ? 0 : r 255 ? 255 : r; g g 0 ? 0 : g 255 ? 255 : g; b b 0 ? 0 : b 255 ? 255 : b; rgb[i * 6 0] (BYTE)b; rgb[i * 6 1] (BYTE)g; rgb[i * 6 2] (BYTE)r; // 第二个像素复用同一个 UV r (int)(y1 1.402f * v); g (int)(y1 - 0.344f * u - 0.714f * v); b (int)(y1 1.772f * u); r r 0 ? 0 : r 255 ? 255 : r; g g 0 ? 0 : g 255 ? 255 : g; b b 0 ? 0 : b 255 ? 255 : b; rgb[i * 6 3] (BYTE)b; rgb[i * 6 4] (BYTE)g; rgb[i * 6 5] (BYTE)r; } }这种双像素共享 UV 的转换逻辑写起来不难但要注意 OpenCV 默认通道是 BGR 排列所以rgb[i*60]放 B、2放 R否则画面会蓝红互换。如果你对性能敏感这段代码可以改成 SSE 指令一次处理四个像素但在多路采集场景下真正占 CPU 的往往不是这个转换而是你后续的图像处理算法。另外提示一句如果设备原生输出 RGB24那直接cv::Mat(h, w, CV_8UC3, pBuffer)就能用记得pBuffer的行对齐一般没问题但有采集卡会做 4 字节对齐这时你需要用cv::Mat(h, w, CV_8UC3, pBuffer, step)手动指定行宽。5. 避坑与常见问题DirectShow 调试三天踩出来的五个坑5.1 坑一枚举不到设备但系统里明明有摄像头现象CreateClassEnumerator(CLSID_VideoInputDeviceCategory, ...)返回S_OK但Next一直返回S_FALSE列表是空的。系统设置里摄像头能正常工作。原因最常见的有两种。第一种是 64 位和 32 位程序混用如果系统装的是 64 位驱动而你编译的是 32 位程序某些摄像头驱动只注册在 64 位 COM 环境下32 位进程枚举不到。第二种是访问权限桌面应用如果以标准用户运行而摄像头被系统服务比如人脸识别登录占用枚举时会看到设备但打不开。解决先确认工程的Platform是 x64。如果是 32 位程序强制改成 x64 重新编译。权限问题可以用管理员身份跑一次验证但正规解法是不要在服务里直接采集摄像头把采集放在用户会话的进程中。我这个坑就是在远程桌面会话里调试时踩的本地登录能枚举到远程登录后设备直接消失。5.2 坑二设置 1920x1080 失败摄像头偷偷给你 640x480现象SetFormat返回成功但回调里拿到的帧宽高不是你要的只有 640x480而且颜色格式也变了。原因很多摄像头驱动有「假协商」问题。在GetStreamCaps里它列出了 1920x1080 30fps 的能力但实际SetFormat时它并不真正切换传感器模式只是把参数记在内部输出仍然走默认的标清模式。更隐蔽的是某些驱动在SetFormat后需要重新Run整个 Graph 才会让新格式生效如果你只是改了格式但没有重启 Graph它保持旧格式。解决每次修改格式后先Stop()再SetFormat最后重新Run()。如果还不行就把SetFormat的目标宽高换成列表里的下一个候选一个一个试找到驱动真正接受的那一个。我在一个国产摄像头项目里驱动只认 800x600 和 1280x720其他统统自动退回 640x480没办法只能把程序适配成 1280x720 流。5.3 坑三回调频率远超设定帧率CPU 占用暴增现象明明SetFormat里设了AvgTimePerFrame 33333330fps但回调每秒来 60 次甚至 90 次。原因Sample Grabber 的回调频率由上游 Filter 决定。对于 USB 摄像头DirectShow 的源 Filter 内部如果开着预览 Pin预览流会单独跑另一个帧率而你接的 Sample Grabber 可能同时接收了 Preview 和 Capture 两路流量。更常见的情况是驱动没有遵守AvgTimePerFrame它内部硬编码了最高帧率尤其是 MJPG 模式。解决不要依赖回调频率来限帧。在回调里按时间戳或系统时钟做节流超过设定间隔的帧直接丢弃。具体做法是在BufferCB里记录上一次处理的时间if (now - lastTime frameInterval) return S_OK;。另外确认你的RenderStream只连了PIN_CATEGORY_CAPTURE不要连 Preview Pin。还有一招是检查IAMStreamConfig::GetFrameRate的实际值有些驱动支持SetFrameRate但效果很差。5.4 坑四颜色格式串场画面发绿或变色现象画面整体偏绿、偏紫或者红蓝互换尤其是换了电脑或摄像头之后。原因DirectShow 的颜色格式协商经常出现「你以为你收到的是 RGB24实际上收到的还是 YUY2」的情况。因为 Sample Grabber 的SetMediaType只设了主类型为MEDIATYPE_Video具体 subtype 是 Graph 自己协商的。如果你在回调里盲目按 RGB24 处理就会解析错位。解决在回调里先检查ISampleGrabber::GetConnectedMediaType拿到当前实际连接的AM_MEDIA_TYPE看subtype是MEDIASUBTYPE_YUY2还是MEDIASUBTYPE_RGB24。然后走对应的转换分支。这是这类资源落地时被问得最多的一个问题很多人以为设置过就一定能拿到实际上在 Source Filter 和 Sample Grabber 之间还可能被其他 Filter 插了一脚。5.5 坑五程序退出后摄像头灯还亮着现象进程已经 Kill 掉但摄像头的指示灯还灭不了USB 设备被占用其他程序打不开。原因DirectShow Filter Graph 的释放顺序有问题。如果你只Release()了IGraphBuilder但没有显式Stop()并断开所有 Filter源 Filter 的流线程不会退出内核驱动还处于 streaming 状态。更隐蔽的是Sample Grabber 回调里如果持有帧缓冲的引用计数没释放整个 Graph 的引用计数归不了零。解决退出时必须按这个顺序执行先IMediaControl-Stop()然后枚举 Graph 里所有 Filter 并调用IFilterGraph::RemoveFilter逐个移除最后才Release各个接口。资源里CaptureGraph.cpp的DestroyGraph()函数已经按这个顺序写好了不要自己去Release后就 sleep。我自己的血泪经验是在OnExit里加一个WaitForSingleObject等待源 Filter 的内部线程退出信号再用CoUninitialize收尾从此再也没有设备占用问题。6. 进阶多路摄像头接入与视频录制6.1 多路采集的关键独立 Filter Graph 与线程亲和性多路摄像头采集最容易翻车的地方是把所有摄像头塞进同一个 Filter Graph。DirectShow 的 Filter Graph 是一个调度单元多路 Source Filter 共享一条时钟线某一路出问题会把整个图拖死。正确做法是每个摄像头独立创建一套 Graph独立的ICaptureGraphBuilder2、独立的IMediaControl、独立的 Sample Grabber 回调对象。这样一路摄像头断开只会影响它自己的 Graph其他路照样跑。线程安排上我给每个 Graph 单独开一个线程跑Run()回调里的BufferCB由该 Graph 的工作线程触发所以你的处理逻辑如果比较重记得把帧拷贝出来再丢到自己的处理线程池不要在回调里做同步处理。某开发者之前看到回调里直接cv::imshow结果 30fps 的流被拖到 12fps回调阻塞时间太长了。6.2 把捕获帧写成 AVI/MP4绕不开的封装顺序如果你要录制视频不要试图在 Sample Grabber 回调里自己拼 AVI 文件。DirectShow 里有现成的AVI MuxFilter 和File WriterFilter你只需把 Capture 链路连到 AVI Mux 上然后再连 File Writer让 Graph 自己负责帧封装和写入。资源里的 OpenCVDemo 展示的是文件录像的完整连线方式摄像头源 Filter - AVI Mux Filter - File Writer Filter - Sample Grabber - 你的回调这样同一路流既进了录像也进了内存回调两边独立消费。要注意的是AVI 封装要求所有帧经过 AVI Mux 时保持固定帧率否则文件时长会不对。编码器选择上用MJPEG Compressor是压缩比和速度的平衡点直接连 Uncompressed RGB 会让 AVI 文件大得离谱。录制结束的时机也有讲究。你必须在Stop()之前先断开 AVI Mux 和 File Writer 的连接否则文件尾部会缺索引块播放器看到的是「文件已损坏」。这个细节在 MSDN 里藏在IFileSinkFilter的说明中没人会主动告诉你。6.3 一个我可以分享的习惯验证摄像头参数是否生效自从在上一个项目中因为格式假协商吃了亏我现在拿到任何一台新摄像头都会先跑一遍资源里那个ConsoleDemo / capstest命令行工具让它把枚举到的设备、支持的所有媒体类型、最终协商的格式全部打印出来再确认回调里拿到的实帧尺寸和颜色格式。这套验证流程走完心里才有底后面写业务代码才不会被摄像头驱动那些反直觉行为打断。从那以后我每次接新项目都强制自己先花十分钟做这个参数验证而不是直接改业务代码。这一步省掉的后调式时间远比十分钟多。这套Dshow Capture资源里已经内置了这些工具你拿到手后建议直接把capstest跑一遍把每路摄像头的真实能力打在屏幕上再开工希望帮到你。本文还有配套的精品资源点击获取