C#使用DirectShowLib读取USB工业相机图像的完整指南
2026/9/8 2:42:01 网站建设 项目流程

简介:基于DirectShowLib读取相机数据的C#示例工程,为需要调用摄像头进行多媒体开发或视觉处理的读者提供了可运行的参考。它利用Filter Graph模型完成设备枚举、源过滤器添加、渲染器连接与捕获启停,适合初中级C#开发者快速上手。压缩包共30个文件,含8个cs源文件、csproj工程配置、resx/resources资源、App.config与Settings.settings配置、可直接运行的exe及依赖dll,另有缓存、pdb、ico等辅助文件,整体仅158KB,结构精简。已有791人学习下载。通过阅读核心窗体与采集类代码,可理解摄像头列表获取、RenderStream连接及graph运行/停止的关键写法;这套小巧的工程演示了从设备枚举到画面渲染的完整链路,便于在此基础上扩展帧回调、图像处理或异常处理,并快速移植到自身项目中。 前阵子帮朋友做一个工控机上的视觉项目,对方要求能接一般市面上的USB工业相机,又不绑定某个厂家的SDK。当时第一反应是用OpenCV的VideoCapture,但试下来发现对部分相机的参数控制、设备热插拔恢复都不太顺手。后来把方案换成了C#里封装DirectShow的DirectShowLib,问题一下清晰了很多。这篇文章就把这套“C#采用DirectShowLib读取相机数据”的完整做法写出来,从选型逻辑、环境准备、核心实现到实际踩坑,尽量讲透,适合正在做C#上位机、机器视觉或者桌面摄像头采集的开发者参考。

1. 说句实在话:为什么最终选了DirectShowLib这条路

1.1 先说我遇到的实际场景

很多做上位机的朋友都碰到过这种事:项目一开始用的是海康或者大华的相机,拿着厂商SDK按文档调API,运行得规规矩矩。结果客户中途换了一台不知名品牌的USB相机,甚至可能是老式采集卡转换出来的视频设备,厂商SDK直接不认。这种情况下,最稳妥的做法就是走系统层的采集接口,让Windows把设备当成普通摄像头统一管理,DirectShow就是Windows在多媒体领域的事实标准。

DirectShowLib是C#对DirectShow COM接口的一套完整封装,项目在GitHub上开源,NuGet直接搜得到。它不解决图像算法问题,只干一件事:让C#代码能枚举到视频设备、建立采集链路、拿到原始帧数据。很多贴吧和论坛里提到C#上位机、C#机器视觉,底层设备接入用的都是它。

1.2 和其它方案的对比取舍

写代码之前,先把市面上几条常见路线摊开对比一下,免得后面走回头路。

方案上手难度设备兼容性帧数据处理能力典型问题
厂商SDK只管自家设备强,能拿到私有数据换设备就得重写
OpenCV VideoCapture依赖FFMPEG后端一般,参数控制弱热插拔恢复难,分辨率控制不稳
AForge.NET还不错一般原生库停止维护,性能有限
DirectShowLib通用,覆盖绝大多数USB/采集卡强,直接拿内存数据概念多,需要理解Filter链路

OpenCV VideoCapture适合做算法验证,不适合做产品。AForge.NET在WinForm时代确实好用,英文社区更新早就停了。厂商SDK适合产线上设备固定的场景,一旦需求变成“什么相机都能接”,直接卡死。DirectShowLib的优势在于它贴近系统底层,不管是海康的USB相机还是十几块钱的免驱摄像头,只要能在系统里出图,它就管得了。

需要提醒一句:DirectShowLib只是一层COM封装,采集链路本身还是由系统DirectShow基础设施提供的。如果你的项目需要GigE网口相机的多相机高带宽传输,建议还是走厂商SDK;如果是USB相机和普通视频设备,再考虑用这套方案。

2. 环境准备:从NuGet到相机能出画面的关键细节

2.1 安装包与平台目标的选择

先在Visual Studio里建一个.NET Framework控制台或者WinForm项目,然后通过NuGet安装DirectShowLib。这个包有个历史情况:老版本叫DirectShowLib-2005,新版本直接用DirectShowLib这个名字,两种都行,功能差别不大。推荐后者,因为它有一个比较友好的release维护节奏。

安装好之后,最容易被坑的是“平台目标”。右键项目属性,选择“生成”选项卡,把“首选32位”关掉,再根据系统情况选x64或x86。这里不是随便选的,要看你的采集卡驱动和依赖的原生DLL是哪个版本。USB免驱摄像头一般没什么限制,但老式采集卡很挑进程位数,驱动是32位就必须编译成x86,否则连设备都枚举不到。

2.2 设备枚举:先认识你手上那台相机

