简介:这份资源面向具备一定C#基础的开发者,聚焦于用DirectShowLib封装库读取电脑相机实时视频流这一常见需求,提供可直接运行的工程示例。压缩包共30个文件、约158KB,以8个cs源码文件为核心,配合resx资源、config配置、csproj工程文件及dll依赖,另含exe、pdb等调试产物与图标素材,构成一个结构完整的Visual Studio项目。代码围绕Filter Graph的构建展开,演示了枚举本地摄像头设备、添加源过滤器与渲染器、通过RenderStream连接并启动捕获的完整流程,并预留了停止捕获与资源释放接口,便于读者在此基础上扩展帧回调、图像处理或错误处理逻辑。目前已有793人学习下载,适合希望绕开复杂COM调用、快速上手相机采集的开发者参考借鉴。
1. 相机画面卡在预览窗口:DirectShowLib 在 C# 里到底补了哪块短板
做过工业相机或 USB 摄像头采集的同行大概都遇到过这种局面:设备管理器里认得到,厂商自带的预览软件也能出图,可一旦换到自己写的 C# 程序里,要么黑屏,要么帧率掉到个位数,要么切个分辨率直接抛异常。问题往往不在相机本身,而在采集链路的选型上。DirectShowLib 就是在这个场景里被反复翻出来的一份托管封装资源,它把 DirectShow 那套 COM 接口用 C# 重新声明了一遍,让 .NET 程序不必手写一堆 interop 就能枚举设备、挂采样回调、拿到原始帧数据。这份资源适合两类人:一类是要在 WinForm/WPF 里快速接一路相机做检测、录制的开发者;另一类是维护老采集模块、需要读懂现有 DirectShow 代码的工程师。它解决的是“能拿到帧”这个最底层的问题,至于拿到帧之后做识别还是推流,那是下一段的事。
2. 把 DirectShow 的采集链路拆开:从设备枚举到帧回调
2.1 为什么是 DirectShow 而不是别的采集方案
在 Windows 上抓相机数据,常见路子无非几条:厂商 SDK、Media Foundation、OpenCV 的 VideoCapture,再就是 DirectShow。厂商 SDK 功能最全但绑定品牌,换一家相机就得重写;Media Foundation 是较新的框架,Win7 之后才完整,老设备兼容性参差;OpenCV 上手最快,可它内部在 Windows 上很多时候也是转调 DirectShow 或 MSMF,封装掉了设备枚举、格式协商这些细节,一旦要精确控制曝光、分辨率、像素格式,反而不好下手。
DirectShow 的价值在于它是 Windows 上历史最长、驱动覆盖最广的采集框架之一,大量 USB 相机、采集卡、虚拟摄像头都按它的模型暴露能力。它的结构是典型的滤镜图:源滤镜(采集设备)→ 中间滤镜(解码、转换)→ 渲染滤镜(显示或写文件),滤镜之间用引脚(Pin)连接,数据以样本(Sample)为单位在图上流动。DirectShowLib 做的事,就是把这套 COM 接口翻译成 C# 能直接调用的类型,比如IFilterGraph2、IBaseFilter、ISampleGrabber、IAMStreamConfig这些接口都有对应声明,省掉了自己写[ComImport]和 GUID 的功夫。
选它的理由很实际:设备枚举走ICreateDevEnum,格式协商走IAMStreamConfig,取帧走ISampleGrabber的回调,这三件事覆盖了采集模块 90% 的需求,而且代码量可控。代价是它偏底层,很多默认行为需要自己兜,后面几章的坑基本都出在这里。
2.2 环境准备与资源引入
这份资源本质是一组 C# 源码文件,核心是 DirectShowLib 的接口声明集合,通常以.cs文件形式存在,直接拖进项目即可编译,不需要额外安装运行时。工程侧要注意几点:目标框架建议 .NET Framework 4.x 或 .NET 6+ 的 Windows 目标,因为底层还是 COM,跨平台没意义;平台目标必须显式设为 x86 或 x64,不能留 AnyCPU,否则 COM 封送会在运行时报类型不匹配;项目属性里允许不安全代码一般用不上,但若自己写像素拷贝可能涉及。
引入方式有两种,按资源形态选:
# 方式一:源码直接纳入项目(推荐,便于调试和裁剪) # 把资源里的 DirectShowLib 相关 .cs 文件复制到项目目录,例如 Libs/DirectShowLib/ # 然后在 .csproj 里确认已包含(SDK 风格项目默认通配包含) dotnet build -c Release -p:Platform=x64<!-- 方式二:若资源提供的是已编译 DLL,在 csproj 中引用 --> <ItemGroup> <Reference Include="DirectShowLib"> <HintPath>Libs\DirectShowLib.dll</HintPath> <Private>true</Private> </Reference> </ItemGroup>第一种方式更适合这份资源,因为 DirectShowLib 的声明文件经常需要按项目裁剪,比如只保留采集相关接口,去掉用不到的 DVD、电视调谐部分,能减少编译体积和命名冲突。第二种方式省事,但一旦遇到接口声明和系统版本对不上,改起来就麻烦。参数上重点盯Platform,x64 进程只能加载 x64 的 COM 组件,相机驱动若是 32 位,就必须把整个进程切成 x86,这一点在部署到现场机器前一定要确认。
2.3 设备枚举:拿到 moniker 才算真正找到相机
枚举设备是整条链路的第一步,DirectShow 用ICreateDevEnum配合VideoInputDevice分类来列出所有视频输入设备。每个设备对应一个 moniker,后续绑定滤镜靠的就是它。下面这段是常见写法:
using DirectShowLib; // 枚举所有视频输入设备,返回 友好名称 -> moniker 的映射 public static Dictionary<string, IMoniker> EnumVideoDevices() { var result = new Dictionary<string, IMoniker>(); ICreateDevEnum devEnum = (ICreateDevEnum)new CreateDevEnum(); IEnumMoniker enumMoniker = null; try { // VideoInputDevice 分类即摄像头/采集卡 int hr = devEnum.CreateClassEnumerator(FilterCategory.VideoInputDevice, out enumMoniker, 0); if (hr != 0 || enumMoniker == null) return result; // 无设备 IMoniker[] monikers = new IMoniker[1]; while (enumMoniker.Next(1, monikers, IntPtr.Zero) == 0) { // 读取 FriendlyName 属性作为显示名 object bagObj = null; try { Guid bagId = typeof(IPropertyBag).GUID; monikers[0].BindToStorage(null, null, ref bagId, out bagObj); var bag = (IPropertyBag)bagObj; object nameObj = null; bag.Read("FriendlyName", out nameObj, null); string name = nameObj as string ?? "未命名设备"; result[name] = monikers[0]; } catch { /* 个别设备无属性包,跳过 */ } finally { if (bagObj != null) Marshal.ReleaseComObject(bagObj); } } } finally { if (enumMoniker != null) Marshal.ReleaseComObject(enumMoniker); Marshal.ReleaseComObject(devEnum); } return result; }逻辑上分三步:创建枚举器、按分类取 moniker、从 moniker 的属性包里读FriendlyName。参数里FilterCategory.VideoInputDevice是固定分类,换成AudioInputDevice就是麦克风枚举,思路一致。Next(1, ...)的 1 是每次取一个,批量取可以改大但没必要。这里最容易忽略的是 COM 对象释放,IMoniker、IPropertyBag、IEnumMoniker都是 COM 引用,不ReleaseComObject的话,反复枚举会累积引用计数,长时间运行的程序会表现为设备列表越刷越慢,甚至相机被占用无法释放。血泪经验是:枚举函数里凡是out出来的 COM 接口,都要在finally里配对释放。
2.4 构建滤镜图并挂上 SampleGrabber 取帧
拿到 moniker 之后,把它BindToObject成IBaseFilter,加入IFilterGraph2,再接一个SampleGrabber滤镜,最后连一个空渲染器(Null Renderer)让图能跑起来。SampleGrabber 是取帧的核心,它通过SetCallback注册一个ISampleGrabberCB实现,每来一帧就回调一次。
// 构建采集图:源滤镜 -> SampleGrabber -> NullRenderer public IFilterGraph2 BuildGraph(IMoniker moniker, ISampleGrabberCB callback) { var graph = (IFilterGraph2)new FilterGraph(); IBaseFilter source = null, grabber = null, nullRenderer = null; try { // 1. 绑定源滤镜 Guid baseFilterId = typeof(IBaseFilter).GUID; object srcObj; moniker.BindToObject(null, null, ref baseFilterId, out srcObj); source = (IBaseFilter)srcObj; graph.AddFilter(source, "VideoSource"); // 2. 创建并添加 SampleGrabber grabber = (IBaseFilter)new SampleGrabber(); var sg = (ISampleGrabber)grabber; // 指定期望的媒体类型,这里先不限定格式,交给协商 var mt = new AMMediaType(); mt.majorType = MediaType.Video; sg.SetMediaType(mt); graph.AddFilter(grabber, "SampleGrabber"); // 3. 添加空渲染器,保证图有输出端 nullRenderer = (IBaseFilter)new NullRenderer(); graph.AddFilter(nullRenderer, "NullRenderer"); // 4. 连接 源 -> Grabber -> NullRenderer graph.ConnectDirect(GetPin(source, "Capture"), GetPin(grabber, "Input"), null); graph.ConnectDirect(GetPin(grabber, "Output"), GetPin(nullRenderer, "Input"), null); // 5. 注册回调,1 表示采样回调(拿到原始数据) sg.SetBufferSamples(false); sg.SetOneShot(false); sg.SetCallback(callback, 1); return graph; } catch { // 失败时释放已创建对象,避免泄漏 if (nullRenderer != null) Marshal.ReleaseComObject(nullRenderer); if (grabber != null) Marshal.ReleaseComObject(grabber); if (source != null) Marshal.ReleaseComObject(source); Marshal.ReleaseComObject(graph); throw; } }这段的关键参数有三个。SetBufferSamples(false)表示不额外拷贝一份到内部缓冲,配合SetCallback(callback, 1)的采样回调模式,回调里直接拿到IntPtr指向的帧数据,省一次内存拷贝,对高帧率场景很重要。SetOneShot(false)表示持续回调而不是只抓一帧。ConnectDirect用的是直连,跳过智能连接,好处是连接失败会立刻暴露,坏处是格式不匹配时不会自动插转换滤镜,所以源和 Grabber 的媒体类型要能对上,通常 Grabber 不限定具体格式就能协商成功。
回调实现里要注意线程模型:SampleGrabber 的回调发生在 DirectShow 的工作线程上,不是 UI 线程,回调里不能直接碰控件,必须Invoke回主线程,或者把数据丢进队列由消费线程处理。回调里也不要做耗时操作,否则会阻塞整个滤镜图,表现为帧率骤降。
// 采样回调:BufferCB 在采样模式下被调用 public class FrameCallback : ISampleGrabberCB { public int SampleCB(double sampleTime, IMediaSample sample) => 0; // 采样模式不走这里 public int BufferCB(double sampleTime, IntPtr buffer, int bufferLen) { // buffer 指向一帧原始数据,bufferLen 为字节数 // 注意:此处在工作线程,禁止直接操作 UI if (buffer == IntPtr.Zero || bufferLen <= 0) return 0; var frame = new byte[bufferLen]; Marshal.Copy(buffer, frame, 0, bufferLen); FrameQueue.Enqueue(frame); // 交给消费线程 return 0; } }BufferCB的buffer生命周期只在回调期间有效,回调返回后内存可能被复用,所以必须当场拷贝出来,这是新手最容易翻车的地方——直接把IntPtr存起来,过一会儿再读,读到的就是花屏或者别的帧。
3. 格式协商与分辨率切换:IAMStreamConfig 的正确打开方式
3.1 先读能力再设格式,别反过来
相机支持哪些分辨率、帧率、像素格式,要通过源滤镜的IAMStreamConfig接口去问。常见错误是直接SetFormat一个想当然的格式,失败后抛VFW_E_INVALIDMEDIATYPE,然后开始怀疑人生。正确顺序是先GetNumberOfCapabilities拿到能力数量,再逐个GetStreamCaps读出每个能力对应的AMMediaType,挑中意的再SetFormat。
// 列出源滤镜支持的所有视频格式 public static List<string> ListFormats(IBaseFilter source) { var list = new List<string>(); var config = (IAMStreamConfig)GetPin(source, "Capture"); int hr = config.GetNumberOfCapabilities(out int count, out int size); if (hr != 0) return list; IntPtr capsPtr = Marshal.AllocCoTaskMem(size); try { for (int i = 0; i < count; i++) { // 每个能力槽位读出媒体类型 hr = config.GetStreamCaps(i, out AMMediaType mt, capsPtr); if (hr != 0) continue; var vih = (VideoInfoHeader)Marshal.PtrToStructure(mt.formatPtr, typeof(VideoInfoHeader)); list.Add($"{vih.BmiHeader.Width}x{vih.BmiHeader.Height} " + $"{(mt.subType == MediaSubType.RGB24 ? "RGB24" : mt.subType.ToString())}"); DsUtils.FreeAMMediaType(mt); // 必须释放,否则泄漏 } } finally { Marshal.FreeCoTaskMem(capsPtr); } return list; }GetStreamCaps返回的AMMediaType里formatPtr指向格式块,视频通常是VideoInfoHeader,从中能读到宽高、位深、图像大小。这里有个必须做的动作:每个AMMediaType用完要FreeAMMediaType,否则每枚举一次泄漏一块非托管内存,程序跑久了内存曲线一路向上,排查起来很费劲。
3.2 切换分辨率时的重连顺序
运行中切分辨率,不能只调SetFormat就完事,滤镜图还连着,格式变了连接会失效。稳妥的顺序是:先Stop图,断开相关引脚连接,SetFormat设新格式,重新ConnectDirect,再Run。如果图里挂了 SampleGrabber,还要确认 Grabber 的媒体类型没有把格式锁死,必要时先SetMediaType成空再重设。
// 运行中切换分辨率(简化流程) public void ChangeResolution(IFilterGraph2 graph, IBaseFilter source, IBaseFilter grabber, int width, int height) { var mediaControl = (IMediaControl)graph; mediaControl.Stop(); // 必须先停 var config = (IAMStreamConfig)GetPin(source, "Capture"); // 遍历能力找到匹配宽高的那一项,再 SetFormat // ...(省略遍历,逻辑同 ListFormats) // config.SetFormat(ref chosenMt); // 断开旧连接后重连 var sourcePin = GetPin(source, "Capture"); var grabberIn = GetPin(grabber, "Input"); graph.Disconnect(sourcePin); graph.ConnectDirect(sourcePin, grabberIn, null); mediaControl.Run(); }参数上要留意,SetFormat传的是ref AMMediaType,设完之后原结构仍归调用方释放。切换失败最常见的原因是目标格式不在能力列表里,或者相机正被别的进程占用,前者靠先枚举规避,后者只能靠关掉占用程序或换设备。
4. 避坑与排查:采集链路上最容易翻车的五处
4.1 现象:程序启动就报 COM 类未注册
原因通常是平台目标设成了 AnyCPU,在 64 位系统上以 64 位进程运行,而相机驱动或某个滤镜只有 32 位版本,COM 找不到对应注册项。解决是把项目平台目标显式改成 x86 或 x64,与驱动位数对齐,部署时也确认目标机注册表里该滤镜的 CLSID 存在。别指望运行时自动降级,COM 不做这件事。
4.2 现象:回调一直不触发,预览却是黑的
先看滤镜图有没有真正Run,再看 SampleGrabber 是否连到了渲染器。只连源到 Grabber、不接输出端,图不会流动,回调自然不来。另一个隐蔽原因是SetCallback的第二个参数传错,传 0 是采样回调走SampleCB,传 1 才走BufferCB,两者实现要对上,否则方法被调了但逻辑没执行,看起来就像没回调。
4.3 现象:跑几分钟后帧率越来越低、内存上涨
典型是 COM 对象和非托管内存没释放。枚举时的AMMediaType、每次GetPin拿到的引脚、回调里拷贝的缓冲,都要有明确的释放或回收路径。建议在采集模块里加一个计数器,统计每秒回调次数和已分配缓冲数,帧率下降时先看这两个数是否异常,比盲猜快得多。
4.4 现象:切分辨率后图像花屏或直接断流
多半是旧连接没断干净就设了新格式,或者 Grabber 的媒体类型还锁着旧格式。按 3.2 的顺序走:停图、断连、设格式、重连、再跑。若仍花屏,检查VideoInfoHeader里的ImageSize和实际bufferLen是否一致,不一致说明协商出的格式和回调假设的格式对不上,以GetMediaType读回的为准。
4.5 现象:多相机同时开,第二路打不开
DirectShow 设备是独占的,同一台相机不能被两个滤镜图同时绑定。多路采集要么用多台物理设备,要么在应用层做单例管理,把设备句柄集中调度。另外枚举函数若没释放 moniker,设备可能被“软占用”,表现为明明程序关了相机还是打不开,重启进程才恢复,这就是 2.3 里强调释放的原因。
5. 把帧数据接进业务:像素格式转换与一个可复用的采集封装
拿到BufferCB里的字节流只是第一步,业务侧通常要的是Bitmap或者能直接喂给推理引擎的数组。这里的关键是搞清楚当前协商出的像素格式:RGB24 每像素 3 字节,行内可能有对齐填充;YUY2 是 4:2:2 打包格式,每 4 字节表示 2 个像素,转 RGB 要按公式算。我一般会在采集封装里把格式判断和转换都收进去,对外只暴露FrameReady(byte[] bgra, int w, int h)一个事件,业务代码不碰 DirectShow 的任何类型。
// 将 RGB24 原始缓冲转为 32 位 BGRA,便于构造 Bitmap public static byte[] Rgb24ToBgra(byte[] src, int width, int height, int stride) { var dst = new byte[width * height * 4]; for (int y = 0; y < height; y++) { int srcRow = y * stride; // 源行跨度,可能大于 width*3 int dstRow = y * width * 4; for (int x = 0; x < width; x++) { int s = srcRow + x * 3; int d = dstRow + x * 4; dst[d] = src[s + 2]; // B dst[d + 1] = src[s + 1]; // G dst[d + 2] = src[s]; // R dst[d + 3] = 255; // A } } return dst; }stride这个参数不能想当然用width * 3,DirectShow 给的缓冲行跨度常按 4 字节对齐,宽不是 4 的倍数时两者不等,用错就会看到图像斜切。验证方法很简单:抓一帧纯色画面,看斜切是否消失。封装成类之后,启动、停止、切格式、释放都收在Dispose里,调用方只using一下,能省掉大量资源泄漏的排查时间。
进阶一点的做法是把回调里的拷贝改成环形缓冲加零拷贝消费:回调只把IntPtr和长度压进无锁队列,消费线程按需Marshal.Copy,这样高帧率下回调线程的停留时间最短。代价是队列满时要丢帧而不是阻塞,丢帧策略得按业务定,检测类应用丢几帧无所谓,录制类就得反压。从那以后我每次接新相机,都强制先跑一遍“枚举→列格式→设格式→抓 100 帧→统计帧率和内存”这条最小验证链,确认底层通了再往上叠业务,省得后面在业务代码里找采集的锅。希望帮到你。
本文还有配套的精品资源,点击获取