
简介这套C#相机读取示例工程基于DirectShowLib实现面向需要调用USB摄像头或内置相机进行实时预览与采集的.NET开发者。压缩包共30个文件包含cs源码、resx资源、pdb调试信息、exe可执行程序、dll引用及配置项等整体仅158KB结构紧凑便于直接在Visual Studio中打开研读。代码演示了获取本地视频输入设备、创建Filter Graph、添加相机源过滤器与渲染器、通过RenderStream建立数据链路以及启动和停止捕获的完整流程并预留了扩展空间可在此基础上加入帧回调、图像分析、编码保存等功能。对于刚接触DirectShow或希望在C#中操作多媒体设备的开发者这份示例提供了直观的代码框架和排错参考已有791人学习下载。 做过上位机开发的朋友应该都有这种经历项目临时要接一个USB相机视觉方案还没完全定下来先要把画面和帧数据跑通。结果厂家SDK申请表还没批下来驱动要装半天看文档看一上午真正开始写代码已经是第二天了。我当时就因为这卡过一周后来换了个思路用C#直接调DirectShowLib读取相机数据。DirectShowLib说白了就是Windows DirectShow这个多媒体框架的C#封装。DirectShow本身是微软早年间推的COM组件体系专门管音视频采集、处理和渲染的。这库的好处是——不挑品牌、不需要厂家SDK、上手快只要是Windows能识别的USB摄像头、采集卡基本都能枚举出来然后你要分辨率、帧率、原始图像数据它全都能给你。这篇文章就把整个实现流程、核心代码和我在实际项目里踩过的坑完整梳理一遍。适合C#上位机刚入门、或者在公司里被临时拉去对接相机设备的朋友。看完你至少能实现枚举到系统中的相机、设置指定分辨率、通过回调拿到每一帧数据并转成Bitmap显示在界面上。1. 相机接入方式怎么选为什么我最后用了DirectShowLib1.1 三种主流方式对比各有各的脾气Windows下用C#接相机实际上就三条路。我先把它们的性格说清楚免得你选错方向。第一种是厂家SDK海康MVS、大华、巴斯勒、映美精都有自己的.NET SDK。优点是什么都给你封装好了曝光、增益、触发、像素格式、网口带宽控制统统能调性能也是最优的。缺点也很明显要先申请授权装他们的运行库版本更新频繁换一个牌子就得重新学一遍API。如果只是前期验证方案这个成本太高了。第二种是OpenCV全家桶从OpenCvSharp的VideoCapture打开摄像头两三行代码就能出画面。非常简单做算法验证特别好用。但它把底层细节全包住了多相机选择、指定设备绑定、某些非标格式、以及帧同步这种事操作起来就会很憋屈。而且实际上它底层还是调的DirectShow或V4L2你想拿原始帧做二次处理中间多了一层封装。第三种就是今天说的DirectShowLib。它比厂家SDK轻量太多只比OpenCV重一点点但拿到了最接近系统底层的操控权设备枚举、格式协商、帧回调都是可控的。通用性强USB摄像头也好、HDMI采集卡也好只要系统认就能用。对项目前期验证和轻量级上位机采集来说这是性价比最高的方案。1.2 它能做的和它不擅长的事先说清楚DirectShowLib能做的事很多枚举系统里的视频设备、按设备路径加载指定摄像头、设置分辨率帧率、在回调函数里拿到原始帧数据、接多个相机、和OpenCvSharp或VisionPro这类图像库做联动。我自己在项目里就用它做了一台设备同时预览三路USB工业相机的功能很稳。但它也不是万能的。首先它控制不了相机的底层特性曝光、增益、自动白平衡这些参数除非相机驱动把这些暴露成了DirectShow属性页否则你动不了。其次它跑的是微软封装的通用UVC协议对GigE网口相机的私有协议、对需要精确定时触发的外触发场景基本无能为力。第三它没法保证严格的工业级低延迟做视觉测量这种毫秒级要求的事还是老实回到厂家SDK。所以我的判断是项目验证阶段、UI原型开发、消费级摄像头采集、以及非核心的辅助监视功能用它非常顺手。一旦进入量产级视觉系统立刻切回厂家SDK别犹豫。2. 动手前的准备环境搭建和三个必懂的概念2.1 引用包与运行环境小心x86/x64的坑环境这块很简单Visual Studio里通过NuGet搜索DirectShowLib装一下就行。我一般用DirectShowLib.Standard这个包兼容性比较好。如果公司网络不通NuGet也可以去GitHub上拉源码自己编译不就一个DLL的事。这里有个大坑必须提醒你找得到设备但打不开、或者打开直接崩溃八成是平台位数不对。很多USB摄像头驱动只提供32位接口而C#项目新建时默认AnyCPU在64位系统上就会以64位进程去加载结果就是设备枚举正常一BindToObject就炸。我的做法是把项目平台直接设置成x86并且勾掉“首选32位”这个选项的干扰。代码层面解决不了的话就用这个土办法实测非常有效。建议运行环境用.NET Framework 4.6.1以上虽然库本身很老但和新的运行时匹配反而更稳。.NET 6/8下我也跑通过但部分摄像头驱动对CoreCLR的COM调用更敏感遇到莫名异常就退回Framework。2.2 Filter Graph和SampleGrabber理解这两个概念就够了DirectShow的核心叫Filter Graph我一般用“水厂管道系统”来类比。水源是相机Filter负责把水(视频数据)打出来中间接一根管道叫SampleGrabber它的作用是在特定位置开一个取样口水流过的时候你舀一勺出来看看管道末端接一个水龙头或蓄水池就是Renderer负责把水流消耗掉不让管道堵住。在这套体系里作为应用开发者你的核心任务就两件事管好水厂、盯住取样口。管好水厂就是构建Filter Graph的连接盯住取样口就是实现ISampleGrabberCB回调在每一帧水流过的瞬间把数据捞出来。SampleGrabber有一个很反直觉的地方它本身不改变数据内容。你设它要RGB24的格式它会试图让上游输出RGB24但如果你不设或者设的格式上游根本不支持那它拿到的可能就是你根本看不懂的压缩流。这就是为什么后面要专门讲格式设置。2.3 调试利器GraphStudioNext一定要装写代码之前强烈建议你先装一个GraphStudioNext免费的网上搜一下就有。它可以可视化地把相机Filter拖到画布上手工连接SampleGrabber和Renderer用鼠标就能验证这条管道通不通。我遇到画面黑屏时第一件事不是看代码而是用这个工具拉一条链路很快就能判断问题出在源设备还是连接逻辑上。基础不牢的时候这个工具能帮你建立直觉。3. 从枚举到取帧核心步骤与可跑代码3.1 第一步枚举相机设备这是整个流程的入口。DirectShowLib里有个静态方法直接返回所有视频输入设备每个设备包含Moniker和NameName用来显示Moniker用来后续创建Filter实例。using DirectShowLib; DsDevice[] devices DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice); for (int i 0; i devices.Length; i) { Console.WriteLine($设备{i}: {devices[i].Name}); }这些设备不只是USB摄像头HDMI采集卡、虚拟摄像头也会被枚举进来。判断具体是哪个设备后面用DevicePath来区分不要用NameName在系统里可能有重名。这一小段代码跑通就说明DLL引用和系统驱动层面的兼容性没问题了。3.2 第二步构建Filter Graph并连接SampleGrabber枚举到设备后要创建两个核心对象ICaptureGraphBuilder2负责智能连接各个FilterIGraphBuilder是Filter Graph本体用来实现运行、暂停、停止。ICaptureGraphBuilder2 capGraph (ICaptureGraphBuilder2)new CaptureGraphBuilder2(); IGraphBuilder graph (IGraphBuilder)new FilterGraph(); capGraph.SetFiltergraph(graph); // 从设备Moniker创建源Filter并加入Graph IBaseFilter sourceFilter (IBaseFilter)devices[index].Moniker.BindToObject(null, null, typeof(IBaseFilter).GUID); graph.AddFilter(sourceFilter, Source); // 创建SampleGrabber设置媒体类型为RGB24并加入Graph ISampleGrabber sampleGrabber (ISampleGrabber)new SampleGrabber(); AMMediaType mediaType new AMMediaType(); mediaType.majorType MediaType.Video; mediaType.subType MediaSubType.RGB24; sampleGrabber.SetMediaType(mediaType); graph.AddFilter((IBaseFilter)sampleGrabber, Grabber);接下来是核心的RenderStream方法。一个参数指定我们要接的是Capture Pin、视频媒体类型、源Filter后面指定SampleGrabber为中间FilterRenderer参数传null的话系统会自动在SampleGrabber后面接一个Null Renderer这样Graph就能跑起来而数据会被SampleGrabber截获。int hr capGraph.RenderStream(PinCategory.Capture, MediaType.Video, sourceFilter, (IBaseFilter)sampleGrabber, null); DsError.ThrowExceptionForHR(hr);很多人到了这一步就黑屏一个很隐蔽的原因是把graph对象写成了局部变量C#的GC觉得没人引用它直接把COM对象释放了。记住graph、capGraph、sourceFilter、sampleGrabber务必保存为窗体或类的字段千万别用局部变量。3.3 第三步设置分辨率和帧率别傻傻用默认很多摄像头默认输出可能是640x480你要1920x1080就得自己协商。DirectShow里干这活的是IAMStreamConfig接口通过它枚举设备支持的媒体类型找到合适的分辨率后设置。IAMStreamConfig streamConfig capGraph.FindInterface(PinCategory.Capture, MediaType.Video, sourceFilter, typeof(IAMStreamConfig).GUID) as IAMStreamConfig; int count, size; streamConfig.GetNumberOfCapabilities(out count, out size); for (int i 0; i count; i) { VideoInfoHeader cap new VideoInfoHeader(); AMMediaType pmt null; streamConfig.GetStreamCaps(i, out pmt, cap); Console.WriteLine(${cap.BmiHeader.Width} x {cap.BmiHeader.Height} 平均帧率约{10_000_000 / cap.AvgTimePerFrame}帧); DsUtils.FreeAMMediaType(pmt); }这里有个单位坑AvgTimePerFrame的单位是100纳秒1秒就是10,000,000。比如值333333就代表约30帧每秒。要主动设置格式就先用GetStreamCaps选一个基准类型改宽高和AvgTimePerFrame再调用SetFormat。前提是你改的这个组合设备驱动支持。如果设备不支持表现为SetFormat返回错误码最好先枚举一遍所有capabilities找出离目标最近的那种格式去改。int selectIndex 0; VideoInfoHeader targetHeader null; AMMediaType targetMedia null; for (int i 0; i count; i) { VideoInfoHeader cap new VideoInfoHeader(); AMMediaType pmt null; streamConfig.GetStreamCaps(i, out pmt, cap); if (cap.BmiHeader.Width 1920 cap.BmiHeader.Height 1080) { selectIndex i; targetHeader cap; targetMedia pmt; break; } } if (targetMedia ! null) { targetHeader.AvgTimePerFrame 333333; // 约30fps Marshal.StructureToPtr(targetHeader, targetMedia.formatPtr, false); streamConfig.SetFormat(targetMedia); DsUtils.FreeAMMediaType(targetMedia); }注意SetFormat之前直接改formatPtr里面的VideoInfoHeader结构体后必须用Marshal.StructureToPtr写回内存否则SetFormat看到的还是原来的内容。这是我最初调试时最郁闷的一个隐藏点。3.4 第四步在BufferCB回调里拿每一帧数据格式协商完成后把回调挂到SampleGrabber上。DirectShow有两种回调SampleCB给的是IMediaSample对象BufferCB直接给未受管内存指针。我一般都选BufferCB少一层COM调用效率更高处理起来也更直观。class GrabberCallback : ISampleGrabberCB { public int BufferCB(double SampleTime, IntPtr pBuffer, int BufferLen) { byte[] buffer new byte[BufferLen]; Marshal.Copy(pBuffer, buffer, 0, BufferLen); // 这里把buffer交给图像处理或转成Bitmap OnFrameReady?.Invoke(buffer, BufferLen); return 0; } public int SampleCB(double SampleTime, IMediaSample pSample) { return 0; } public event Actionbyte[], int OnFrameReady; }订阅后设置回调模式sampleGrabber.SetCallback(callback, 1);第二个参数传1表示使用BufferCB方式。拿到buffer之后怎么变成Bitmap如果是RGB24格式直接填充位图即可Bitmap bitmap new Bitmap(width, height, PixelFormat.Format24bppRgb); BitmapData bmpData bitmap.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb); Marshal.Copy(frameBuffer, 0, bmpData.Scan0, frameBuffer.Length); bitmap.UnlockBits(bmpData);width和height就是你之前设置的分辨率最好在SetFormat成功后就存为字段不要每次回调里去解析媒体类型太慢。3.5 第五步UI刷新不卡界面SampleGrabber的回调跑在DirectShow的工作线程上绝对不能在这里直接操作PictureBox跨线程访问控件要么报错要么界面卡顿。要回UI线程更新最常见的就是Control.BeginInvoke。pictureBox.BeginInvoke(new Action(() { pictureBox.Image?.Dispose(); pictureBox.Image bitmap; }));但如果帧率是60fps每次回调都BeginInvoke一次你的UI线程会被挤爆。我的做法是做一个节流不管回调多快只保证UI每秒刷新25到30次就够判断一下时间戳或者用一个定时器去拉最新一帧。这个细节做完之后界面流畅度完全不一样。启动和停止也别忽略。启动时用mediaControl.Run()停止时用mediaControl.StopWhenReady()或Stop()。StopWhenReady会等队列里的帧排空更温柔一点但如果在UI线程直接调用可能会等一会儿最好放在单独线程或使用Stop()避免卡界面。4. 我踩过的坑先帮你排除一遍4.1 排查速查表照着对号入座下面这个表格是我在实际项目里整理出来的碰到类似症状直接查。症状可能原因解决办法枚举到的设备数为0没有安装相机驱动或程序以64位运行但驱动只有32位换x86平台确认系统相机应用能打开该相机设备有但一点BindToObject就异常COM接口调用线程不匹配或Moniker已经释放确保在同一线程加载Device对象不要提前Dispose画面黑屏但Preview能出图SampleGrabber格式设置与设备输出不兼容链路走不通先枚举所有格式选设备真正支持的RGB24或YUY2格式运行几秒后内存暴涨不停的new byte[]和Bitmap回调里没释放使用预分配缓冲池Bitmap及时Dispose停止后再次启动失败Graph状态没完全回到Stopped或者SourceFilter未重置先Stop再释放所有Filter引用重新构建Graph图像横竖颠倒了相机驱动输出的方向问题不是DirectShowLib的锅用RotateFlip校正或改注册表驱动属性程序退出时崩溃COM对象没有正确释放析构顺序不对先停止Graph再释放SampleGrabber最后释放sourceFilter4.2 COM对象生命周期管理这是老生常谈但真有人崩C#有GC兜底但COM对象不吃这套。Graph运行期间你声明的IBaseFilter、ISampleGrabber、IGraphBuilder这些COM接口都需要手动释放。我的释放顺序是mediaControl.Stop()然后Marshal.ReleaseComObject从下游组件开始逐个释放最后置null并调用GC.Collect()让清理生效。还有一个细节每次调用RenderStream之后内部hook了很多COM连接如果你要重新设置分辨率最省事的做法不是去断开某个pin而是把整个Graph拆掉重建。别嫌麻烦DirectShow的连接状态机复杂拆pin容易拆出各种未定义行为。重建虽然多几行代码但逻辑跟新的一样不会出现“第二次连接不上”的诡异问题。4.3 回调里的GC压力帧率越高越要命假设你运行在60fps每帧1080P RGB24数据将近6MB每秒要分配360MB的byte[]数组。.NET的GC面对这种大对象分配几乎每秒钟都在触发垃圾回收表现就是CPU占用高、画面撕裂、偶发卡顿。我的解法是先开一个固定大小的byte[]缓冲区每帧回调时用Marshal.Copy往这个缓冲区里拷然后交给一个独立消费线程去做后续处理。消费线程用BlockingCollection接收帧数据如果同时处理不过来就丢弃最旧帧优先保证实时性。这个模式我在后面第5节也会详细展开。5. 进阶多相机、帧队列和什么时候该换方案5.1 用缓冲队列解耦采集和消费回到刚才说的帧率问题最终我还是上了生产者消费者模式。采集回调只是把帧指针拷贝到预分配的buffer里然后塞入BlockingCollection不碰UI不碰算法。BlockingCollectionbyte[] frameQueue new BlockingCollectionbyte[](new ConcurrentQueuebyte[](), 5); // 回调线程或采集线程中 frameQueue.TryAdd(buffer); // 消费线程中 while (true) { byte[] frame frameQueue.Take(); ProcessFrame(frame); }队列容量设到5左右就够超过就丢旧帧。这样即使消费端算法偶尔卡顿采集回调也不会被拖住Graph管道不会积压整个系统的实时性反而更好。后来我还在队列元素里加了时间戳字段用于视觉算法做时间同步这一套在多个项目里复用都很稳定。5.2 多相机同时采集的要点需要接多台相机时思路很简单每台相机各自构建一套独立的Filter Graph、SampleGrabber、回调对象互不干扰。这里有两个容易翻车的地方。一是设备区分。不要用Name系统里同名很常见。用设备Moniker里的DevicePath枚举后先用DevicePath做绑定这样才能保证重启后不会因为设备插入顺序变化而接错相机。二是线程亲和性。每台相机可以各起一个线程去处理本机的帧队列但COM对象创建最好都在同一个线程避免跨线程Marshall产生额外开销。我实际做过三路同时采集1920x108030fps每个相机独立线程独立GraphCPU占用和稳定性都在可接受范围。5.3 什么情况下该果断换掉DirectShowLib虽然说了这么多好处我还是得泼点冷水。遇到以下情况趁早换成厂家SDK第一是相机要外触发或者硬件同步多相机需要拍同一时刻画面。DirectShow的UVC层根本控制不了GPIO信号这时候只能用海康MVS或大华SDK的硬件触发功能。第二是需要调整曝光、增益、伽马、色彩矩阵这些参数而且要在程序运行中动态调不是启动前设一次。第三是做定位、测量、条码识别等高精度算法需要稳定的低延迟和确定性帧率DirectShow的调度不具备这种实时性保证。第四是GigE网口相机需要做丢包重传、巨型帧设置这类网络优化。判断标准很简单项目一旦跨过了“验证”阶段别犹豫用直连SDK或者工业相机厂商的标准解决方案。DirectShowLib是我的前期探路工具但不是工业系统的最终答案。最后再分享一个我坚持了很久的调试习惯接任何摄像头设备第一步永远不写界面先做控制台程序把设备枚举、格式协商、帧回调三步跑通打上清晰的日志然后再套UI框架。这样分层推进哪一层出问题一目了然。这套流程不止适用于DirectShowLib后来我换用海康SDK、OpenCvSharp时也是同样的套路。希望这篇分享能让你少走点弯路。本文还有配套的精品资源点击获取