C#操作USB摄像头:DirectShow封装实现WinForm实时预览与拍照

发布时间:2026/10/16 3:56:52
C#操作USB摄像头:DirectShow封装实现WinForm实时预览与拍照
简介面向.NET平台C#开发者的USB摄像头操作资源围绕设备枚举、连接打开、实时帧捕获、拍照存图、视频暂停恢复及资源释放等完整流程展开适合WinForm桌面应用、图像采集与安防监控等场景。实现上基于AForge.NET等第三方库通过VideoCaptureDevice与NewFrame事件驱动处理视频帧非常适合需要为桌面软件快速集成摄像头功能的初中级开发者。压缩包共38个文件包含可直接运行的exe、核心C#源码、依赖dll库以及Visual Studio工程配置.sln/.csproj等类型其中dll提供运行时依赖、exe可直接体验效果、cs为源码整体仅181KB轻量且便于阅读。该资源已有3912人学习下载。随包示例工程提供完整的摄像头操作源码可直观对照设备选择、帧保存与界面联动逻辑同时参考暂停、停止、释放资源的规范写法以及实际项目中常见的权限与异常处理思路可直接迁移复用。1. 一个C#操作USB摄像头的库从枚举到拍照解掉WinForm开发里最难受的一环做桌面端业务系统时最容易被卡住的需求之一就是“把USB摄像头里的画面嵌到我自己的窗格里”。去网上搜C#拍照得到的多半是调用系统相机应用的方案点一下弹出一个独立窗口拍完再拿回来根本没法跟业务界面融合。我也被折腾过AForge停更多年在新环境里跑不动MediaFoundation在WinForm里接入又太绕。这套资源解决的就是这个问题——它基于DirectShow在C#里自己封装了一套摄像头操作组件支持设备枚举、分辨率切换、实时预览、单帧抓拍、参数调节和照片保存全程不依赖第三方相机SDK还带一个能直接跑的WinForm示例。适合做体检拍照、扫码采集、人脸录入这类桌面场景也适合想搞明白Windows摄像头底层调用链路的.NET开发者新手能照着跑通老手也能看懂封装思路。2. 技术选型与工程结构为什么是DirectShow以及项目是怎么组织起来的在.NET里操作USB摄像头主流有三条路WIA、DirectShow和MediaFoundation。WIA只能简单调用系统拍照窗口拿不到连续帧做预览MediaFoundation设计更新但在WinForm里要做实时预览需要写不少异步代码而且很多老型号USB摄像头压根没有MediaFoundation驱动DirectShow是Windows上资历最老的视频架构普通摄像头、USB内窥镜、工业相机几乎都提供DirectShow驱动兼容性最好。这套资源走的是DirectShow加P/Invoke自封装路线刻意绕开那个年久失修的DirectShow.NET包。原因很简单那些第三方包最后更新年份太早对.NET 6以上的COM互操作支持不完整出现问题根本无从下手不如自己从接口层往上写。2.1 工程目录与核心类职责把项目里的窗体资源文件去掉之后核心代码集中在下面这几个目录里UsbCameraDemo/ ├── Cameras/ │ ├── CameraManager.cs // 设备枚举与选择 │ ├── CameraDevice.cs // 单路摄像头封装预览、拍照、参数控制 │ ├── DirectShow/ │ │ ├── FilterGraph.cs // COM组件封装负责构建视频管线 │ │ ├── SampleGrabber.cs // 抓帧回调把某一帧图像取出来 │ │ └── VideoSettings.cs // 分辨率、帧率、亮度等参数 │ └── Interop/ │ ├── ComInterop.cs // DirectShow相关COM接口、GUID定义 │ └── NativeMethods.cs // DllImport声明和媒体类型结构体 ├── Demo/ │ └── MainForm.cs // WinForm界面绑定下拉框和预览控件 └── Utilities/ └── BitmapConverter.cs // 将视频帧转成System.Drawing.Bitmap这里的分层思路很直观CameraManager只负责“找到有哪些摄像头”CameraDevice是外部调用的主入口DirectShow文件夹里是COM组件的封装Interop里是接口声明和结构体定义。我拿到一套代码最先看的一定是CameraDevice因为业务代码只跟它打交道底层的FilterGraph和SampleGrabber对上层完全透明。这样即使你完全不懂DirectShow也能先把画面跑起来再慢慢往底层看。真正需要二次开发时改DirectShow下面的类就够了UI层不用动。2.2 为什么用P/Invoke而不是NuGet现成的DirectShow包DirectShow.NET这个库历史包袱很重网上能找到的大多版本对.NET Core支持靠强行兼容COM接口定义和现代SDK有的对不上经常出现内存访问越界、句柄泄漏这类玄学问题。手动P/Invoke其实不需要把整个DirectShow封装一遍日常操作只依赖四个核心COM接口ICreateDevEnum负责枚举系统里的设备IMoniker标识单个设备IFilterGraph把各个Filter连接成一条流水线ISampleGrabber负责把视频帧截下来。这套资源就是把这几个接口精简封装的整个Interop目录一共才几百行代码出了问题翻一遍全明白了。枚举设备时最关键的调用链是这一段// CameraManager.GetDevices() var comEnum (ICreateDevEnum)Activator.CreateInstance( Type.GetTypeFromCLSID(Clsid.SystemDeviceEnum)); IEnumMoniker enumMoniker; comEnum.CreateClassEnumerator(Clsid.VideoInputDeviceCategory, out enumMoniker, 0); IMoniker[] moniker new IMoniker[1]; IntPtr fetched IntPtr.Zero; Liststring names new Liststring(); while (enumMoniker.Next(1, moniker, fetched) 0) { IPropertyBag bag; moniker[0].BindToStorage(null, null, ref IID.IPropertyBag, out bag); object value; bag.Read(FriendlyName, out value, null); names.Add(value.ToString()); Marshal.ReleaseComObject(bag); Marshal.ReleaseComObject(moniker[0]); } return names;逻辑说明先用CreateClassEnumerator拿到视频输入设备类别的枚举器然后反复调用Next取每个设备。每取到一个IMoniker就把它绑定到IPropertyBag上读取设备的FriendlyName作为显示名称。这里有个必须注意的参数Clsid.VideoInputDeviceCategory是视频输入设备的类别GUID固定为{860BB310-5D01-11D0-BD3B-00A0C911CE86}一旦写错CreateClassEnumerator返回空枚举你怎么扫描都扫不到设备。fetched传IntPtr.Zero可以直接忽略返回的实际数量因为我们逐个循环处理。最关键的是Next方法每返回一个IMoniker必须用Marshal.ReleaseComObject手动释放否则COM引用计数只增不减循环几百次之后进程内存就开始不正常上涨。2.3 参数类型的定义与回调机制摄像头分辨率、帧率这些参数在DirectShow里统一用AM_MEDIA_TYPE结构体描述。这个库在ComInterop.cs里按x86/x64的内存布局重新声明了一遍这也是最容易踩坑的地方C#手工声明COM结构体字段顺序、内存对齐方式、嵌套结构必须和C头文件完全一致差一个StructLayout的Pack值都会导致内存错乱。下面是最简版本的结构体声明[StructLayout(LayoutKind.Sequential, Pack 1)] public struct AM_MEDIA_TYPE { public Guid majortype; // 大类视频一般是 MEDIATYPE_Video public Guid formattype; // 格式类型如 FORMAT_VideoInfo public IntPtr pUnk; public int cbFormat; public IntPtr pbFormat; }逻辑说明majortype区分是音频还是视频formattype指向具体的格式头cbFormat表示pbFormat指向的内存大小pbFormat则是指向VIDEOINFOHEADER结构体的指针。用Pack 1是为了避免默认对齐方式在x64平台上把结构体撑大导致从DirectShow返回的数据解析错位。如果这里用默认的Pack 0常见症状就是抓回来的帧宽高全是错的画面扭曲而且很难排查到结构体定义上。实际在设置分辨率时你需要把pbFormat转成VIDEOINFOHEADER改完里面的宽高字段再写回去不能直接改外层结构体。3. 核心模块拆解从设备打开到画面图像的解码链路这一章把核心调用路径完整走一遍从打开设备、设置预览格式、抓帧、拍照到调节参数每一步都是实际可运行的关键代码片段。3.1 打开设备并构建预览管线调用入口是CameraDevice.Open它接收一个设备的MonikerString、目标宽度、高度和帧率public bool Open(string monikerString, int width, int height, int fps) { // 用给定的moniker字符串创建filter加入graph IFilterGraph graph new FilterGraph() as IFilterGraph; IBaseFilter source CreateFilterFromMoniker(monikerString); graph.AddFilter(source, Source); // 创建SampleGrabber并设置媒体类型 var sampleGrabber new SampleGrabber(); AMMediaType mt new AMMediaType(); mt.majortype MediaType.Video; mt.subtype MediaSubType.RGB24; sampleGrabber.SetMediaType(mt); graph.AddFilter(sampleGrabber, Grabber); // 直接连接source和grabber不接renderer graph.Connect(source, sampleGrabber); _streamConfig source as IAMStreamConfig; _videoProcAmp source as IAMVideoProcAmp; return true; }逻辑说明整个预览链路就是把摄像头源直接连到采样器上中间不加渲染器这样画面不会跳到系统默认的预览窗口上。SetMediaType里指定RGB24是让SampleGrabber收到的是RGB格式而不是YUV省去后续颜色转换的麻烦。这里要留意如果设备不支持RGB24graph.Connect这步会直接失败返回VFW_E_CANNOT_CONNECT代码里通常会把它翻译成“视频流连接失败”。这个错误和“设备打不开”完全不是一回事排查方向是先确认分辨率、格式是否在设备支持范围内。3.2 SampleGrabber回调拿到一帧图像当图运行起来后摄像头每产生一帧数据SampleGrabber的BufferCB回调就会被触发public int BufferCB(double sampleTime, IntPtr pBuffer, int bufferLen) { // 把非托管缓冲拷到托管byte数组 byte[] data new byte[bufferLen]; Marshal.Copy(pBuffer, data, 0, bufferLen); // 用BitmapConverter把字节流转成Bitmap Bitmap bmp BitmapConverter.ToBitmap( data, _videoWidth, _videoHeight, _videoStride); frameReady?.Invoke(bmp); return 0; }逻辑说明回调收到的是一个非托管内存指针必须先拷贝到托管byte[]里再转成Bitmap绝不能直接拿指针去构造Bitmap对象。这个回调跑在DirectShow的流线程上不是UI线程所以里面只能把事件抛出去不能在回调函数里直接操作WinForm控件。界面层接住frameReady事件后需要用Control.BeginInvoke把更新画面的操作切回UI线程否则会出现跨线程操作控件异常运气好不报错但界面会一卡一卡。参数说明sampleTime是当前帧的时间戳通常用不到pBuffer指向帧数据bufferLen是缓冲区大小。_videoWidth、_videoHeight是媒体类型里的实际尺寸_videoStride是行字节数这三者的计算必须保持一致否则画面会偏移成斜纹。3.3 拍照保存从抓帧到文件拍照的本质是“等待下一帧回调到达把图像直接保存成文件”。这个库的CapturePhoto实现如下public bool CapturePhoto(string filePath) { Bitmap captured null; using (var waitHandle new ManualResetEvent(false)) { EventHandlerBitmap handler null; handler (sender, bmp) { captured (Bitmap)bmp.Clone(); frameReady - handler; // 关键拍完立即解挂避免重复触发 waitHandle.Set(); }; frameReady handler; waitHandle.WaitOne(TimeSpan.FromSeconds(2)); } if (captured null) return false; captured.Save(filePath, ImageFormat.Jpeg); captured.Dispose(); return true; }逻辑说明这里比网上很多Demo多了一步“拍完解挂”。如果不主动解除事件订阅连续拍照时之前挂载的handler还会被后来的帧触发造成Bitmap重复克隆和累积泄漏。WaitOne(TimeSpan.FromSeconds(2))表示最多等两秒等不到帧就返回避免拍照按钮把界面卡死。保存格式用JPEG最稳妥JPEG编码会自动处理图像质量问题不用去管原Bitmap是24位还是32位。如果一定要存PNG建议先转成32bppArgb再存否则某些驱动下输出颜色会整体偏暗。3.4 参数调节亮度、分辨率、帧率参数调节主要是通过IAMVideoProcAmp接口和IAMStreamConfig接口完成public void SetBrightness(int value) { _videoProcAmp.Set( VideoProcAmpProperty.Brightness, value, VideoProcAmpFlags.Manual); } public bool SetResolution(int width, int height) { AMMediaType pmt null; _streamConfig.GetFormat(out pmt); var vih (VideoInfoHeader)Marshal.PtrToStructure( pmt.pbFormat, typeof(VideoInfoHeader)); vih.SrcRect.right width; vih.SrcRect.bottom height; vih.BmiHeader.Width width; vih.BmiHeader.Height height; Marshal.StructureToPtr(vih, pmt.pbFormat, false); return _streamConfig.SetFormat(pmt) 0; }逻辑说明SetBrightness里Manual标志很重要不指定这个标志部分驱动会把参数当作自动调节你设置的亮度会被面板忽略。SetResolution的做法是取出当前媒体类型改掉宽高后写回。这里最大的坑是SrcRect和BmiHeader.Width都要改只改SrcRect会让设置看起来成功但实际采集分辨率还是旧的。用SetFormat后最好立刻用GetFormat读回来确认数值是否生效因为有些摄像头驱动对分辨率组合有严格限制比如只接受16的倍数你传一个奇数进去它可能悄悄给你改成最接近的偶数。4. 运行与调试从克隆到弹出画面的完整步骤前面的代码看明白了接下来就是把整个项目跑起来。这套资源里带一个可直接运行的WinForm Demo运行流程足以覆盖大多数需求。4.1 环境准备与编译配置开发环境用Visual Studio 2022.NET 6.0及以上版本项目引用了System.Drawing.Common注意这个包只支持Windows不要拿到Linux上编。解决方案有两个项目一个是UsbCameraDemo主示例另一个是封装库项目。直接打开解决方案还原NuGet就能编译。最关键的一步是平台目标。USB内窥镜、老工业相机等很多设备驱动只提供32位桥接库如果平台目标选x64CreateClassEnumerator会返回空枚举界面上看不到任何摄像头。建议右键项目属性在“生成”里把“平台目标”改成x86并且勾选“首选32位”选项去掉。如果没有特殊兼容需求直接x86是最省事的这也是我调试了几次官方Demo后踩出来的一条血泪经验。改完之后F5运行主窗体弹出下拉框里应该能列出当前电脑上的摄像头名称。4.2 界面操作与代码入口主窗体的布局很直接顶部一个ComboBox用于选择设备旁边“启动预览”和“停止预览”两个按钮中间一个PictureBox用于显示摄像头画面最下面一个“拍照”按钮。按钮事件的代码逻辑如下private void btnStart_Click(object sender, EventArgs e) { var deviceName cboDevices.SelectedItem as string; if (deviceName null) return; _camera new CameraDevice(); _camera.FrameReady Camera_FrameReady; _camera.Open(deviceName, 640, 480, 30); _camera.Start(); } private void Camera_FrameReady(object sender, Bitmap bmp) { if (InvokeRequired) { BeginInvoke(new Action(() pictureBox.Image (Bitmap)bmp.Clone())); return; } pictureBox.Image (Bitmap)bmp.Clone(); }逻辑说明启动预览时先创建CameraDevice订阅FrameReady事件然后Open并Start。回调里用InvokeRequired判断是否需要跨线程切换必须切到UI线程再更新PictureBox否则界面在拖动窗口时会频繁报跨线程错误。每次回调都Clone一次Bitmap是为了避免PictureBox在异步绘制时原图已经被下一次帧回调Dispose掉。如果你发现画面延迟大可以把Clone去掉但前提是PictureBox必须改成自行绘制到非托管内存这里不展开。4.3 用GraphEdit排查画面连接问题如果界面显示黑色或干脆连接失败常规日志不足以定位时可以借助系统自带的GraphEdit或社区版GraphStudioNext来检查。把摄像头作为源Filter连接到SampleGrabber再连接到Null Renderer如果能连通说明设备工作正常如果连不上问题多半出在媒体类型协商上。我在开发这套Demo时遇到过一台老设备只支持YUY2格式而代码里强制要求RGB24GraphEdit里同样连不通。解决办法是先枚举设备支持的媒体类型从中挑一个RGB24兼容的或者在SetMediaType里留空让DirectShow自动协商。4.4 调试时的日志输出习惯这套代码里在FilterGraph.cs里留了几个辅助日志方法可以用Debug.WriteLine把每次Connect、SetFormat、BufferCB的调用和返回码打出来。排查问题时先看Connect返回码再看SetFormat返回码一般就能锁定是哪一层出了问题。DirectShow的返回码都是HRESULT可以直接把它转成uint再查WinError.h中的定义常见的有VFW_E_CANNOT_CONNECT无法连接、VFW_E_NO_ACCEPTABLE_TYPES找不到可接受的媒体类型、S_OK成功。刚开始调试时我也觉得看这些数字是玄学后来养成一个习惯每次改动代码都先看日志里的返回码再判断是不是驱动问题排查效率提升很明显。5. 常见问题与排查摄像头项目里的四个经典翻车场景这一章专门整理摄像头调试中最常见的几个坑现象、原因、解决方式一条条对应着写。5.1 黑屏但能拍照画面永远是黑的现象点击启动预览后PictureBox一片黑但是点击拍照按钮能正常保存出一张黑色图片。原因最常见的是摄像头已被其他应用占用Windows不允许同一设备被两个进程同时打开。另一个原因是Start成功后没有调用Render相关方法但本项目的代码结构不会出现这个问题所以优先考虑占用冲突。解决先关闭微信、腾讯会议等有摄像头权限的软件再重新启动预览。如果还是黑屏检查BufferCB是否被调用在回调里打日志如果日志没输出说明图根本没有在跑问题在Start之后的Run阶段。5.2 同一台电脑枚举出多个同名摄像头现象下拉框里显示两个相同名称的设备但物理电脑只插了一个USB摄像头。原因某些摄像头驱动会在系统里注册多个设备节点一个指向实际传感器一个指向该驱动的虚拟枚举设备。这是摄像头厂商驱动实现问题不是代码问题。解决枚举时读取设备的DevicePath而不是只读FriendlyName根据DevicePath去重。或者按设备类别过滤很多虚拟节点属于“影像设备”或“USB Composite Device”可以直接过滤掉。改代码时在读取FriendlyName的同时额外读一次DevicePath保留第一次出现的设备后续同名设备忽略。5.3 设置1920x1080分辨率后连接失败现象默认640x480能正常预览一改成1920x1080就报视频流连接失败。原因摄像头驱动未上报这个分辨率或者上报的分辨率帧率组合不支持。DirectShow的媒体类型协商是严格匹配的请求一个非支持的格式会导致连接失败。解决先在设备打开后枚举所有媒体类型在界面上列出分辨率选项不让用户手输分辨率。枚举方式是通过IAMStreamConfig.GetNumberOfCapabilities和GetStreamCaps循环读取取出来的是VIDEO_STREAM_CONFIG_CAPS结构体里面有MinFrameInterval、MaxFrameInterval等字段可以顺带做帧率约束。5.4 程序退出后摄像头指示灯仍然亮着现象关闭登录窗口摄像头红外灯或指示灯不灭只有进程彻底退出后灯才熄灭。原因CameraDevice.Dispose里没有及时释放COM引用FilterGraph还持有设备Filter的引用。C#的本地对象被GC回收不代表底层的COM对象被释放因为DirectShow引用计数由手动管理。解决在Dispose中按逆序释放先停止图再断开连接然后释放所有Filter最后释放Graph。顺序反了容易出现先释放Graph、后释放Filter导致Filter里的接口悬空。我在代码里做了一个ReleaseAll方法把所有需要释放的引用放到一个Listobject里在Dispose时倒序遍历并Marshal.ReleaseComObject这样能保证每路设备收到正确的释放顺序摄像头灯在进程关闭前就会灭掉。6. 二次开发方向从拍照到录像与人脸检测的改造路径代码跑通之后最常遇到的二次开发需求就是录像和算法集成。先说说从拍照扩展到录像本质就是把BufferCB回调里那一帧Bitmap按时间顺序写入编码器。推荐用OpenCvSharp的VideoWriter它内部封装了FFmpeg写入H.264文件非常稳定。改动量很小只需要在BufferCB里额外做一次videoWriter.Write(mat)注意把Bitmap先转成Mat并且控制写入频率否则CPU会被冲得很高。做录像时还要记得处理采集帧率与真实时间的对应关系可以用sampleTime做帧间隔计算。人脸检测也是常见改造点。拿到Bitmap后转成Mat调用OpenCvSharp的CascadeClassifier检测人脸把检测到的人脸矩形画在原始Bitmap上再显示到界面拍照保存的也是带框的图像。这个方案比在底层YUV帧上做人脸检测简单太多代价是多一次RGB到Mat的转换消耗大概在几百毫秒级别日常够用。如果想压到实时检测就把检测放到独立Task里用队列传递Bitmap避免阻塞BufferCB回调。最后一个建议是写一组回归验证步骤。每次改动完我都习惯把“设备枚举、分辨率切换、开始预览、拍照、停止预览、释放设备”这条链路完整跑一遍同时在任务管理器里盯着进程内存观察是否重复拍照后内存回落。这套流程已经成为我的固定测试套路凡是摄像头项目改代码我都强制走一遍再提交能少留很多线上隐患。希望帮到你。本文还有配套的精品资源点击获取