ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C#使用DirectShowLib读取USB工业相机图像的完整指南

C#使用DirectShowLib读取USB工业相机图像的完整指南 简介基于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#接口虽然名字还是那么绕但用起来其实很直接。想看到画面或拿到帧数据起码要做四件事创建Graph对象和捕获构建器把相机设备Filter加进Graph把视频数据引到预览窗口或SampleGrabber帧抓取器启动流水线开始跑帧。我之前看过不少人一上来就写一堆接口强转最后到处释放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之后调用IAMStreamConfig的GetFormat查询。实际项目里我通常固定先让相机输出一个已知分辨率再配合SetFormat做配置。4.2 实时处理的另一个方案队列加消费线程如果要做实时检测比如每秒处理25帧不能每来一帧都在回调里跑算法那样一帧卡住后面全乱了。建议做一个有容量的帧队列。我实践中用ConcurrentQueuebyte[]配合丢弃旧帧策略保证消费端拿到的永远是最新数据ConcurrentQueuebyte[] _frameQueue new ConcurrentQueuebyte[](); 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拿到标准的Bitmap或byte[]就能无缝转给这些算法库。比如Halcon的HObject可以从指针直接构造OpenCvSharp的Mat也能从Bitmap或者内存指针构造。关于分辨率设置我建议创建Graph之后、调用RenderStream之前先通过IAMStreamConfig试着设置分辨率和帧率。为什么是这个时机因为某些摄像头驱动在Pin没有连接时允许修改格式一旦连接建立了就锁定格式再要改就麻烦得多。完整思路是用FindInterface拿到IAMStreamConfig调用SetFormat设置VideoInfoHeader里的宽度高度和帧率然后再RenderStream。这段代码比较长但它会让你少走半天弯路。5. 实际使用中踩过的坑错误码、拔插和多相机5.1 一启动就抛异常或者画面黑屏最常见的报错是VFW_E_NOT_CONNECTED或0x80040217这类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实现上层视觉算法完全不用动。这也是我为什么一直觉得设备接入这件事不值得和具体业务绑死。先把帧数据稳定拿到手后面的路就好走多了。本文还有配套的精品资源点击获取
返回列表