C# USB摄像头开发:DirectShow枚举与OpenCvSharp采集实战
2026/9/14 2:35:35 网站建设 项目流程

简介:一套名为Nighteop Camera的C#与.NET框架USB摄像头控制工程示例代码,面向需要快速入门摄像头开发、USB设备交互或夜视功能调用的Windows开发者,也适合对USB外设控制感兴趣的初学者。压缩包为Visual Studio解决方案,共46个文件,包含6个.cs源码、1个.sln、1个.csproj,以及配套的dll、exe、pdb、resources、resx等构建与运行文件,整体约307KB,适合直接打开工程查看具体实现思路与目录组织。项目演示了如何通过MediaCapture或AForge.NET捕获视频流,借助LibUsbDotNet等库访问USB设备,并针对夜视模式调整曝光、增益或控制红外LED。其中还涉及事件处理、异步编程和错误处理机制,配合简单的界面布局,可在实时预览中调节参数并增强程序稳定性。已有2331人学习下载,对于想用C#控制摄像头、了解硬件交互和夜视场景的开发者,可从中获取完整的工程示例、源码结构以及排错思路,便于快速扩展出图像分析或视频录制功能,同时也可作为二次开发的基础。

1. C# USB摄像头开发第一步,是先把Camera类从压缩包里搬出来

一份命名为 Camera.rar 的 C# USB摄像头 工程,最容易让人忽略的不是压缩包里缺 DLL,而是设备枚举和帧回调这两个骨架。很多下载包里放了历史版本的采集库、几个 Form,但换到新机器上永远列不出摄像头。如果 nighteop 是整理这段代码的人,那它记录的应该也是同一个问题:怎么在 WinForms/WPF 上位机里稳定罗列出 USB 摄像头、把画面拉出来,再做抓拍和参数调节。

对开发五年以上的人,重新走一遍这条路的价值在于:把 DirectShow 的 Moniker 枚举、OpenCvSharp 的 VideoCapture 帧循环、IAMVideoProcAmp 参数调节这三个环节各自踩过的坑串起来。对新手,这条链路能直接照抄。本文按枚举、打开、调参、多路收尾的顺序讲,不讨论 WPF 渲染和 AI 识别,只解决一个目标:让任意一台 UVC 摄像头在你的 C# 程序里稳定出图、能调参数、拔线不崩。

2. C# USB摄像头列表拉不起来,多半是枚举 DirectShow 出的错

2.1 先分清 DirectShow 和 Windows Runtime 两套设备枚举

Windows 下 C# USB摄像头 能看到的设备枚举口径不止一种。DirectShow 把摄像头当作 Filter,挂在CLSID_VideoInputDeviceCategory这个 Category 下,用ICreateDevEnum枚举出来的是 Moniker;Windows Runtime 的DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)也能列出摄像头,但返回的是符号链接和 EnclosureLocation。桌面版 WinForms 项目通常选前者,因为 AForge、OpenCvSharp 的 VideoCapture 最终都通过 DirectShow 打开设备;用 Runtime 枚举到的 Id 还要再转一次才能让 DirectShow 使用,绕路且容易出错。

需要特别区分的是,DirectShow 枚举和 USB 设备枚举不是一回事。USB 摄像头插上后,设备管理器里能看到USB\VID_xxxx&PID_xxxx,但如果驱动层没有把 UVC 视频流注册到 DirectShow Category,C# 侧就看不到相机。此时先去设备管理器确认 PNPClass 是不是 Camera,再回头查代码,不要一上来怀疑 COM 调用写错了。

2.2 用 ICreateDevEnum 枚举视频输入 Category:三个字段一次拿全

DirectShow 枚举是 C# 高级编程里 COM 互操作最常出错的段落之一。标准流程是创建系统设备枚举器,按视频输入 Category 拿到IEnumMoniker,再逐个读取属性包里的FriendlyNameDevicePath和 Moniker 字符串。下面这段代码可以直接放进工具类里。

