简介:一套基于C#与.NET框架的USB摄像头控制示例项目(Nighteop Camera),面向需要实现摄像头实时预览、夜视模式及外部USB设备通信的桌面端开发者,既可作为入门C#硬件编程的练手素材,也能作为二次开发的工程基础。压缩包共46个文件,整体约307KB,以6个.cs源码文件、10个dll类库和3个exe可执行程序为主体,同时包含.sln/.csproj工程配置、.pdb调试符号、.resx/.resources界面资源等,便于直接打开Visual Studio工程查看运行效果或定位问题。项目覆盖多个技术点:基于LibUsbDotNet与SharpUSBLib进行USB设备访问,使用MediaCapture或AForge.NET完成摄像头视频捕获,并通过调整曝光、增益或外部红外LED等方式实现夜视模式。代码中还有摄像头类封装、事件处理、异步操作以及错误处理示例,能帮助读者理解C#环境下从设备枚举、通信交互到界面集成的完整链路。压缩包已有2332人学习/下载,适合有一定.NET基础、希望快速掌握USB摄像头与硬件交互的开发者参考。
1. 用 C# 接 USB 摄像头:这套方案解决什么问题
做 C# 上位机的人,迟早都要接一次 USB 摄像头。工位调试软件要抓拍、检测外观、存档到 MES,客户从抽屉里掏出一个免驱摄像头,第一反应是找官方 SDK,结果官网连驱动页都找不到。其实这套“C# USB摄像头”玩法很统一:Windows 对符合 UVC 标准的摄像头提供通用驱动,.NET 里通过 DirectShow 就能把画面拿到手。网上流传的 Camera.rar,通常就是把设备枚举、预览、抓拍、录像组装好的完整工程;解压出来,照着读代码、改分辨率、接自己的回调,一小时就能跑通。这篇文章适合你在 WinForm 或 WPF 里写上位机、不想被厂家 SDK 绑架、又希望代码能复用到下一台摄像头上的场景。下面所有参数和坑,都按最常见的 UVC 摄像头来写。
2. 选型与最小实现:DirectShow 是 C# 接 USB 摄像头的最稳入口
2.1 为什么优先选 DirectShow 而不是 MediaFoundation
DirectShow 是微软在 ActiveMovie 时代定下来的采集框架,到现在 Windows 的 WDM/AVStream 驱动模型仍然对它提供完整支持。USB 摄像头走 UVC 协议,插进系统会挂到 usbvideo.sys 上,DirectShow 的 Video Capture Filter 几乎不用额外驱动就能出流。MediaFoundation 是后推的框架,接口更干净、也支持硬件解码,但在这类设备上有实际问题是:厂商测试投入少。很多低端免驱摄像头在 MediaFoundation 下要么枚举不到默认设备,要么只能出低分辨率;退回 DirectShow 才一切正常。做工业上位机,我要的是稳定和可复现,不要把采集链交给一个黑匣子。除非项目明确要求 UWP、新式 WinUI,或者要直接做硬件编码推流,否则首选 DirectShow。
C# 直接调 DirectShow 要写一堆 COM 互操作,所以常见做法是依赖第三方组件。免费开源组件里,AForge.NET 的 AForge.Video.DirectShow 流传最广,Accord.NET 是它的延续版本。有人说它好几年没更新,但它封装的底层接口至今变化极小,跑起来反而稳定。真正要留意的是它的目标框架停留在 .NET Framework 时代,新 SDK 项目引用它可能触发 System.Drawing.Common 兼容警告。我一般先按 AForge 跑通整个逻辑,让枚举、回调、分辨率这些概念都出现在代码里,再决定要不要换成 OpenCvSharp 的 VideoCapture。下面这张表是选型时最常比的几点。
| 对比项 | DirectShow | MediaFoundation |
|---|---|---|
| 系统要求 | Windows XP 至今 | Windows 7 开始可用 |
| UVC 摄像头兼容 | 几乎所有免驱摄像头 | 部分老旧机型对齐不完整 |
| 像素格式暴露 | YUY2、MJPG 直接可读 | 部分机型只暴露一种 |
| .NET 托管封装 | AForge/Accord 成熟 | 自己要写投影,资料少 |
| 调试工具链 | GraphStudioNext 可看图 | 工具不直观 |
2.2 用 AForge/Accord 三行代码完成枚举和预览
从零开始最快的路径,是拿到 Camera.rar 这种打包工程后,先找到其中读取摄像头的核心文件,再看它用的是哪个采集组件。大多数包用的都是下面这套结构:FilterInfoCollection 枚举设备,VideoCaptureDevice 打开设备,NewFrame 事件把帧送出来。
using AForge.Video; using AForge.Video.DirectShow; // 1. 枚举所有 USB 摄像头,这一步拿到的是 DirectShow 设备集合 FilterInfoCollection videoDevices = new FilterInfoCollection(FilterCategory.VideoInputDevice); if (videoDevices.Count == 0) { Console.WriteLine("没有找到摄像头,先检查连接和通用驱动"); return; } // 2. 用 MonikerString 而不是序号选中设备 VideoCaptureDevice camera = new VideoCaptureDevice(videoDevices[0].MonikerString); // 3. 注册帧回调,预览画面从这里出来 camera.NewFrame += (sender, eventArgs) => { // eventArgs.Frame 是采集线程内部的 Bitmap,必须 Clone 后另作他用 Bitmap copy = (Bitmap)eventArgs.Frame.Clone(); pictureBox1.Image?.Dispose(); pictureBox1.Image = copy; }; camera.Start();代码说明:FilterCategory.VideoInputDevice 是 DirectShow 给视频输入设备定义的过滤类别,它枚举的是系统里所有可用的采集 Filter。MonikerString 是每一个设备实例的稳定标识,比数组下标可靠。NewFrame 回调运行在后台采集线程,不是 UI 线程,所以直接改 PictureBox 有线程问题;我这里的写法是简化演示,实际项目里要么用 Invoke,要么把图片扔进队列由 UI 定时取。Start 方法启动采集后立刻返回,画面通过回调异步到达,别在 Start 后面同步等帧。
停止采集时一定要成对调用:
camera.SignalToStop(); camera.WaitForStop(); camera = null;SignalToStop 通知采集线程退出,WaitForStop 阻塞等待它真正结束。直接调 Stop 在某些设备上会卡死,这是老驱动留下的习惯,见后面避坑章节。
2.3 不依赖第三方组件时,自己写 DirectShow 互操作是底线
如果公司对开源组件有合规要求,或者目标机不能连网还原 NuGet 包,就得自己封 DirectShow。这件事没有想象中难,整个采集链核心就是创建 Filter Graph,往里面加一个视频采集 Filter,再把输出接到渲染或抓帧环节。下面是极简互操作声明,能跑通“创建对象”这一步:
// 自己声明 DirectShow 需要的两个 COM 接口:IFilterGraph 和 IMediaControl // 实际项目还要 IGraphBuilder、ICaptureGraphBuilder2、IBaseFilter、IPin、IAMStreamConfig [ComImport, Guid("56a86891-0ad4-11ce-b03a-0020af0ba770")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] interface IGraphBuilder { } [ComImport, Guid("56a86893-0ad4-11ce-b03a-0020af0ba770")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] interface IMediaControl { } // 通过 CLSID_FilterGraph 创建 Graph 对象 Type graphType = Type.GetTypeFromCLSID(new Guid("e436ebb3-524f-11ce-9f53-0020af0ba770")); IGraphBuilder graph = (IGraphBuilder)Activator.CreateInstance(graphType);这段代码里的 Guid 不是乱码,它们分别是 IGraphBuilder、IMediaControl 和 CLSID_FilterGraph 的 COM 标识。如果你解压 Camera.rar 后看到源码里有一长串 Guid 声明,别改它们,照着原样保留。自己封装的成本主要在 IAMStreamConfig 和 IPin 这两个接口上,它们负责读分辨率列表、设置视频格式,大概多写两三百行 interop。好处是项目不依赖 AForge 的更新节奏,坏处是调试难度上了一个台阶,所以我的建议是:先 AForge 跑通,再按需替换。
3. UVC 枚举与会话参数:区分多摄像头、分辨率、帧率的配置逻辑
3.1 用 SetupAPI 拿设备路径,构造物理 ID
多路摄像头工位最常见的翻车点,是代码写死用 videoDevices[0]。某天开机后系统的枚举顺序变了,另一台同型号设备排到前面,软件连错相机。DirectShow 只能告诉你设备集合,不告诉你设备的物理位置。要稳定区分设备,需要去查 Windows 设备接口信息,这一步由 SetupAPI 完成。
using System; using System.Collections.Generic; using System.Runtime.InteropServices; public static class UsbCameraEnum { private const int DIGCF_PRESENT = 0x2; private const int DIGCF_DEVICEINTERFACE = 0x10; [StructLayout(LayoutKind.Sequential)] public struct SP_DEVICE_INTERFACE_DATA { public int Size; public Guid InterfaceClassGuid; public int Flags; public IntPtr Reserved; } // devices 设备接口类:KSCATEGORY_VIDEO_CAMERA private static readonly Guid VideoCameraClass = new Guid("E5323777-F976-4f5b-9B55-B94699C46E44"); [DllImport("setupapi.dll", SetLastError = true, CharSet = CharSet.Auto)] private static extern IntPtr SetupDiGetClassDevs( ref Guid classGuid, IntPtr enumerator, IntPtr hwndParent, int flags); [DllImport("setupapi.dll", SetLastError = true)] private static extern bool SetupDiEnumDeviceInterfaces( IntPtr deviceInfoSet, IntPtr deviceInfoData, ref Guid interfaceClassGuid, int memberIndex, out SP_DEVICE_INTERFACE_DATA deviceInterfaceData); [DllImport("setupapi.dll", SetLastError = true, CharSet = CharSet.Auto)] private static extern bool SetupDiGetDeviceInterfaceDetail( IntPtr deviceInfoSet, ref SP_DEVICE_INTERFACE_DATA deviceInterfaceData, IntPtr deviceInterfaceDetailData, int deviceInterfaceDetailDataSize, out int requiredSize, IntPtr deviceInfoData); [DllImport("setupapi.dll")] private static extern bool SetupDiDestroyDeviceInfoList(IntPtr deviceInfoSet); public static List<string> EnumerateCameraPaths() { var result = new List<string>(); IntPtr devs = SetupDiGetClassDevs(ref VideoCameraClass, IntPtr.Zero, IntPtr.Zero, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); for (int index = 0; ; index++) { var ifData = new SP_DEVICE_INTERFACE_DATA(); ifData.Size = Marshal.SizeOf(ifData); if (!SetupDiEnumDeviceInterfaces(devs, IntPtr.Zero, ref VideoCameraClass, index, out ifData)) { break; // 返回 false 表示枚举结束 } int requiredSize = 0; SetupDiGetDeviceInterfaceDetail(devs, ref ifData, IntPtr.Zero, 0, out requiredSize, IntPtr.Zero); if (requiredSize <= 0) continue; IntPtr pDetail = Marshal.AllocHGlobal(requiredSize); try { // 变长结构体的第一个 DWORD 表示结构体自身大小, // Unicode 模式下填 4 字节即可。 Marshal.WriteInt32(pDetail, 4); if (SetupDiGetDeviceInterfaceDetail(devs, ref ifData, pDetail, requiredSize, out requiredSize, IntPtr.Zero)) { // DevicePath 从结构体偏移 4 个字节开始 string devicePath = Marshal.PtrToStringAuto(IntPtr.Add(pDetail, 4)); if (!string.IsNullOrEmpty(devicePath)) result.Add(devicePath); } } finally { Marshal.FreeHGlobal(pDetail); } } SetupDiDestroyDeviceInfoList(devs); return result; } }常见 DevicePath 长这样:\\?\usb#vid_046d&pid_0825#8c0...&mi_00#...。里面的 vid_ 是厂商 ID,pid_ 是产品型号,mi_ 是设备功能号,后面还有实例序列号。同型号多台相机的区别就在序列号和 USB 端口路径上。拿到 DevicePath 后,可以和 DirectShow 的 MonikerString 做映射,之后无论系统怎么重排枚举顺序,软件都能找到正确的那一路。
提示:如果现场只有一台摄像头,匹配 VID+PID+MI 就够了。多台同型号时,还要比对实例序列号或者父 USB 端口号,后者需要再查 DEVPKEY_Device_Parent,代码量不多,但必须在设备插入状态下读取。
3.2 分辨率与帧率怎么选:理解 VideoCapabilities 和像素格式
UVC 摄像头不是任意分辨率都能出图。传感器内部做裁切和缩放后,对 PC 暴露的是一组固定预设,每种预设包含分辨率、帧率、像素格式三个参数。在 AForge 里对应 VideoCapabilities 集合:
var camera = new VideoCaptureDevice(videoDevices[0].MonikerString); foreach (var cap in camera.VideoCapabilities) { Console.WriteLine( $"{cap.FrameSize.Width}x{cap.FrameSize.Height}, " + $"{cap.AverageFrameRate}fps, {cap.BitCount}bit"); } // 找一档不低于 1280 宽、且帧率最高的配置 var choose = camera.VideoCapabilities .Where(c => c.FrameSize.Width >= 1280) .OrderByDescending(c => c.AverageFrameRate) .FirstOrDefault(); if (choose != null) { camera.VideoResolution = choose; }注意 AverageFrameRate 是设备标称值,不是实测值。同一分辨率下,帧率档位可能写 5/15/30,实际能不能跑满还取决于 USB 带宽、CPU 解码能力和驱动实现。BitCount 则对应像素格式,采集时最常遇到的是下面三种:
| 格式 | 特点 | 带宽占用 | CPU 开销 | 适用场景 |
|---|---|---|---|---|
| YUY2/YUYV | 无压缩,色彩还原准 | 高 | 很低 | 近距离图像处理、测量 |
| MJPG | 帧内 JPEG 压缩 | 低 | 解码消耗 CPU | 1080P 以上高帧率 |
| H.264 | 帧间压缩,延迟略高 | 最低 | 依赖显卡硬解 | 长时间录像、推流 |
带宽不够时,同分辨率下切到 MJPG 往往能把帧率提上去。代价是解码多耗 CPU,所以采集端和业务处理端的 CPU 预算要一起算。
3.3 帧回调分离:抓帧、入队、处理分三段
NewFrame 回调天然运行在采集线程上,这是整个方案里最容易被写坏的地方。新手喜欢把识别、保存、甚至是网络上传直接写进回调,画面立刻变卡。正确思路是回调只负责拿帧,处理放到另外的工作线程。
using System.Collections.Concurrent; var queue = new ConcurrentQueue<Bitmap>(); var camera = new VideoCaptureDevice(videoDevices[0].MonikerString); camera.NewFrame += (sender, e) => { var clone = (Bitmap)e.Frame.Clone(); // 队列积压超过 5 帧,丢最旧的,保证实时性 if (queue.Count > 5 && queue.TryDequeue(out var dropped)) dropped.Dispose(); queue.Enqueue(clone); }; // 处理线程 Task.Run(() => { while (processRunning) { if (queue.TryDequeue(out var frame)) { ProcessFrame(frame); // 识别、测量都在这里做 frame.Dispose(); } else { Thread.Sleep(5); } } });队列深度 5 是实时性和延时的折中:太深会让画面延迟越来越明显,太浅扛不住处理端的一次抖动。如果你的项目后面要接 AI 识别,可以把 ConcurrentQueue 换成 Channel 的 BoundedChannel,用内置背压控制积压,效果更干净。这里锁定的原则是:采集线程绝不能等业务线程。
4. USB 摄像头 C# 开发高频避坑:现象、原因、解决
4.1 初始化失败:摄像头被占用
现象:camera.Start() 不报错,但 NewFrame 从未触发;或者直接抛-2147467259。原因:摄像头被另一个进程占住。微信、浏览器、远程会议软件都会在后台占用摄像头,最常见的其实是上一个测试程序没退出。UVC 摄像头同一时间只允许一个会话独占抓帧。解决:先关掉可能占用的软件;代码里停止摄像头必须成对调用 SignalToStop 和 WaitForStop;程序退出时把 camera 置 null,再调用一次 GC 也能缩短释放时间。初始化失败时把 Win32 错误码打到日志,不要只显示“打开失败”。
4.2 画面偏绿花屏:像素格式和解码不匹配
现象:图像大面积发绿,边缘有彩色条纹,人脸像底片。原因:摄像头输出 YUY2 或 YUYV 格式,显示层却按 RGB32 解释。很多第三方组件对部分新设备的默认格式协商不准,或者你在代码里手动指定了分辨率但没有同步指定像素格式。解决:先打印 VideoCapabilities 里的 BitCount 和 FrameSize,确定设备真正输出的格式;如果设备支持 MJPG,优先切到 MJPG 再看颜色是否正常;自己封 DirectShow 时,用 IAMStreamConfig 读取 VIDEOINFOHEADER 里的 biCompression 字段,按格式做转换。
4.3 硬刚帧回调导致预览卡顿
现象:任务管理器 CPU 不高,但预览窗口一卡一卡,严重时整个 UI 失去响应。原因:NewFrame 在采集线程里逐帧触发,你在里面做识别、保存、上传,等于把 USB 驱动的取帧节奏拖慢,设备端开始丢帧。另一个原因是没有跨线程更新 UI,直接在回调里改 PictureBox。解决:回调里只做 Clone 和入队,耗时操作全部移到独立线程;UI 更新通过 Control.BeginInvoke 投递到主线程。可以用帧计数和入队时间戳验证卡顿发生在采集段还是处理段。
4.4 同样的摄像头在 USB 3.0 口反而掉帧
现象:从 USB 2.0 口换到 USB 3.0 口后,帧率下降、偶发掉线,说出去别人都不信。原因:一半是玄学,一半是硬件工程。部分主板 USB 3.0 控制器和芯片组共用中断,负载上来后等时出现问题;劣质线缆超过 3 米也可能造成传输错误;还有些设备的 UVC 带宽协商在 xHCI 控制器上表现奇怪。解决:先换回 USB 2.0 口对比,排除驱动问题;换一根短一点的带屏蔽线;在设备管理器里找到对应 USB 根集线器,把“允许计算机关闭此设备以节约电源”关掉。不要一上来就改代码。
4.5 拔插一次后连错摄像头
现象:程序第一次运行正常,拔掉相机重插后,读到的第一台设备变成另一个型号。原因:DirectShow 的枚举顺序由总线和驱动加载顺序决定,不是固定不变的,索引完全不可靠。解决:用第 3.1 节的 SetupAPI 枚举 DevicePath,通过 VID、PID、MI 锁定设备。匹配逻辑写成独立方法,减少业务代码里的字符串比较:
private static bool IsTargetCamera(string devicePath) { return devicePath.Contains("vid_0c45") && devicePath.Contains("pid_6366") && devicePath.Contains("mi_00"); }这里 vid_0c45 和 pid_6366 要替换成你设备铭牌上的实际值。多台同型号设备时,再比对 DevicePath 里的序列号字段。坚决不要在代码里用 VideoCaptureDevice 的索引做长期绑定,索引只适合临时测试。
5. 交付前的最后一步:帧率验证、内存与打包注意事项
5.1 用计时器量帧率,不要用肉眼看
判断摄像头是否达标,最直接的办法是统计单位时间内的回调次数。这个数字反映的是“应用层实际收到帧的频率”,比设备标称帧率更有说服力。
int frameCount = 0; var stopwatch = Stopwatch.StartNew(); camera.NewFrame += (sender, e) => { frameCount++; if (stopwatch.ElapsedMilliseconds >= 1000) { Console.WriteLine($"过去 1 秒收到 {frameCount} 帧"); stopwatch.Restart(); frameCount = 0; } }; camera.Start(); Console.WriteLine("按 Q 键退出..."); while (Console.ReadKey().Key != ConsoleKey.Q) ; camera.SignalToStop(); camera.WaitForStop();这个测试代码里没有做图像处理,纯粹测采集链路。如果实测帧率低于设备标称,优先检查是否开了高分辨率、像素格式是不是 YUY2、机器上有没有其他程序抢带宽。测的时候把 UI 缩到最小,避免渲染影响结果。
5.2 打包与验收:运行库、位宽、长期稳定性
打包交付时,目标框架和运行库是踩坑重灾区。老工程包里的代码常写的是 .NET Framework 3.5 或 4.0,新机器不一定带对应运行时,编译部署时会报“缺少 .NET Framework”。这种情况我一般保持目标框架为 .NET Framework 4.8,它在 Win10/Win11 上默认可用,回归成本最低。如果用 .NET 6 以上重写,需要单独处理 System.Drawing.Common 在非 Windows 环境的兼容问题,好在工控机基本都是 Windows,这个坑容易绕过去。
发布配置建议固定 x64,不要用 AnyCPU。DirectShow 的 COM 互操作在 x64 下没有 32 位进程的 WoW64 重定向问题,运行也更稳定。如果代码里用到了 C++ 编写的原生封装,目标机必须装对应版本的 Visual C++ Redistributable,缺了它程序可能启动即崩溃。
我自己做这套东西的习惯是:采集层单独一个工程,与业务逻辑完全分离;设备标志只认 DevicePath,不认索引;每次切换分辨率后必须实测帧率再验收。遇到玄学报错,先拔摄像头、看设备管理器里有没有警告号,再回来查程序。很多时候问题出在供电、线材和驱动上,不全是代码的事。希望帮到你。
本文还有配套的精品资源,点击获取