C# WinForm基于DirectShow实现摄像头预览录像与截图
2026/9/12 1:37:39 网站建设 项目流程

简介:一套基于DirectShow.dll的C# WinForm程序,实现了边预览边保存视频、音频与图片的功能。面向需要开发摄像头与麦克风采集录制类应用的C#开发者,程序能自动识别并选择接入的设备(默认选中第一个),支持录制带有音频的WMV与AVI视频,其中AVI可自定义分辨率与帧率,WMV自带清晰度优化;预览状态下既可单独拍照,也能在录制过程中边录边拍,并集成了视频与音频的压缩能力。压缩包内共32个文件,涵盖C#源代码、解决方案、可执行程序、配置文件及说明文档等,解压后便于直接运行验证或二次开发,总大小仅201KB。目前已有945人学习/下载。作者因发现C#下DirectShow的相关资料较少,特意共享源码,读者可从中掌握设备枚举与选择、录制参数设置、音视频压缩等直接可复用的实现思路,适合作为多媒体采集功能模块的参考基础。 做桌面端摄像头采集,绕不开一个老伙计:DirectShow。虽然微软后来推出了Media Foundation,但在工控机、教学录播、医疗影像这些场景里,DirectShow的老牌生态和稳定性依然有很大市场。我最近用C# WinForm封装了一个基于DirectShow.dll的采集程序,核心目标很朴素:启动之后一边实时预览画面,一边把摄像头画面和麦克风声音同步写进视频文件,同时支持随时截取当前帧保存为图片。这套流程走通之后,你会发现很多所谓的“视频采集软件”核心骨架其实就是这么回事。

这篇文章把整个实现思路、Graph链路构建、关键接口调用和踩过的坑一次性梳理清楚,适合正在做摄像头采集、视频留存、上位机配套工具的朋友参考。代码基于VS2022 + .NET Framework 4.8 + DirectShow(通过Quartz.dll托管包装),视频封装用AVI,音频用PCM。如果你要的是MP4/H.264,文末也会给兼容方案。

1. 方案选型:DirectShow凭什么还能打

碰到视频采集,很多人的第一反应是OpenCV或者厂商SDK。OpenCV接USB摄像头确实方便,VideoCapture一开就能读帧,但有两个问题很难绕开:一是OpenCV的读帧和编码是CPU密集操作,长时间跑容易发热掉帧;二是音频同步采集OpenCV基本不管,你得另起炉灶。厂商SDK(比如海康、大华的网络SDK)在自家设备上很强,但绑定硬件,换一个品牌摄像头就要重新适配。

DirectShow是Windows上最成熟的流媒体框架,它把摄像头、麦克风、编码器、文件写入统统抽象成Filter,开发者只需要把Filter像积木一样串成一条Graph,剩下的调度交给框架自己。对C#开发者来说,虽然DirectShow是COM组件,但通过Quartz.dll这个托管包装,调用起来并不比调用普通类库复杂多少。

这套方案最舒服的地方在于Preview和Capture天然并行。你把Graph串成预览分支和文件分支,二者共用同一个Source Filter的pin,视频流同时往两条路走,一边在窗体上渲染画面,一边通过SampleGrabber把帧数据交给写入线程。音频同理,走独立的采集通道,混流交给AVI Mux完成。整个过程中,信号从采集到呈现是一条流水线,不占额外线程,不需要你手工管理帧率。

2. 前置准备:Graph过滤器的核心元件

DirectShow的Graph是一个有向图,Filter是节点,Pin是连接点。要实现“边预览边保存”,整条链路至少需要四类Filter。

角色典型Filter作用
采集源USB Camera / 麦克风设备从硬件抓取原始音视频流
预览分支Video Renderer把视频帧绘制到窗体句柄上
抓取分支SampleGrabber截获每一帧原始数据,用于截图或自定义处理
存储分支AVI Mux + File Writer音视频流混合并写入磁盘文件
using QuartzTypeLib; // 创建Graph管理器 FilgraphManager graphManager = new FilgraphManager();

FilgraphManager是整个Graph的总调度器,它的底层就是IGraphBuilder。你在Windows目录下能找到quartz.dll,这是系统自带的DirectShow运行库,不用额外安装。在C#工程里添加COM引用,选择QuartzTypeLib即可。

另外还需要引入DirectShowLib-2005这个开源类库(NuGet上直接搜DirectShowLib),它提供了ISampleGrabberIBaseFilter等接口的完整定义,比手写COM互操作省事太多。

3. 构建视频采集链路:从设备枚举到画面预览

3.1 枚举系统摄像头和麦克风

采集第一步是把设备找出来。DirectShow做了一个系统设备枚举器,你只需遍历这个枚举器筛选出视频输入设备和音频输入设备类别。