写任何采集代码之前,我习惯先把设备列表打出来看看。DirectShow里所有视频输入设备都注册在FilterCategory.VideoInputDevice这个类目下,代码非常简单:

using DirectShowLib; DsDevice[] devices = DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice); for (int i = 0; i < devices.Length; i++) { Console.WriteLine($"{i}: {devices[i].Name}"); }

我实际跑过的输出类似这样:

0: USB2.0 Camera 1: Integrated Camera 2: HD Capture Card

这里有个细节:GetDevicesOfCat每次返回的都是全新COM对象,使用完后需要调用Marshal.FinalReleaseComObject释放,否则内存会涨。调试时看不出影响,挂一晚上就会发现句柄数爆炸。

还有一点,DsDevice.Name是设备友好名称,不代表唯一身份。如果同一型号插了两台,名字一模一样,这时候要用devices[i].Moniker,也就是设备的COM标识,后续构建采集图时也要靠它指定具体设备。多相机项目里,这个Moniker就是稳定区分设备的唯一依据。

3. 核心链路拆解:枚举设备、构建Graph、拉取帧数据

3.1 DirectShow里“Graph”是什么,可以类比成流水线

DirectShow里最核心的抽象叫Filter Graph,翻译成人话就是一条数据流水线。相机设备是“源头”Filter,负责发出视频帧;后续接的Filter负责处理或输出;管道的连接关系叫Pin。DirectShowLib把这套COM机制封装成了C#接口,虽然名字还是那么绕,但用起来其实很直接。

想看到画面或拿到帧数据,起码要做四件事:

  1. 创建Graph对象和捕获构建器;
  2. 把相机设备Filter加进Graph;
  3. 把视频数据引到预览窗口或SampleGrabber(帧抓取器);
  4. 启动流水线,开始跑帧。

我之前看过不少人一上来就写一堆接口强转,最后到处释放COM,搞得晕头转向。其实只要记住这条主线,其他的都是围绕它打转。

3.2 用GraphBuilder把相机画面接出来

下面这段代码是初始化一条采集链路的标准姿势,我用注释标出了每一步的意图:

using DirectShowLib; // 1. 创建设备Filter DsDevice[] devices = DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice); IBaseFilter sourceFilter = null; int hr = DirectShowLib.DsDevices.AddSourceFilterForMoniker( devices[0].Moniker, null, devices[0].Name, out sourceFilter); DsError.ThrowExceptionForHR(hr); // 2. 创建Graph和CaptureGraphBuilder IGraphBuilder graphBuilder = (IGraphBuilder)new FilterGraph(); ICaptureGraphBuilder2 captureBuilder = (ICaptureGraphBuilder2)new CaptureGraphBuilder2(); captureBuilder.SetFiltergraph(graphBuilder); // 3. 把设备Filter加入Graph graphBuilder.AddFilter(sourceFilter, devices[0].Name); // 4. 获取预览窗口接口,并把它嵌入到WinForm面板里 IVideoWindow videoWindow = null; captureBuilder.FindInterface( PinCategory.Preview, MediaType.Video, sourceFilter, typeof(IVideoWindow).GUID, out videoWindow); if (videoWindow != null) { videoWindow.put_Owner(panel.Handle); videoWindow.put_WindowStyle(WS_CHILD | WS_CLIPCHILDREN); videoWindow.put_MessageDrain(panel.Handle); videoWindow.put_Left(0); videoWindow.put_Top(0); videoWindow.put_Width(panel.Width); videoWindow.put_Height(panel.Height); } // 5. 渲染出流 captureBuilder.RenderStream(PinCategory.Preview, MediaType.Video, sourceFilter, null, null); // 6. 启动流水线 IMediaControl mediaControl = (IMediaControl)graphBuilder; mediaControl.Run();

captureBuilder.RenderStream是整条链路的关键,它会自动在相机的输出Pin后面接上合适的渲染器,不需要手动指定。如果只要后台采集不要显示窗口,第4步可以跳过,把PinCategory.Preview换成PinCategory.Capture,数据照样走,只是不渲染到屏幕。

很多机器视觉场景根本不需要用户看到画面,但为了调试方便,开发阶段建议保留一个小预览窗口。等算法跑通后再把预览去掉,能省不少CPU。

3.3 用SampleGrabber拉帧,拿回调数据

只出画面还不够,我们要的是每一帧的内存数据。这就需要往Graph里插入一个叫SampleGrabber的Filter,它像流水线上的质检工,每帧经过时提醒我们一声。