// 需要引用 System.Runtime.InteropServices.ComTypes using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; public class CameraDeviceInfo { public string Name { get; set; } public string DevicePath { get; set; } public string Moniker { get; set; } } public static class DsCameraEnumerator { [ComImport, Guid("29840822-5B84-11D0-BD3B-00A0C911CE86")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface ICreateDevEnum { [PreserveSig] int CreateClassEnumerator(ref Guid clsid, out IEnumMoniker enumMoniker, int dwFlags); } [ComImport, Guid("55272A00-42CB-11CE-8135-00AA004BB851")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IPropertyBag { [PreserveSig] int Read([MarshalAs(UnmanagedType.LPWStr)] string name, [Out, MarshalAs(UnmanagedType.Struct)] out object value, IntPtr errorLog); [PreserveSig] int Write([MarshalAs(UnmanagedType.LPWStr)] string name, ref object value, IntPtr errorLog); } public static List<CameraDeviceInfo> Enumerate() { var list = new List<CameraDeviceInfo>(); Guid category = new Guid("860BB310-5D01-11D0-BD3B-00A0C911CE86"); // CLSID_SystemDeviceEnum 与 IID_ICreateDevEnum 是同一个 GUID Type comType = Type.GetTypeFromCLSID(new Guid("29840822-5B84-11D0-BD3B-00A0C911CE86")); var comObj = (ICreateDevEnum)Activator.CreateInstance(comType); IEnumMoniker monikerEnum; if (comObj.CreateClassEnumerator(ref category, out monikerEnum, 0) != 0 || monikerEnum == null) return list; // 视频设备 Category 不存在时返回空列表 IMoniker[] one = new IMoniker[1]; Guid bagId = new Guid("55272A00-42CB-11CE-8135-00AA004BB851"); while (monikerEnum.Next(1, one, IntPtr.Zero) == 0) { var info = new CameraDeviceInfo(); object storage = null; try { one[0].BindToStorage(null, null, ref bagId, out storage); var bag = (IPropertyBag)storage; object value; bag.Read("FriendlyName", out value, IntPtr.Zero); info.Name = value as string; bag.Read("DevicePath", out value, IntPtr.Zero); info.DevicePath = value as string; one[0].GetDisplayName(null, null, out string displayName); info.Moniker = displayName; } catch { // 虚拟摄像头或驱动不完整时,属性读取会失败,跳过即可 } finally { if (storage != null) Marshal.ReleaseComObject(storage); Marshal.ReleaseComObject(one[0]); } if (!string.IsNullOrEmpty(info.Name)) list.Add(info); } Marshal.ReleaseComObject(monikerEnum); return list; } }

逻辑说明:CreateClassEnumerator拿到的是摄像头 Category 的 Moniker 枚举器;每次调用Next返回一个IMonikerBindToStorage得到该设备的属性包。FriendlyName是设备显示名,DevicePath是设备实例路径,GetDisplayName得到完整 Moniker 字符串,后续打开设备时通过BindToObject用它。finally 里释放 COM 对象是必须的,否则 Win10 长跑后会遇到枚举越来越慢。

参数说明:三个字段各有用途,建议都存下来。

字段来源典型值主要用途
FriendlyNamePropertyBag"USB Camera"下拉框显示
DevicePathPropertyBag\\?\usb#vid_xxxx&pid_xxxx#...精确定位设备实例
MonikerGetDisplayName@device:pnp:\\?\usb#...后续 BindToObject 打开

提示:摄像头列表为空时,先确认系统里有没有虚拟相机、驱动有没有正确注册 DirectShow Filter。CreateClassEnumerator第二个参数在无设备时返回 null,这是正常状态,不是异常。

2.3 用 WMI 拉 PID/VID:一台机器插多个 USB 摄像头时按名字排不可靠

多相机工位最常见的坑是:两台同型号摄像头FriendlyName完全一样,枚举顺序一变就串台。此时需要用 WMI 读设备实例 ID,从中解析 VID/PID 和接口路径,和DevicePath做匹配。

// .NET Framework 直接引用 System.Management; // .NET 6+ 需要 NuGet 安装 System.Management using System.Management; public class UsbCameraIdentity { public string Name { get; set; } public string Vid { get; set; } public string Pid { get; set; } public string DeviceId { get; set; } } public static List<UsbCameraIdentity> GetUsbCameraPidVid() { var result = new List<UsbCameraIdentity>(); string query = "SELECT DeviceID, Name FROM Win32_PnPEntity " + "WHERE PNPClass = 'Camera' OR PNPClass = 'Image'"; using (var searcher = new ManagementObjectSearcher(query)) { foreach (ManagementBaseObject obj in searcher.Get()) { string id = obj["DeviceID"]?.ToString() ?? ""; var match = System.Text.RegularExpressions.Regex.Match(id, @"VID_([0-9A-Fa-f]{4})&PID_([0-9A-Fa-f]{4})"); if (!match.Success) continue; result.Add(new UsbCameraIdentity { Name = obj["Name"]?.ToString() ?? "", Vid = match.Groups[1].Value.ToUpperInvariant(), Pid = match.Groups[2].Value.ToUpperInvariant(), DeviceId = id }); } } return result; }