using DirectShowLib; using System.Runtime.InteropServices; private List<string> GetVideoDevices() { var devices = new List<string>(); var comEnum = new DsDevice[0]; DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice, out comEnum); foreach (var device in comEnum) { devices.Add(device.Name); } return devices; } private List<string> GetAudioDevices() { var devices = new List<string>(); var comEnum = new DsDevice[0]; DsDevice.GetDevicesOfCat(FilterCategory.AudioInputDevice, out comEnum); foreach (var device in comEnum) { devices.Add(device.Name); } return devices; }

注意DsDevice在枚举完设备后需要释放COM资源,否则反复打开关闭会造成设备占用不释放。我在实际项目里用using语句包裹枚举逻辑,确保每个设备对象都被正确处理。

3.2 把设备Filter接入Graph

拿到设备名称后,用AddSourceFilter按名字创建源Filter并加入Graph。如果设备名有变化(比如同一型号的两个摄像头),按FriendlyName匹配偶尔会撞车,稳妥做法是通过设备实例ID或物理路径去精确匹配,不过大多数普通应用场景按名字足够了。

IBaseFilter sourceFilter = graphManager.AddSourceFilterForMoniker( deviceMoniker, null, "VideoSource");

AddSourceFilterForMoniker是DirectShowLib扩展方法,它接受设备Moniker来创建源Filter。这是最干净的方式,不经过设备名匹配,直接用设备唯一标识。如果你手头的环境没有这个方法,就退回AddSourceFilter(deviceName)

3.3 预览渲染管线

源Filter有一个输出Pin,画面默认是走Preview Pin(如果硬件支持单独预览Pin)或直接走输出Pin。我们需要把这个Pin连到VideoRenderer,并把渲染窗口句柄设置成WinForm的panel1.Handle,这样画面就直接显示在界面里了。

// 创建视频渲染器 IBaseFilter videoRenderer = (IBaseFilter)new VideoRendererDefault(); // 将渲染器加入Graph graphManager.AddFilter(videoRenderer, "VideoRenderer"); // 连接源Filter输出Pin和渲染器输入Pin IPin sourceOut = DsFindPin.ByDirection(sourceFilter, PinDirection.Output, 0); IPin rendererIn = DsFindPin.ByDirection(videoRenderer, PinDirection.Input, 0); graphManager.ConnectDirect(sourceOut, rendererIn, null); // 设置预览窗口句柄 IVideoWindow videoWindow = (IVideoWindow)graphManager; videoWindow.Owner = panel1.Handle; videoWindow.WindowStyle = WS_CHILD | WS_CLIPSIBLINGS; videoWindow.SetWindowPosition(panel1.ClientRectangle); videoWindow.put_Visible(OABool.True);

这一步走通之后,画面就能在窗体上实时显示了。graphManager.ConnectDirect是直连,要求两端pin的媒体类型必须兼容,绝大多数USB摄像头输出YUY2或MJPG,VideoRendererDefault都能消化。如果直连失败,可以让Graph自动查找中间转换Filter,用graphManager.Connect代替,它能自动插入色彩空间转换器。

4. 音视频同步保存:SampleGrabber拦截帧数据

预览只是前半场,真正的重头戏是把数据同步落盘。这里有两种路线:一条是硬接线,用AVI Mux把所有数据交给File Writer;另一条是软回调,用SampleGrabber拦截每一帧数据,自己编码写文件。

4.1 AVI Mux方案(简单省心但灵活性差)

思路极简单:视频源、音频源 -> AVI Mux -> File Writer。AVI Mux内部自动处理音视频时间戳对齐和交织,最终写出的AVI文件在播放器里音画同步。

// 添加AVI Mux和File Writer IBaseFilter aviMux = (IBaseFilter)new AviMux(); IBaseFilter fileWriter = (IBaseFilter)new FileWriter(); graphManager.AddFilter(aviMux, "AVI Mux"); graphManager.AddFilter(fileWriter, "File Writer"); // 设置输出文件路径 IFileSinkFilter sink = (IFileSinkFilter)fileWriter; sink.SetFileName(@"D:\Record\video.avi", null); // 走GraphBuilder.Render接管自动连接 graphManager.RenderStream(PinCategory.Capture, MediaType.Video, sourceFilter, null, aviMux); graphManager.RenderStream(PinCategory.Capture, MediaType.Audio, audioSourceFilter, null, aviMux); graphManager.ConnectDirect( DsFindPin.ByDirection(aviMux, PinDirection.Output, 0), DsFindPin.ByDirection(fileWriter, PinDirection.Input, 0), null);

RenderStream会自动完成源Filter输出Pin到AVI Mux之间的连接,包括插入必要的编码Filter(比如视频压缩器)。这个方案缺点是编码方式不可控——默认可能用未压缩RGB,文件体积大得吓人,而且无法中途做自定义处理(比如叠加时间戳、画面裁剪)。