// 创建SampleGrabber并设置期望格式 SampleGrabber sampleGrabber = (SampleGrabber)new SampleGrabber(); IBaseFilter grabberBase = (IBaseFilter)sampleGrabber; AMMediaType mediaType = new AMMediaType(); mediaType.majorType = MediaType.Video; mediaType.subType = MediaSubType.RGB24; sampleGrabber.SetMediaType(mediaType); // 加到Graph里 graphBuilder.AddFilter(grabberBase, "SampleGrabber"); // 在RenderStream时把SampleGrabber串在sourceFilter后面 captureBuilder.RenderStream( PinCategory.Capture, MediaType.Video, sourceFilter, grabberBase, null); // 开启回调,第二个参数用1表示BufferCB ISampleGrabber cbGrabber = (ISampleGrabber)sampleGrabber; cbGrabber.SetBufferSamples(true); cbGrabber.SetCallback(new SampleGrabberCallback(), 1);

回调接口的实现如下:

public class SampleGrabberCallback : ISampleGrabberCB { public int SampleCB(double sampleTime, IMediaSample pSample) { return 0; } public int BufferCB(double sampleTime, IntPtr pBuffer, int bufferLen) { // 不要在这里做耗时操作,尽快把数据拷贝出去 byte[] rawData = new byte[bufferLen]; Marshal.Copy(pBuffer, rawData, 0, bufferLen); return 0; } }

这里SetCallback的第二个参数是1,系统就会调BufferCB,直接把数据指针给过来。如果只关心帧率,可以用SampleCB,它只传IMediaSample,少一次拷贝。但绝大多数视觉项目都要拿像素数据,所以BufferCB更常用。

要注意的是:BufferCB运行在DirectShow的内部线程上,千万别在里面做图像处理、写文件、更新UI这种费时操作。不然帧率会被拖到惨不忍睹,甚至把Graph阻塞死。正确做法是拷贝出byte[]后扔到队列,由另一个工作线程消费。

4. 帧数据到手之后:抓图保存与实时处理的分岔路

4.1 最直接的应用:单帧抓拍保存

工业检测场景里最常见的需求是触发抓拍。采集人员按一个按钮,上位机取当前一帧存成BMP或JPG。实现上,只要在BufferCB里把最后一次数据存成一个字段,然后由外部线程读取即可。

private byte[] _latestFrame; private readonly object _frameLock = new object(); public int BufferCB(double sampleTime, IntPtr pBuffer, int bufferLen) { byte[] buffer = new byte[bufferLen]; Marshal.Copy(pBuffer, buffer, 0, bufferLen); lock (_frameLock) { _latestFrame = buffer; } return 0; } public Bitmap GetLatestBitmap(int width, int height) { byte[] snapshot; lock (_frameLock) { if (_latestFrame == null) return null; snapshot = (byte[])_latestFrame.Clone(); } Bitmap bmp = new Bitmap(width, height, PixelFormat.Format24bppRgb); BitmapData bmpData = bmp.LockBits( new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb); // 相机输出的行字节数可能不等于 width*3,需要对齐到4 int stride = bmpData.Stride; int srcStride = width * 3; for (int y = 0; y < height; y++) { Marshal.Copy( snapshot, y * srcStride, bmpData.Scan0 + y * stride, srcStride); } bmp.UnlockBits(bmpData); return bmp; }

这里最容易被忽略的是stride对齐问题。BMP每一行的字节数必须是4的倍数,很多相机输出确实是width*3,但某些分辨率下width*3不能被4整除,直接拷贝就会出现斜纹或锯齿。所以严谨的代码一定要按BitmapData.Stride逐行拷贝,而不是一次性拷贝整个缓冲区。

宽度和高度怎么知道?可以从最开始设置的AMMediaType里拿,也可以在RenderStream之后调用IAMStreamConfigGetFormat查询。实际项目里,我通常固定先让相机输出一个已知分辨率,再配合SetFormat做配置。

4.2 实时处理的另一个方案:队列加消费线程

如果要做实时检测,比如每秒处理25帧,不能每来一帧都在回调里跑算法,那样一帧卡住后面全乱了。建议做一个有容量的帧队列。

我实践中用ConcurrentQueue<byte[]>配合丢弃旧帧策略,保证消费端拿到的永远是最新数据:

ConcurrentQueue<byte[]> _frameQueue = new ConcurrentQueue<byte[]>(); const int MaxQueueSize = 3; public int BufferCB(double sampleTime, IntPtr pBuffer, int bufferLen) { byte[] buffer = new byte[bufferLen]; Marshal.Copy(pBuffer, buffer, 0, bufferLen); _frameQueue.Enqueue(buffer); while (_frameQueue.Count > MaxQueueSize) { _frameQueue.TryDequeue(out _); } return 0; }

限制队列长度是为了防止算法处理速度跟不上时内存无限膨胀。我见过有人直接把所有帧都塞进List,结果内存占用直线拉升,进程崩溃。控制好队列深度,就相当于给流水线一个背压,最坏情况也是丢帧,不会崩。

实时处理线程再从队列里取数据转成Bitmap,转完丢给OpenCvSharp或Halcon的接口继续算。C#机器视觉社区常见的做法就是这样:DirectShowLib负责取图,OpenCvSharp负责算法,两边各司其职。

4.3 针对C#机器视觉开发的额外提示

不少做视觉的朋友还会把DirectShowLib和VisionMaster、Halcon联合使用。这没关系,只要从DirectShowLib拿到标准的Bitmapbyte[],就能无缝转给这些算法库。比如Halcon的HObject可以从指针直接构造,OpenCvSharp的Mat也能从Bitmap或者内存指针构造。

关于分辨率设置,我建议创建Graph之后、调用RenderStream之前,先通过IAMStreamConfig试着设置分辨率和帧率。为什么是这个时机?因为某些摄像头驱动在Pin没有连接时允许修改格式,一旦连接建立了就锁定格式,再要改就麻烦得多。完整思路是用FindInterface拿到IAMStreamConfig,调用SetFormat设置VideoInfoHeader里的宽度高度和帧率,然后再RenderStream。这段代码比较长,但它会让你少走半天弯路。

5. 实际使用中踩过的坑:错误码、拔插和多相机

5.1 一启动就抛异常或者画面黑屏

最常见的报错是VFW_E_NOT_CONNECTED0x80040217这类HRESULT异常。遇到这种错误,先别急着怀疑代码,八成是SetMediaType设置的格式和相机实际输出不匹配。

很多免驱摄像头默认输出MJPG或者YUY2,而你把SampleGrabber的期望格式写成了RGB24。DirectShow理论上能做色彩空间转换,但转换需要依赖系统解码器,某些精简版系统上就是缺组件,于是连接失败。调试办法很简单:先不调用SetMediaType,让链路按默认格式连接,看能不能跑起来。能跑的话,再根据相机实际支持的格式调整期望值。

还有种“画面黑屏但程序不报错”的情况,通常是SampleGrabber没有真正挂在链路上。记住RenderStream第四个参数要传grabberBase,而且如果项目里同时有预览窗口和抓帧需求,FindInterface(PinCategory.Capture...)FindInterface(PinCategory.Preview...)返回的接口是不同的。抓帧一定要走PinCategory.Capture,走预览Pin拿到的数据很可能为空。

5.2 拔插相机导致的系统卡死与恢复

工业现场经常有操作工不小心踢掉USB线又插回去。DirectShow的Graph一旦设备断开,往往直接进入错误状态,IMediaControl.Run()会一直失败,甚至阻塞在内部等待上。

我的处理习惯分两层:第一层在WinForm里监听WM_DEVICECHANGE系统消息,检测到设备变更就触发重新枚举;第二层是先Stop()旧Graph,释放所有Filter和COM对象,再重新走一遍3.2节的初始化流程。

监听消息的核心代码大概这样:

protected override void WndProc(ref Message m) { const int WM_DEVICECHANGE = 0x0219; if (m.Msg == WM_DEVICECHANGE) { RebuildGraph(); } base.WndProc(ref m); }

注意RebuildGraph里一定不要直接在新线程里操作UI控件,应该用BeginInvoke调度到UI线程再重建。另外释放旧Graph时,顺序也很重要:先Stop(),再断开事件回调,最后逐个Marshal.ReleaseComObject。反了的话,有时会卡在ReleaseComObject出不来,因为内部线程还没退出。

5.3 多相机并行与资源回收

多相机的用法不是“一个Graph里挂多个设备”,而是每台相机各建一套Graph、各配一个回调对象。最开始我想过共享一个GraphBuilder,结果发现数据流串了,两台相机图像交替出现,完全没法用。后来改成每台相机独立一套对象,稳定多了。

资源回收方面,系统采集链路持有大量COM对象,如果每次关闭界面直接Application.Exit(),后台线程可能还在跑,导致进程残留。稳妥做法是在FormClosing事件里先把IMediaControl.Stop(),然后断开SampleGrabber的回调,最后释放IVideoWindow和所有IBaseFilter

有个很隐蔽的点是SampleGrabber的回调对象必须保持引用,不能让垃圾回收器提前回收掉。很多人把回调对象写成局部变量,结果回调不触发,排查半天。正确的做法是把回调对象存成字段,和Graph对象同生命周期。

最后再分享一个个人习惯。正式项目里我会在采集层外面包一层接口,比如定义ICameraService,内部再区分DirectShow实现和厂商SDK实现。这样客户今天要支持USB摄像头,用DirectShowLib;明天换成千兆网工业相机,直接换厂商SDK实现,上层视觉算法完全不用动。这也是我为什么一直觉得,设备接入这件事,不值得和具体业务绑死。先把帧数据稳定拿到手,后面的路就好走多了。

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

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

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

立即咨询