逻辑说明:WMI 查的是设备节点而不是 DirectShow Filter,返回结果能回答“这台 USB 摄像头物理上是不是同一个”。当多路相机的FriendlyName一样时,用DeviceId里的 VID/PID 加接口路径可以区分,再和 DirectShow 枚举出来的DevicePath做交集。参数说明:PNPClass='Camera'覆盖 Win10 以后的 UVC 相机,加'Image'是为了兼容老式驱动;正则VID_([0-9A-Fa-f]{4})&PID_([0-9A-Fa-f]{4})是 USB 设备实例 ID 的固定格式。

注意:Win32_PnPEntity查询有开销,不要放在 UI 线程每秒刷新。正确做法是只在软件启动、用户点刷新、收到插拔事件后调用。

3. 用 C# 打开 USB 摄像头并连续出图:Camera 类设计与帧循环

3.1 AForge、OpenCvSharp、纯 DirectShow 三种路怎么选

很多 Camera.rar 里用的是 AForge.Video.DirectShow,代码短,但 AForge.NET 已停止维护,对 x64 和 Win10 之后的 UVC 相机兼容性要看驱动状态。OpenCvSharp 的 VideoCapture 在 Windows 上默认也走 DirectShow,但帧循环、格式转换、Mat 内存管理比自己写 COM 调用省事很多。纯 DirectShow 加 SampleGrabber 的路线最可控,代价是要自己管 IGraphBuilder、IBaseFilter、媒体类型协商,工程量是前者的两三倍。

方案优势主要坑适合场景
AForge.Video.DirectShowAPI 简单,历史代码多停止维护,偶发 x64 异常快速验证、旧项目维护
OpenCvSharp VideoCapture跨平台,图像处理链路完整Mat 生命周期要自理上位机、视觉检测
自写 DirectShow完全可控,能读私有属性COM 状态复杂,调试费时工业相机、特殊帧格式

我的选择是:功能验证用 OpenCvSharp,定位到具体驱动属性后再去看 DirectShow 接口。这样既不丢帧率,也能在出问题时向下排查。

3.2 最小可运行的 UsbCamera 类:Task 循环加 Mat 克隆

绕开控件直接写一个 Camera 类,是后续所有功能的基础。采集线程只做一件事:从VideoCapture读帧,克隆后触发事件。UI 线程不参与读取,否则画面一卡,采集就跟着卡。

// NuGet: OpenCvSharp4、OpenCvSharp4.Extensions using System; using System.Threading; using System.Threading.Tasks; using OpenCvSharp; public class UsbCamera : IDisposable { private VideoCapture _capture; private CancellationTokenSource _cts; private Task _loop; /// <summary>把一帧 Mat 交给订阅者,订阅者负责 Dispose</summary> public event EventHandler<Mat> FrameReady; public bool Open(int deviceIndex, int width = 1280, int height = 720, double fps = 30) { // 显式指定 DirectShow 后端,避免被 MSMF 带偏 _capture = new VideoCapture(deviceIndex, VideoCaptureAPIs.DSHOW); if (!_capture.IsOpened()) return false; _capture.Set(VideoCaptureProperties.FrameWidth, width); _capture.Set(VideoCaptureProperties.FrameHeight, height); _capture.Set(VideoCaptureProperties.Fps, fps); return true; } public void Start() { if (_loop != null) return; _cts = new CancellationTokenSource(); _loop = Task.Run(() => CaptureLoop(_cts.Token)); } private void CaptureLoop(CancellationToken token) { using (var frame = new Mat()) { while (!token.IsCancellationRequested) { try { if (!_capture.Read(frame) || frame.Empty()) { Thread.Sleep(10); // 断线或驱动忙,不要死循环刷 CPU continue; } var clone = frame.Clone(); // 帧内存在缓冲区复用,不 Clone 会互相覆盖 FrameReady?.Invoke(this, clone); Thread.Sleep(0); // 强制让出时间片,避免采集线程饿死 UI } catch { Thread.Sleep(50); // 拔线瞬间 Read 可能抛异常 } } } } public void Stop() { _cts?.Cancel(); try { _loop?.Wait(500); } catch { } _capture?.Release(); _cts?.Dispose(); _loop = null; } public void Dispose() => Stop(); }