4.2 SampleGrabber回调方案(可控性高,推荐)

我更推荐用SampleGrabber。它是一个透明Filter,不修改数据,只把每一帧内容复制到回调函数里。你可以在这里把帧转成Bitmap实现截图,同时也可以把原始数据交给自己的编码器存文件。

核心实现如下:

ISampleGrabber sampleGrabber = (ISampleGrabber)new SampleGrabber(); var mediaType = new AMMediaType(); mediaType.majortype = MediaType.Video; mediaType.subtype = MediaSubType.RGB24; sampleGrabber.SetMediaType(mediaType); sampleGrabber.SetBufferSamples(true);

注意SetMediaType这一步非常关键。你必须显式请求RGB24格式,否则回调收到的往往是YUY2原始数据,你自己转RGB会多一层计算开销,而且格式判断很容易出错。设置好媒体类型后,把Grabber插入视频源和渲染器之间:

graphManager.AddFilter((IBaseFilter)sampleGrabber, "SampleGrabber"); // 源Filter输出 -> SampleGrabber输入 IPin srcOut = DsFindPin.ByDirection(sourceFilter, PinDirection.Output, 0); IPin grabIn = DsFindPin.ByDirection((IBaseFilter)sampleGrabber, PinDirection.Input, 0); graphManager.ConnectDirect(srcOut, grabIn, null); // SampleGrabber输出 -> 渲染器输入 IPin grabOut = DsFindPin.ByDirection((IBaseFilter)sampleGrabber, PinDirection.Output, 0); IPin renderIn = DsFindPin.ByDirection(videoRenderer, PinDirection.Input, 0); graphManager.ConnectDirect(grabOut, renderIn, null);

在回调里拿帧数据:

