简介:面向C# WinForm开发者的DirectShow二次开发示例工程,解决在托管环境中调用DirectShow.dll实现视频预览与音视频录制的问题。程序可自动识别并选择摄像头与麦克风,支持WMV、AVI两种带音频的录制格式,AVI可自定义分辨率与帧率,WMV做了清晰度优化;预览时可独立拍照,也能边录边拍,并集成音视频压缩功能。压缩包为rar格式,共32个文件,约201KB,包含4个C#源码文件、Visual Studio解决方案与工程配置、可直接运行的exe及pdb调试文件,以及升级报告、样式表和图片等辅助文档,目录结构清晰,适合对照学习完整项目组织。目前已有1212人学习/下载。作者因C# DirectShow资料稀缺而共享源码,读者可借此掌握摄像头、麦克风枚举选择、DirectShow图构建、录像与截图等关键实现,减少二次开发踩坑。
1. 用 DirectShow 摸透摄像头采集:C# WinForm 实现边预览边存视频、音频和图片
做设备采集类工具时,最烦的不是界面怎么写,而是“预览归预览、录像归录像”这条管道怎么搭才不打架。某次给某公司质检工位做摄像头采集 Demo,要求画面实时显示,同时把视频存成 AVI、把麦克风声音一起录进去,还要按一下快门存一张 JPG。当时查了一圈,最后落在 C# WinForm 直接调 DirectShow 的 COM 接口上。这套方案虽然老,但在 Windows 平台上做摄像头采集依然是性价比最高的路子:不用引一堆第三方包,dll 就在系统里,代码量可控制在几百行内。这篇笔记就把这个“边预览、边保存视频、音频和图片”的完整工程拆开讲透,适合要做小型监控客户端、设备调试工具、视觉样本采集的开发者。
2. 构建 DirectShow 采集图:设备枚举、Filter 选型与双路分流
2.1 设备枚举与 Filter 选型:为什么用 Smart Tee 做双路分流
DirectShow 的核心逻辑是“图”。摄像头是一个 Source Filter,预览窗口是一个 Renderer Filter,录 AVI 则要靠 AVI Mux 和 File Writer。最直观的做法是把摄像头直接连到 Video Renderer,但这样只能看不能录;连到 AVI Mux 吧,画面又没了。两边都要,就得在 Source Filter 后面分叉,常见做法是插入一个 Smart Tee Filter,它把一个输入脚分成 Preview 和 Capture 两个输出脚,Preview 管界面显示,Capture 管录像和采集。
真正干活时,我不推荐手动去连 Smart Tee 的各个 Pin。ICaptureGraphBuilder2 这个接口就是为这种事设计的,你给它指定“预览”或“捕获”类别,它会自动在中间补上 Smart Tee 或者不补,然后到达目标。工具封装好了,但背后的原理得清楚:预览路径若不需要录像,可以直连渲染器,开销小;一旦要同时录像,Smart Tee 是必然出现的一环,它会复制一份数据流向下游,所以对内存带宽有一定占用,分辨率推到 4K 时能看到 CPU 和内存占用上涨,这是正常的,不是代码写错了。
设备枚举部分要用 ICreateDevEnum 遍历系统里注册的 DirectShow 设备类别。视频采集设备类别是 CLSID_VideoInputDeviceCategory,音频采集设备是 CLSID_AudioInputDeviceCategory。枚举结果是 IMoniker 列表,每个 Moniker 绑定后就是一个 IBaseFilter,能加进图。这一步很多人直接跳过,写死设备名,结果换台电脑就翻车,所以枚举这块不建议省。
// 枚举视频采集设备,返回第一个可用设备对应的 IBaseFilter ICreateDevEnum devEnum = (ICreateDevEnum)new CreateDevEnum(); IEnumMoniker enumMoniker = null; Guid videoCategory = CLSID_VideoInputDeviceCategory; devEnum.CreateClassEnumerator(videoCategory, out enumMoniker, 0); if (enumMoniker == null) throw new Exception("没有找到视频采集设备"); IMoniker[] moniker = new IMoniker[1]; IntPtr fetched = IntPtr.Zero; IBaseFilter videoSource = null; while (enumMoniker.Next(1, moniker, fetched) == 0) { object filterObj = null; moniker[0].BindToObject(null, null, ref IID_IBaseFilter, out filterObj); videoSource = filterObj as IBaseFilter; moniker[0].Release(); if (videoSource != null) break; }这段代码的关键参数有三个:CreateClassEnumerator 传的类别 GUID 决定枚举的是视频还是音频;BindToObject 的 IID 必须是 IBaseFilter,否则拿不到 Filter 接口;最后一定要 Release 掉 Moniker,否则设备切换时可能锁住资源。音频设备枚举同理,把类别换成 CLSID_AudioInputDeviceCategory,得到的是音频采集 Filter。在工程里视频和音频各走一遍枚举,分别入图,后面 RenderStream 才能把两路数据合进同一个 AVI 文件。
2.2 代码实战:RenderStream 一次连好预览、录制和文件输出
图构建的顺序有讲究。先把视频源、音频源、Sample Grabber 都 AddFilter 进图,再调 ICaptureGraphBuilder2 的 RenderStream 把管道连起来。注意 RenderStream 的调用顺序:文件输出要先 SetOutputFileName 建好 AVI Mux 和 File Writer,再把 Mux 作为目标传进去。否则你先 RenderStream 了预览,再 SetOutputFileName,构建器可能给你再拉一条多余的路径。
// 创建 Filter Graph 和 Capture Graph Builder IGraphBuilder graph = (IGraphBuilder)new FilterGraph(); ICaptureGraphBuilder2 capBuilder = (ICaptureGraphBuilder2)new CaptureGraphBuilder2(); capBuilder.SetFiltergraph(graph); // 视频源、音频源、Sample Grabber 全部加入图 graph.AddFilter(videoSource, "VideoSource"); graph.AddFilter(audioSource, "AudioSource"); ISampleGrabber grabber = (ISampleGrabber)new SampleGrabber(); graph.AddFilter(grabber, "FrameGrabber"); // 先建 AVI 输出文件和 Mux IBaseFilter aviMux = null; IFileSinkFilter fileSink = null; capBuilder.SetOutputFileName(MediaSubType.Avi, "output.avi", out aviMux, out fileSink); // 视频预览路径:自动接 Video Renderer 到窗口 capBuilder.RenderStream(PinCategory.Preview, MediaType.Video, videoSource, null, null); // 视频捕获路径:Smart Tee -> Sample Grabber -> Null Renderer capBuilder.RenderStream(PinCategory.Capture, MediaType.Video, videoSource, grabber, null); // 视频捕获路径:Smart Tee -> AVI Mux capBuilder.RenderStream(PinCategory.Capture, MediaType.Video, videoSource, null, aviMux); // 音频捕获路径:音频源 -> AVI Mux capBuilder.RenderStream(PinCategory.Capture, MediaType.Audio, audioSource, null, aviMux);这里最容易误解的是 RenderStream 最后一个参数,它表示“目标 Filter 的输出端承接者”。传 null 表示用默认 Renderer;传 aviMux 表示路径最终接到 AVI Mux;传 grabber 时,构建器会给 grabber 后面补一个 Null Renderer,保证数据有下游接收。因为 Sample Grabber 本质是一个旁路采样器,数据经过它时会触发回调,同时原样透传给下游,所以视频捕获路径上 Sample Grabber 和 AVI Mux 可以并存:Smart Tee 的 Capture Pin 会分给两条下游。若不想让构建器乱猜,也可以先把 Sample Grabber 手动连到 Smart Tee 上,再 RenderStream 到 Mux,但工程里让构建器自动处理更省事。
预览路径 RenderStream 返回后,构建器会自动添加 Video Renderer,并把预览窗口句柄挂到当前进程。如果没指定窗口,画面上会弹一个独立窗口,做 WinForm 嵌入时要拿到 PANEL 的 Handle 传给 Renderer 的窗口设置接口,这点在第 3 章展开。
| 路径 | 使用的 Pin 类别 | 媒体类型 | 目标 |
|---|---|---|---|
| 预览 | PIN_CATEGORY_PREVIEW | MEDIATYPE_Video | Video Renderer |
| 视频录制 | PIN_CATEGORY_CAPTURE | MEDIATYPE_Video | Sample Grabber + AVI Mux |
| 音频录制 | PIN_CATEGORY_CAPTURE | MEDIATYPE_Audio | AVI Mux |
| 抓帧存图 | PIN_CATEGORY_CAPTURE | MEDIATYPE_Video | Sample Grabber 回调 |
图构建完,调用 IMediaControl.Run() 开始跑。此时画面应该已经显示在窗口里,AVI Mux 开始接收数据。但别急着高兴,Sample Grabber 的回调配置如果不对,后面录制、抓图全都会踩坑。这就是下一章的重点。
3. 边预览边保存的实现:Sample Grabber 回调与 AVI 实时写入
3.1 BufferCB 回调:从流线程复制帧数据的正确姿势
Sample Grabber 要工作,需要先配置媒体类型和回调接口。媒体类型建议设成 RGB24,这样回调里拿到的帧数据可以直接转成 Bitmap;如果设成 YUY2 或 NV12,转 Bitmap 前还得先做颜色空间转换,平白多一层麻烦,而且 DirectShow 的 Color Space Converter 是否自动插入还要看运气。
// 配置 Sample Grabber 的媒体类型为 RGB24 AM_MEDIA_TYPE mt = new AM_MEDIA_TYPE(); mt.majortype = MediaType.Video; mt.subtype = MediaSubType.RGB24; mt.formattype = FormatType.FormatInfo; grabber.SetMediaType(mt); // 设置回调 grabber.SetCallback(new SampleGrabberCallback(), 0);回调接口是 ISampleGrabberCB,核心方法是 BufferCB。这个方法运行在 DirectShow 的流线程上,不是 UI 线程,所以里面绝对不能直接碰 WinForm 控件,否则界面会卡死。正确做法是把数据拷到托管数组,扔进队列,让后台线程去处理。
public int BufferCB(double SampleTime, IntPtr pBuffer, int BufferLen) { // pBuffer 指向非托管内存,必须拷贝出来再用 byte[] frame = new byte[BufferLen]; Marshal.Copy(pBuffer, frame, 0, BufferLen); // 入队,交给后台线程解析帧、统计、存档 _frameQueue.Enqueue(frame); Interlocked.Increment(ref _callbackCount); return 0; }这段代码的关键是 Marshal.Copy。DirectShow 流线程管理的内存生命周期很短,BufferCB 返回后那块缓冲区可能马上被回收或覆写,所以必须在回调内复制。BufferLen 是字节数,不是像素数也不是行数,后续解析 Bitmap 时要结合宽、高、stride 算。SampleTime 的单位是 100 纳秒,不是毫秒,做时间戳换算时除 10000 才是毫秒,我见过有人直接拿它当毫秒用,结果时间轴全乱。
帧数据入队后,真正写 AVI 的另有其人。AVI Mux 已经在图管道的下游,它拿到的是原始流,不需要回调参与。回调副本更适合做三件事:抓帧存图、帧率统计、后续二次算法处理。搞清楚这条边界能避免“在回调里写文件”这种常见误用——直接写磁盘会让流线程阻塞,掉帧是小事,严重时 DirectShow 内置的 QoS 会主动丢缓冲。
3.2 视频、音频进 AVI:RenderStream 到 AVI Mux 的管道设置
保存视频不是手动把帧写成文件,而是靠图管道自己流。前面第 2 章的 RenderStream 已经把视频源和音频源都接进了 AVI Mux。当 IMediaControl.Run() 执行后,AVI Mux 会把视频帧和音频样本交织封装,File Writer 最终落盘。这套机制是 DirectShow 自带的,工程里不需要写 AVI 文件头的代码,但配置不对会出现文件打不开或音画不同步。
音频采样率要先配置好。多数 USB 麦克风支持 44100Hz 和 48000Hz 两种采样率,16 位单声道或双声道。我习惯固定成 44100Hz、16 位、双声道,因为 AVI Mux 对 PCM 音频的兼容性最好,兼容性最稳。用 IAMStreamConfig 接口拿到音频源的配置能力,再 SetFormat 指定 WAVEFORMATEX。
// 配置音频采集格式为 44100Hz 16 位双声道 IAMStreamConfig audioStreamConfig = audioSource as IAMStreamConfig; int count = 0, size = 0; audioStreamConfig.GetNumberOfCapabilities(out count, out size); if (count > 0) { AMMediaType capType = null; audioStreamConfig.GetStreamCaps(0, out capType); WaveFormatEx wf = new WaveFormatEx(); wf.wFormatTag = 1; // PCM wf.nChannels = 2; // 双声道 wf.nSamplesPerSec = 44100; wf.wBitsPerSample = 16; wf.nBlockAlign = (ushort)(wf.nChannels * wf.wBitsPerSample / 8); wf.nAvgBytesPerSec = wf.nSamplesPerSec * wf.nBlockAlign; audioStreamConfig.SetFormat(capType); }nBlockAlign 这个参数特别容易错。它等于声道数乘以采样位数除以 8,双声道 16 位就是 4 字节。DirectShow 音频回调的 BufferLen 通常是一个完整样本块的整数倍,但某些采集驱动会给出不整齐的结尾,按 nBlockAlign 对齐裁剪才能保证写入 AVI 的数据不出现半帧。关于音画同步,AVI Mux 默认交织粒度是 0.5 秒,若想更精细,可以拿到 IConfigInterleaving 接口设成更小的交织间隔,但不要低于 0.1 秒,否则文件体积膨胀明显。
3.3 抓帧存图:RGB24 转 Bitmap 的步长计算与清晰度控制
抓帧不做单独重录,直接从 BufferCB 的帧数据里取最新一帧构造 Bitmap。构造前先看帧头里的 BITMAPINFOHEADER,拿到宽度、高度和 biSizeImage。很多摄像头输出的每行字节数是按 4 字节或 32 字节对齐的,直接拿 width * 3 去算 stride 会踩到“底边斜纹”的坑。
// 从帧数据构造 Bitmap 并保存为 JPG BitmapInfoHeader bmi = GetBitmapInfoHeader(frameData); int width = bmi.biWidth; int height = bmi.biHeight > 0 ? bmi.biHeight : -bmi.biHeight; int stride = ((width * 3 + 3) / 4) * 4; // 按 4 字节对齐 Bitmap bmp = new Bitmap(width, height, stride, PixelFormat.Format24bppRgb, pBuffer); bmp.Save("snapshot_" + DateTime.Now.ToString("HHmmssfff") + ".jpg", ImageFormat.Jpeg); bmp.Dispose();stride 计算看起来简单,但图像源若输出宽度为奇数值,例如 1278 像素,每行像素字节数是 3834,刚好不是 4 的倍数,对齐后变成 3836,这时候直接用 width*3 构造 Bitmap 就会出现画面右移或斜线。以 BITMAPINFOHEADER 里的 biSizeImage 校验一下最保险,它通常已经是对齐后的总数。负的 biHeight 表示图像是自顶向下的,处理时要取绝对值,否则 Bitmap 上下颠倒。
清晰度控制上,抓图不要从队列里一帧帧按顺序存,而是定时取队列里最新的一帧。比如 500 毫秒取一次,避免用户按一次快门存出好几张几乎一样的图。截图的时机最好安排在下游没有大帧率波动时,也就是队列长度稳定的时候,否则取到的帧可能是 DirectShow 正在切换分辨率时的残缺帧。
4. 避坑实录:DirectShow 在 WinForm 下的五个隐蔽问题
4.1 回调里直接碰 UI 控件:界面卡死与白屏
现象:程序跑起来,预览窗口能出画面,但只要视频一开始回调,窗体就会无响应,拖拽窗口直接白屏。
原因:BufferCB 运行在 DirectShow 的流线程,线程池里跑着采集循环,你去直接赋 Text 或改控件颜色,WinForm 控件线程模型会尝试跨线程更新,轻则抛异常,重则让消息泵停转,界面整个冻结。
解决:回调里把数据或统计值塞进 ConcurrentQueue,再用 System.Windows.Forms.Timer 在 UI 线程上每 200 毫秒取队列内容刷新界面。或者用 SynchronizationContext.Post 把 UI 更新动作投递到主线程。不要用 Invoke,高频回调下 Invoke 的同步等待会拖慢采集线程。
4.2 设为 RGB24 后预览还是黑屏:媒体类型协商不通过
现象:Sample Grabber 的 SetMediaType 设了 MEDIASUBTYPE_RGB24,回调也能收到数据,但预览窗口一片黑,视频文件却正常。
原因:预览路径和 Sample Grabber 走的不是同一个媒体类型。设备源输出是 YUY2,预览渲染器能吃 YUY2,但 Sample Grabber 强制要 RGB24,DirectShow 有时不会自动在预览路径插颜色转换器,导致预览黑屏。
解决:在 SetMediaType 前先调用 grabber.SetBufferSamples(true),强制 Sample Grabber 向下游请求 RGB24;如果再不行,手动在图上添加 Color Space Converter Filter,插在视频源和 Video Renderer 之间。也可以把 Sample Grabber 的媒体类型设成 MEDIASUBTYPE_YUY2,在回调里自己转 RGB24,但性能损耗明显,不建议。
4.3 录像文件打不开或播放器无法拖动:AVI Mux 索引缺失
现象:录像停止后 output.avi 文件存在,但播放器打开提示“文件格式不正确”或只能从头播,拖进度条直接卡死。
原因:AVI 文件能否拖动靠的是文件尾部的索引块。DirectShow 的 AVI Mux 默认索引模式是“完成后建立索引”,若停止流程没走完,或 File Writer 没有正确调用 IPersistFile::Save,索引块就写不进去。
解决:停止时按顺序操作,先 IMediaControl.Stop(),再调用 IMediaEventEx.WaitForCompletion,确保所有样本排空,最后让 File Writer 完成落盘。不要 Stop 完立刻释放 COM 对象。若还是不行,拿到 IConfigAviMux 接口,把 SetOutputCompatibilityIndex 设为 TRUE,生成兼容索引。
4.4 音频保存出来全是噪音:nBlockAlign 对齐和采样格式不匹配
现象:录像里有画面,声音是刺耳的爆音或持续噪声,波形图上看到大量不连续的毛刺。
原因:音频回调 BufferLen 里可能含末段不完整样本。比如 nBlockAlign 是 4 字节,某次回调长度是 1022 字节,按字节直接写文件就会出现半帧,解码器读到的 PCM 数据错位,噪声随之而来。
解决:回调里对音频数据做对齐裁剪,只写入 nBlockAlign 整数倍长度的部分,剩余半帧丢弃。同时检查音频源输出的 WAVEFORMATEX 与实际回调数据是否一致,摄像头自带麦克风和 USB 麦克风驱动输出的声道数常常默认不同,统一在 IAMStreamConfig 里固定成双声道 16 位。
4.5 Stop 后程序崩溃:回调还没结束就释放了图
现象:点停止按钮,程序大概率在清理解析和关闭窗口时抛 AccessViolation 异常,崩溃点在非托管代码里。
原因:IMediaControl.Stop() 返回后,可能还有一两个 BufferCB 正处于执行中,引用计数没归零就把图、回调对象、队列全部释放,流线程随后访问了已释放内存。
解决:用 Interlocked 计数器记录回调进入和退出次数,Stop 之后忙等计数器归零,超时置 2 秒。固件代码如下,每进入一次回调加一,退出减一,等归零再释放图。
void StopCapture() { mediaControl.Stop(); DateTime deadline = DateTime.Now.AddSeconds(2); while (Interlocked.Read(ref _callbackCount) > 0 && DateTime.Now < deadline) { Thread.Sleep(10); } ReleaseGraph(); }这个等待逻辑看似简单,但漏掉的人极多。我经手过的采集工具里,七八成崩溃都出在这 20 行代码的位置,而不是复杂业务环节。
5. 运行时调试与验证:不看图的管道路径,等于盲调
5.1 用 GraphEdit 远程连接运行中的程序
DirectShow 图搭建完直接跑,出了问题往往很难定位是哪个 Pin 没连通。GraphEdit 这类工具可以把运行中的 Filter Graph 图形化展示出来,但要先在程序里把图注册到 Running Object Table(ROT)。
// 图创建后注册到 ROT,供 GraphEdit 远程查看 uint rotReg = 0; IRunningObjectTable rot = null; IMoniker moniker = null; GetRunningObjectTable(0, out rot); CreateItemMoniker("!", "FilterGraph", out moniker); rot.Register(0, graph, moniker, out rotReg);注册成功后,打开 GraphEdit(或 GraphStudioNext),菜单里选“Connect to Running Graph”,选择对应进程,就能看到完整的图结构。检查三个关键点位:视频源到 Smart Tee 是否分出两条路;Sample Grabber 后面是否有 Null Renderer;AVI Mux 是否同时接管了视频和音频两个输入 Pin。在任意 Pin 上右键查看媒体类型,能直接看出是不是 RGB24,省去反复猜。注意 GraphEdit 是 32 位进程,你的程序若是 64 位,远程连接可能失败,GraphStudioNext 对这个问题的处理更好,建议直接用后者。
5.2 验证视频帧进入回调:帧计数器和 PTS 时间戳
画面黑、文件空,先确认是不是根本没数据流到 Sample Grabber。在回调里加一个帧计数逻辑,配合定时输出就能知道采集链路通没通。
int _frameCount = 0; Stopwatch _sw = Stopwatch.StartNew(); long _lastFrameCount = 0; long _lastTickMs = 0; // BufferCB 内部 Interlocked.Increment(ref _frameCount); if (_sw.ElapsedMilliseconds - _lastTickMs >= 1000) { long fps = _frameCount - _lastFrameCount; _lastFrameCount = _frameCount; _lastTickMs = _sw.ElapsedMilliseconds; Debug.WriteLine("采集帧率: " + fps); }如果帧计数持续为 0,说明媒体类型协商失败或 Source 没有数据;如果帧率远低于设定值,比如 30 帧变成 5 帧,说明上游设备驱动或 USB 带宽不足。另外可以借用 SampleTime 参数换算成毫秒后打印,观察时间戳是否单调递增。时间戳出现回跳,大概率是设备在自动切换分辨率和帧率。
5.3 验证音频数据长度:用 WaveFormat 计算帧大小
音频问题比视频隐蔽。给音频回调加一个长度统计日志,打印每次 BufferLen 和当前 nBlockAlign 的取模结果。
// 音频回调里检查长度是否对齐 int blockAlign = channels * bitsPerSample / 8; if (bufferLen % blockAlign != 0) { Debug.WriteLine("音频长度未对齐: " + bufferLen + " mod " + blockAlign); }正常情况下,BufferLen 应该是 blockAlign 的整数倍。打印机出现非零余数,就得在写入前做截断处理。还有一种情况是设备源输出的是 float 格式而非 PCM,单从 length 看不出问题,切片出来的波形全是噪声。这时要把 AM_MEDIA_TYPE 的 subtype 打印出来,确认是 MEDIASUBTYPE_PCM 而不是 MEDIASUBTYPE_IEEE_FLOAT,或者去 IAMStreamConfig 里强制 SetFormat 成 16 位 PCM,从源头卡死格式。
6. 进阶:把 Sample Grabber 的数据再接一层,做动态截图与二次编码
到这里,采集、预览、录像、抓图已经能跑通。但工程要真正落地,还得把 Sample Grabber 的数据再加工一层。我最常做的动作是加一个后台队列线程,专门消费回调里产生的帧副本,让截图和算法分析与采集线程彻底解耦。
// 后台消费线程:每 500ms 取最新帧存图 Task.Run(() => { while (!_stopFlag) { if (_frameQueue.TryDequeue(out byte[] frame)) { // 转 Bitmap、保存、或喂给检测算法 SaveLatestFrame(frame); } Thread.Sleep(50); } });这个模式的好处是,回调只管拷贝和入队,消费线程按自己的节奏做重活——存 JPG、算直方图、跑检测模型,都不会反压到 DirectShow 流线程上。抓图频率想更精细,可以给每帧打上入队时间戳,消费端按时间戳切分,比如只保留相邻两张间隔超过 500ms 的帧,做定时采样就很顺手。再进一步,这个队列里的帧副本完全可以喂给 VLCLib 或 FFmpeg 做二次编码,DirectShow 只管实时采集,保存格式就交出更灵活的工具去处理,两个环节各司其职。
回到标题说的这份工程,它真正省时间的地方在于:预览、录像、音频、抓图四件事在一条管道里跑完,不用绕道第三方控件库。我从那次采集 Demo 之后,每接一台新摄像头都会强制先走一遍整套验证流程:先 GraphStudioNext 看图结构,再跑帧率计数器,最后做一次含音频的完整录像,确认 WaveFormat 对齐无误后才封装出门。这套习惯让我后来接各种 USB 摄像头基本一次过。如果你手里正好也在做类似的采集工具,照着这套工程拆开看,能少走不少弯路,希望帮到你。
本文还有配套的精品资源,点击获取