逻辑说明:打开设备时显式传VideoCaptureAPIs.DSHOW,在 Windows 上能得到更接近底层驱动的行为,Linux 上则换成VideoCaptureAPIs.V4L2Read失败时 sleep 10ms 而不是continue,防止驱动恢复过程中把 CPU 跑满;frame.Clone()是为了避免下一帧覆盖当前回调正在使用的缓冲区。Thread.Sleep(0)只让出当前时间片,不会造成明显延迟。

参数说明:deviceIndex从 0 开始,对应 DirectShow 枚举顺序;widthheightfps只是请求值,驱动可能自动降级,所以要在打开后回读实际值。FrameReady回调运行在采集线程,事件里不能直接操作控件,必须切到 UI 线程。

3.3 C# 循环数据采集与 UI 刷新卡顿:一帧最多跨一次线程

WinForms 里循环采集卡 UI,最常见原因不是采集慢,而是每帧都在回调里Invoke并且每次都 new Bitmap。正确做法是:采集线程读帧、克隆、触发事件;UI 订阅者收到后BeginInvoke,在 UI 线程把 Mat 转 Bitmap 再显示。转出来的 Bitmap 用完就 Dispose,否则 GDI 句柄几小时涨到几万。

private void OnFrameReady(object sender, Mat frame) { if (_pictureBox.IsHandleCreated) { _pictureBox.BeginInvoke(new Action(() => { using (var bmp = OpenCvSharp.Extensions.BitmapConverter.ToBitmap(frame)) { var old = _pictureBox.Image; _pictureBox.Image = (Bitmap)bmp.Clone(); old?.Dispose(); } frame.Dispose(); // 保证每一帧都释放 })); } else { frame.Dispose(); } }

逻辑说明:BeginInvoke是异步的,UI 忙时回调会堆积,所以显示帧率低于采集帧率时,界面会自动丢帧,这比同步Invoke卡住采集线程健康得多。bmp.Clone()必须保留:BitmapConverter.ToBitmap返回的 Bitmap 与 Mat 共享内存,不 Clone 就释放 Mat 会出现花屏。参数说明:如果画面有撕裂感,把Thread.Sleep(0)换成Thread.Yield();不要用Thread.Sleep(1),Windows 下 1ms 的 sleep 精度并不稳定。

4. C# USB 摄像头的分辨率、FPS 与亮度曝光怎么调:先读回再确认

4.1 分辨率不是设置完就生效:Set 之后必须 Get 回读

UVC 驱动对分辨率和帧率的支持是“一组离散档位”,不是连续区间。请求1920x1080@60而驱动不支持时,VideoCapture.Set可能返回 false 或静默失败,画面停在默认的640x480。所以设置完立刻回读,回读值对不上就降一档,UI 上显示实际值而不是请求值。

public (int Width, int Height, double Fps) ApplyAndReadBack( VideoCapture cap, int w, int h, double fps) { cap.Set(VideoCaptureProperties.FrameWidth, w); cap.Set(VideoCaptureProperties.FrameHeight, h); cap.Set(VideoCaptureProperties.Fps, fps); int rw = (int)cap.Get(VideoCaptureProperties.FrameWidth); int rh = (int)cap.Get(VideoCaptureProperties.FrameHeight); double rfps = cap.Get(VideoCaptureProperties.Fps); return (rw, rh, rfps); }

逻辑说明:这段代码把“请求”和“实际生效”拆开,先 Set 后 Get,拿到的才是驱动最终给出的档位。参数说明:Get返回的是 double,帧率可能出现 29.xxx,不要用==比较,允许 1fps 误差即可。

分辨率帧率常见场景备注
640x48030/60对焦测试、条码识别MJPEG 大多能跑满
1280x72030上位机监控USB2.0 够用
1920x108030抓拍、视觉检测最好 USB3.0 或 MJPEG 压缩
2592x194415静态拍照全分辨率帧率低

4.2 亮度、对比度、曝光:OpenCvSharp 的 Set 只覆盖了标准属性

USB 摄像头在 UVC 规范里通过IAMVideoProcAmp(亮度、对比度、饱和度)和IAMCameraControl(曝光、焦距、缩放)访问。OpenCvSharp 的VideoCaptureProperties.BrightnessContrastExposure在 Windows DSHOW 后端就是转发到这两个接口,所以多数标准属性可以用Set直接设置,不用自己写 COM 调用。

属性OpenCvSharp 枚举典型范围常见问题
亮度Brightness-64 ~ 64部分驱动 0 表示中间值
对比度Contrast0 ~ 100默认值不一定是 50
饱和度Saturation0 ~ 100出厂差异明显
曝光Exposure-13 ~ -1负值表示 log 尺度
自动曝光AutoExposure0.25 / 0.750.25 自动,0.75 手动
// 先关自动曝光,再手动设曝光值,顺序不能反 _capture.Set(VideoCaptureProperties.AutoExposure, 0.75); _capture.Set(VideoCaptureProperties.Exposure, -6); int exposure = (int)_capture.Get(VideoCaptureProperties.Exposure); bool autoExposure = _capture.Get(VideoCaptureProperties.AutoExposure) > 0.5;

逻辑说明:自动曝光开启时,手动曝光值会被驱动忽略,所以先切手动再写值。参数说明:不同驱动的Exposure语义可能不同,界面上做一个 TrackBar 让用户拖动观察效果,比写死参数可靠。如果AutoExposure设置后没有生效,说明该驱动不走 UVC 标准属性,只能直接调IAMCameraControl

4.3 参数改不动、画面全黑:四个检查点按顺序走

参数改不动不一定是代码问题。按经验,检查顺序是:设备管理器确认相机没有被其他软件独占;关闭自动曝光后再调亮度;确认程序位数和驱动位数一致;确认没有走错采集后端。OpenCvSharp 默认后端在某些环境是 MSMF,MSMF 和 DSHOW 对同样设备的属性支持不完全一样,这也是Open时需要显式指定DSHOW的原因。另外,把分辨率设到1920x1080@60但 USB2.0 带宽不够时,画面会黑或帧率掉一半,回读 FPS 就能判断。

5. 多路 USB 摄像头同时采集,以及拔插后不崩的收尾写法

多路采集是 USB 摄像头工程量上去后最常见的场景。每一路摄像头开一个独立的UsbCamera实例,采集线程相互独立,UI 用事件分发。设备拔掉时,VideoCapture.Read开始返回 false,采集线程会空转,但调用Release()有概率卡住,所以停止顺序比启动顺序更重要:先Cancel,再Wait,最后Release。不要在采集线程里调用Stop(),否则是在释放自己,容易死锁。

private readonly ConcurrentDictionary<string, UsbCamera> _cameras = new(); public void AddCamera(string key, int index) { var cam = new UsbCamera(); if (!cam.Open(index, 1920, 1080, 30)) { cam.Dispose(); return; } cam.FrameReady += OnFrameReady; cam.Start(); _cameras[key] = cam; } public void RemoveCamera(string key) { if (_cameras.TryRemove(key, out var cam)) { cam.FrameReady -= OnFrameReady; cam.Dispose(); } }

配合 WMI 监听 USB 摄像头插拔时,事件到达后不要立刻重新枚举,因为驱动 Filter 还没注册完。延迟 500ms 再刷新列表,能避开大部分“刚插入就枚举不到”的假故障。

ManagementEventWatcher _watcher; public void StartWatch() { var query = new WqlEventQuery( "SELECT * FROM __InstanceOperationEvent WITHIN 2 " + "WHERE TargetInstance ISA 'Win32_PnPEntity' " + "AND (TargetInstance.PNPClass = 'Camera' OR TargetInstance.PNPClass = 'Image')"); _watcher = new ManagementEventWatcher(query); _watcher.EventArrived += (s, e) => { string eventType = e.NewEvent.ClassPath.ClassName; // 创建或删除事件 // 不要在这里操作 UI 和采集对象,用 BeginInvoke 丢给主线程 }; _watcher.Start(); }

验证多路采集是否干净,除了看画面,还可以记录Process.GetCurrentProcess().HandleCount。开始采集后每 10 秒采样一次,拔插两次后句柄数只增不减,说明 Bitmap 或 COM 对象有泄漏。另一个兜底技巧是把释放动作从主线程摘出去,避开驱动在断开瞬间的阻塞:

public void Stop() { _cts?.Cancel(); var capture = _capture; _capture = null; Task.Run(() => { capture?.Release(); }); }

这段代码的含义是:先取消采集循环,再把Release()放到后台线程,即使驱动在线程里阻塞 200ms,UI 也不会卡死。代价是释放时机不再确定,所以只用在拔线容错场景;正常退出程序时,仍然建议同步调用Stop()等待资源回收。

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

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

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

立即咨询