sampleGrabber.SetCallback(new SampleGrabberCallback(), 1); public class SampleGrabberCallback : ISampleGrabberCB { public int SampleCB(double sampleTime, IMediaSample pSample) { // 从pSample取数据 int size = pSample.GetSize(); IntPtr ptr = Marshal.AllocCoTaskMem(size); pSample.GetPointer(out ptr); byte[] bytes = new byte[size]; Marshal.Copy(ptr, bytes, 0, size); Marshal.FreeCoTaskMem(ptr); BufferBlock(buffer); // 交给你的写入线程 return 0; } public int BufferCB(double sampleTime, IntPtr pBuffer, int bufferLen) { return 0; } }

这里有一个关键点:SampleCB是在DirectShow的工作线程上调用的,频率跟摄像头帧率一致(一般25/30fps)。线程里千万不要做锁文件的IO操作,否则会阻塞采集线程导致掉帧。正确姿势是把数据塞进阻塞队列(比如ConcurrentQueue),让后台线程以恒定速度消费写入文件。

音频采集的SampleGrabber同理,设置MediaType.Audio,拿到PCM字节流,按时间戳写入文件。如果音视频分文件写,后期用FFmpeg合并;如果合一个文件,更稳妥的做法是音频和视频都抓原数据,用Bass或FFmpeg库自己混流。

4.3 图片抓拍

图片抓拍本质上就是利用SampleGrabber缓冲区的数据。Grabber设了SetBufferSamples(true)之后,最新的那一帧会一直存在缓冲区里,抓到的是实际保存的最近一帧,操作简单。更精确的截图是走回调线程里转Bitmap,然后把截图加入一个“截图像素队列”,UI线程定时查询并保存,这样能做到“按下截图键就立刻保存当前帧”,而不是延迟到等到下一帧才处理。

private void SaveSnapshot(string path) { int bufferSize = 0; sampleGrabber.GetCurrentBuffer(ref bufferSize, IntPtr.Zero); byte[] buffer = new byte[bufferSize]; IntPtr ptr = Marshal.AllocCoTaskMem(bufferSize); sampleGrabber.GetCurrentBuffer(ref bufferSize, ptr); Marshal.Copy(ptr, buffer, 0, bufferSize); Marshal.FreeCoTaskMem(ptr); using (Bitmap bmp = new Bitmap(width, height, stride, PixelFormat.Format24bppRgb, Marshal.UnsafeAddrOfPinnedArrayElement(buffer, 0))) { bmp.Save(path, ImageFormat.Jpeg); } }

这里GetCurrentBuffer拿到的缓冲区大小是固定的,如果你不提前查询宽高和stride,很容易踩错位。宽高获取可以在SetMediaType后用sampleGrabber.GetConnectedMediaType拿到VIDEOINFOHEADER结构。

5. 实操过程的调试心法

5.1 Graph状态机的管理

DirectShow Graph是有状态机的:Stopped -> Paused -> Running。保存前必须等Graph进入Running状态,否则文件写入器拿不到数据。我用一个状态位记录当前状态:

public enum CaptureState { Idle, Recording } private CaptureState _state = CaptureState.Idle;

开始保存时,点下按钮先切状态、准备文件流,然后调用graphManager.Run()。停止保存时先graphManager.Stop(),让所有Filter清空缓冲,再关闭文件流。这两个顺序不能反,否则文件末尾会缺失数据,播放时最后几秒画面花掉。

5.2 停止预览和释放资源

程序关闭时最大的坑是COM对象没有干净释放,导致摄像头被占用,下一次打开软件提示“设备被占用”。正确的释放顺序是:先停止Graph -> 断开所有Pin -> 释放所有Filter引用 -> 置空FilgraphManager引用 -> 调用Marshal.ReleaseComObject。其中断开Pin必须遍历每个Filter的所有Pin,逐个断开。

public void StopCapture() { graphManager.Stop(); graphManager.AboutToRelease(); Marshal.ReleaseComObject(sourceFilter); Marshal.ReleaseComObject(videoRenderer); Marshal.ReleaseComObject(sampleGrabber); GC.Collect(); }

5.3 踩过的坑速查表

我把实操中遇到的问题整理成了一张速查表,每个坑都真实踩过。

症状原因解决办法
画面黑屏但Graph已Running预览窗口句柄被重建窗体的CreateControl后再获取Handle,或在Resize事件里重新SetWindowPosition
保存的AVI文件无法播放文件写入顺序错误,或AVI Mux还没来得及写索引停止时对文件做完整Finalize,保存前确保AVI索引完成写入
高分辨率摄像头(如4K)点击截图纸张崩溃SampleGrabber获取buffer大小与实际数据大小不匹配GetConnectedMediaType获取VIDEOINFOHEADER里的真实宽高stride
回调里写文件导致掉帧回调线程被IO阻塞用队列缓冲 + 单独写入线程
关闭窗体后摄像头红灯常亮COM对象未完全释放AboutToRelease+ 手动释放所有Filter引用
麦克风采集回声默认采集到了扬声器混音在音频采集Filter里选择“麦克风阵列”,关闭“立体声混音”设备
录像文件随时间推移音画不同步音频时钟和视频时钟用的是不同Source Filter用Graph内部时钟,让音视频统一走IMediaClock的时钟基准

5.4 笔记本分辨率低场景下的界面适配

做WinForm采集界面时,如果运行环境是较低分辨率的笔记本(比如1366x768),预览面板和按钮布局经常会被挤爆。我在开发套件里加了一版自适应逻辑,根据屏幕分辨率动态计算预览区域比例,窗口最小宽度限制为预览用,避免高DPI缩放导致控件错位。具体做法就是Dock布局+比例分割,预览区固定占用70%高度,控制栏用FlowLayoutPanel动态排列,运行起来在低分辨率机器上也不至于按钮跑出屏幕。

6. 扩展思考:从AVI到MP4,从本地到推流

上面这套方案能稳定输出AVI文件,但实际项目里客户动不动就要求MP4。DirectShow原生不支持MP4的封装,一种方式是保存时用DvVideoCodec/MPEG4做视频压缩,但最终封装还是AVI。更务实的方案是:SampleGrabber回调里拿到的原始帧写到一个缓冲队列,后台用FFmpeg CLI或者FFmpeg.AutoGen做编码,输出MP4。这样等于你把DirectShow当采集器,把FFmpeg当编码器,各干各擅长的活,音视频同步由你自己的时间戳处理。实测下来720P@30fps的流,FFmpeg软编码在i5处理器上占用约30%,可以接受。

如果要做局域网推流,思路完全一样:SampleGrabber拿到的帧打包成RTSP流,用FFmpeg推到流媒体服务器,WinForm只做采集端和管理端。核心就是Grabber的通用性:预览、截图、本地保存、推流,全部从同一帧数据来源出发。

7. 尾声:一点经验小结

这一整套程序做完,最大的体会是DirectShow虽然技术上有点旧,但它的稳定性真不是吹的。只要把Graph链路理解透了,后面改功能几乎就是换Filter的事,比用OpenCV从零搭采集链路要省心得多。下面这几个点是我觉得最有价值的经验,再单独列一遍:一是SampleGrabber的SetMediaType一定要显式设RGB24,能避免后面所有格式转换的麻烦;二是回调线程绝不能做阻塞操作,无脑塞队列就对了;三是停止采集和释放COM的顺序必须严格,不然设备占用问题会让你怀疑人生。

如果你正在做一个Windows桌面端的摄像头工具,照着这套骨架改,很快就能看到预览画面出现在自己的窗体里,视频文件和截图也能正常落盘。遇到具体问题欢迎在评论区交流,我这边踩过的坑大概率能给你省点时间。

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

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

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

立即咨询