简介这份C# USB摄像头多开工具面向需要在Windows上同步管理多路摄像头的开发者与项目实践者借助C#与AForge组件实现多个USB摄像头同时开启、分辨率选择以及九画面均分组合可用于视频会议、监控预览、直播录制等需要多路视频并行的场景。压缩包共56个文件约7.35MB包含cs源码、sln与csproj工程文件、AForge系列dll依赖、resx与resources资源、exe与pdb编译产物及配置说明等便于直接运行或二次开发。已有1538人学习下载。读者可从中了解DirectShow视频采集、多线程取流与界面布局组织方式并参考工程结构、依赖引用和错误处理思路快速搭建自己的多摄像头预览工具。1. 为什么单线程开两路 USB 摄像头必翻车C# 多开工具要解决的三件事手上有两个同型号 USB 摄像头插在同一台机器上用 C# 写十几行代码就能各开一路预览——然后第二个窗口黑屏、程序关不掉、设备管理器里多出一个黄色感叹号。C# USB 摄像头多开这件事难的不是第一路开起来而是第二路、第三路同时跑以及退出时能不能把设备干干净净还回去。这份多开工具的思路是把 AForge.NET 的采集层包成「一路一个会话」枚举、开流、取帧、释放四段各管各的谁挂了不牵连别人。适合做多机位录制、双目标定、产线工位监看的开发者也适合刚被「第二路打不开」劝退过一次的人。2. 选型与骨架AForge.NET 采集层怎么拆成一路一会话2.1 四条采集路线为什么最后落到 AForge.Video.DirectShowWindows 上拿 USB 摄像头的数据能走的路就那么几条每条都有明确的代价。手写 DirectShow 的 COM 互操作最自由IAMStreamConfig、IAMCrossbar全在你手上但光是把IBaseFilter、IPin、IMediaControl这一套接口声明写对就得小半天而且一旦设备回调出异常堆栈是 COM 层的排查体验很差。OpenCVSharp 的VideoCapture上手最快new VideoCapture(0)一行出图但它的索引是按设备顺序排的两个同型号摄像头热插拔一次索引就可能互换你在界面上点了左边那个窗口出来的其实是右边那颗的画面。MediaCapture 是系统自带的能力异步 API 干净但它的生命周期跟应用模型绑得紧做个小工具反而绕。AForge.NET 这套 DirectShow 封装老归老胜在 API 面窄到可以背下来设备枚举一个FilterInfoCollection开流一个VideoCaptureDevice取帧一个NewFrame事件。它内部已经把 COM 那层脏活包完了你拿到的是托管Bitmap。方案依赖体积多开表现分辨率控制适合场景手写 DirectShow interop无额外包代码量大完全可控全权IAMStreamConfig需要精确控制曝光、像素格式OpenCVSharp VideoCaptureOpenCV 运行时较大可以但索引易错位有限算法侧顺手复用MediaCapture系统内置好但 API 约束多较全新应用模型下的项目AForge.Video.DirectShow两个小程序集一路一实例互不干扰VideoCapabilities够用Windows 桌面小工具我一般会先问一句你是要「尽快看到图」还是要「精确控制每一路」。前者选 AForge后者直接上 interop别在中间层纠结。这个工具选 AForge是因为它的诉求恰好是「多路、稳定、界面简单」而不是「调快门、调增益」。2.2 多开的本质是三件事不是多开几个线程很多人第一反应是「开个线程池每路一个线程去读帧」。方向对但抓错了重点。VideoCaptureDevice内部本来就会为自己起一个采集线程你Start()之后不需要再手写线程去轮询帧是通过NewFrame事件推给你的。真正让多开变难的是下面三件事第一是设备唯一标识。FilterInfoCollection里每个元素的Name是设备名两个同型号摄像头拿到的字符串完全一样你没法靠它区分。能用的只有MonikerString那是 DirectShow 的设备路径同一台机器上每颗摄像头不同。第二是会话独立释放。A 路关闭时如果顺手把整个采集层都Dispose了B 路会跟着掉。所以每一路必须有自己的对象图释放动作只能在自家会话内部发生。第三是帧数据的所有权。NewFrameEventArgs.Frame指向的那块Bitmap不是给你的回调一返回它就会被下一帧覆盖。多路场景下这个坑被放大因为 CPU 忙着处理三路拷贝回调返回得慢你手上那张图分分钟变成脏数据。想清楚这三点代码结构自然就出来了一个CameraDescriptor描述「哪一路」一个CameraSession承载「这一路的生命周期」中间靠一个只保留最新帧的槽位做解耦。2.3 工程骨架两个 NuGet 包和一个 x64 目标依赖只有两个AForge.Video.DirectShow会自动带上AForge.Video。别去网上找 dll 手动引用版本混了会出System.TypeLoadException而且报错信息完全不提是哪个包的问题。# 在项目目录下执行只装采集相关的两个包 dotnet add package AForge.Video.DirectShow # AForge.Video 是它的依赖通常会自动带进来没带就补一句 dotnet add package AForge.Video工程文件里把目标框架和平台钉死Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeWinExe/OutputType !-- AForge 是老程序集走 Windows 兼容层必须带 -windows 后缀 -- TargetFrameworknet8.0-windows/TargetFramework UseWindowsFormstrue/UseWindowsForms !-- 采集层有 32/64 位之分钉成 x64 免得 AnyCPU 在不同机器上跑出两种行为 -- Platformsx64/Platforms /PropertyGroup ItemGroup PackageReference IncludeAForge.Video.DirectShow / /ItemGroup /Project说明两点。TargetFramework必须是net8.0-windows这类带 Windows 后缀的目标因为System.Drawing.Bitmap在跨平台目标下不可用编译期不报错运行到取帧那一行才炸。Platforms钉成 x64 是因为同一台机器上如果混装了 32 位和 64 位的采集驱动AnyCPU 在两种宿主进程里可能枚举出不同数量的设备这种问题排查起来非常费时间。目录上我一般这么分Devices/放枚举Sessions/放会话与帧槽Ui/放窗口和定时器Diagnostics/放掉帧计数和日志。不是为了好看是为了让「哪一路出问题」这件事在堆栈里能一眼看出来。3. 四条代码路径枚举、开流、回调、释放各写一遍3.1 枚举设备同型号的两颗摄像头怎么区分先把设备列表拉出来并且把「界面显示的名字」和「开流用的路径」分开存。很多人图省事直接用Name去开流单摄像头时没问题两个同型号插上就开始随机开错。using AForge.Video.DirectShow; public sealed class CameraDescriptor { public string Tag { get; init; } ; // 逻辑编号 CAM00/CAM01全程用它做 key public string Name { get; init; } ; // 设备名同型号会重复只用于显示 public string Moniker { get; init; } ; // DirectShow 设备路径开流只认它 } public static IReadOnlyListCameraDescriptor Enumerate() { var result new ListCameraDescriptor(); // FilterCategory.VideoInputDevice 只枚举视频输入设备不会把音频设备混进来 var devices new FilterInfoCollection(FilterCategory.VideoInputDevice); for (int i 0; i devices.Count; i) { result.Add(new CameraDescriptor { Tag $CAM{i:D2}, Name devices[i].Name, Moniker devices[i].MonikerString }); } return result; }Tag是这整套代码的主键后面所有的字典、日志、界面控件都靠它对齐绝不拿Name当 key。Moniker里包含设备路径同型号的两颗摄像头在这一项上不同这是唯一可靠的区分依据。注意一点Moniker在设备换个 USB 口之后可能变化所以别把它持久化到配置文件里当永久 ID每次启动重新枚举最稳。如果确实要记住「上次用的是哪颗」用「设备名 在同名设备中的序号」这种组合去匹配比存路径可靠。3.2 构造会话分辨率必须在 Start 之前设好VideoCaptureDevice打开设备后分辨率不是随时能改的。你在Start()之后再赋VideoResolution代码不报错画面还是老的——这是新手最常踩的一个坑。public sealed class CameraSession : IDisposable { private readonly VideoCaptureDevice _device; private long _frameCount; public string Tag { get; } public long FrameCount Interlocked.Read(ref _frameCount); public CameraSession(CameraDescriptor desc) { Tag desc.Tag; _device new VideoCaptureDevice(desc.Moniker); // 构造完就能读到驱动上报的能力集不用先 Start var caps _device.VideoCapabilities; var chosen PickCapability(caps, new Size(1280, 720), minFps: 25); // 关键赋值必须在 Start 之前Start 之后再改不生效也不报错 if (chosen is not null) _device.VideoResolution chosen; _device.NewFrame OnNewFrame; _device.VideoSourceError (_, e) Console.WriteLine($[{Tag}] 采集层报错: {e.Description}); } public void Start() _device.Start(); }VideoCapabilities是驱动上报的「我能给你这些组合」的清单常见是十几到几十项。PickCapability的做法后面第 4 章会给完整实现思路是按目标分辨率算曼哈顿距离、过滤掉帧率不够的、取最接近的一档。VideoSourceError这个事件一定要挂USB 掉线、带宽不足、驱动内部失败,很多情况下不会抛异常只在这里吐一句话不挂它你就只能看到画面卡住不动。3.3 帧回调Clone 不是可选项回调里第一件事永远是拷贝第二件事才是别的。private void OnNewFrame(object? sender, NewFrameEventArgs e) { Bitmap copy; try { // e.Frame 归采集线程所有回调返回后会被下一帧覆盖必须立刻深拷贝 copy (Bitmap)e.Frame.Clone(); } catch (InvalidOperationException) { // 设备正在被释放这一帧丢掉就行不要往上抛 return; } Interlocked.Increment(ref _frameCount); _slot.Put(copy); // 交给帧槽所有权转移 }Clone()是完整的像素级复制一帧 1280×720×3 大约 2.7MB四路 25fps 就是每秒 270MB 的拷贝量。听着吓人但这笔开销省不掉——不复制就得在回调里把图处理完而处理一旦慢了采集线程会被拖住表现是帧率断崖式下跌甚至设备假死。所以正确做法是「快速复制、立刻返回、把重活丢给下游」。try/catch包住Clone是为了应对关流瞬间的竞态设备对象已经释放回调还在路上这时候Clone会抛InvalidOperationException属于预期内的情况吞掉即可让它冒出去会让整个采集线程静默退出。3.4 释放顺序错了设备就被占住关流是这套代码里最容易写错的一段。WaitForStop()是阻塞调用直接在主线程等界面会白屏几秒不等又可能设备还没还回去下次就开不起来。public void Dispose() { // 先摘事件避免释放过程中还有回调进来操作已释放的对象 _device.NewFrame - OnNewFrame; if (_device.IsRunning) { _device.SignalToStop(); // 通知采集线程退出非阻塞 // WaitForStop 内部要等采集线程真正结束放后台等避免卡 UI var waiter Task.Run(() _device.WaitForStop()); if (!waiter.Wait(3000)) { // 3 秒还没停下来说明驱动层卡住了强杀兜底 _device.Stop(); } } _slot.Dispose(); // 清掉还没被取走的最后一帧否则这张 Bitmap 泄漏 }顺序是「摘事件 → 通知 → 等待 → 兜底强杀 → 清理缓冲」。SignalToStop只是打个招呼真正让线程退出的是WaitForStop等待的过程。3 秒这个超时不是随手写的正常摄像头 200~500ms 内就能停干净超过 3 秒基本就是驱动异常或者 USB 掉了再等下去没意义。_slot.Dispose()那行经常被忘忘了的话每次关闭一路就泄漏一张Bitmap开开关关几十次之后进程内存涨到几百兆还查不出在哪泄漏的。4. 参数调优分辨率、帧率与 USB 带宽的三方拉扯4.1 先问摄像头支持什么再谈你要设多少参数不是你想设多少就多少是驱动上报的那几十个组合里挑一个。所以第一步永远是把能力集打出来看一眼。public static void DumpCapabilities(CameraDescriptor desc) { var dev new VideoCaptureDevice(desc.Moniker); foreach (var c in dev.VideoCapabilities) { // FrameSize 是分辨率AverageFrameRate 是驱动给的标称帧率 // BitRate 是这一档的码率估算YUY2 和 MJPEG 差别很大 Console.WriteLine(${desc.Tag} {c.FrameSize.Width}x{c.FrameSize.Height} ${c.AverageFrameRate}fps bitrate{c.BitRate}); } }跑一遍你会看到很有意思的现象同一颗标称 1080p30 的摄像头能力集里可能同时有1920x108030、1280x72060、640x48030还有一堆160x120这种。更关键的是很多摄像头在 1080p 档位只给 MJPEG 格式在 720p 档位才给 YUY2 原始格式。YUY2 每像素 2 字节不做压缩MJPEG 是压缩流、CPU 要解码。四路 1080p MJPEG 的 CPU 开销可能比四路 720p YUY2 还高。选择函数按「距离目标分辨率的偏差」排序同时把帧率不达标的直接淘汰private static VideoCapabilities? PickCapability( VideoCapabilities[] caps, Size want, int minFps) { return caps .Where(c c.AverageFrameRate minFps) // 帧率不达标直接淘汰 .OrderBy(c Math.Abs(c.FrameSize.Width - want.Width) Math.Abs(c.FrameSize.Height - want.Height)) // 先看分辨率接近度 .ThenByDescending(c c.AverageFrameRate) // 同分辨率下帧率高的优先 .FirstOrDefault(); // 全被淘汰就返回 null沿用默认 }OrderBy后面跟ThenByDescending是刻意的先保证分辨率对再在同等分辨率下挑帧率高的。如果反过来你会拿到一个 640×48060 的结果而用户明明要 720p。返回null时不要抛异常让VideoResolution保持默认值继续跑很多便宜摄像头的能力集上报不全硬挑反而开不了流。4.2 路数和分辨率的取舍得按 USB 控制器算USB 2.0 的控制器理论带宽 480Mbps实际可用在 300Mbps 上下一颗 1080p30 的 YUY2 摄像头就要吃掉接近 1000Mbps——这就是为什么很多摄像头在 1080p 下强制走 MJPEG。四颗摄像头如果插在同一个控制器下的四个口通常主板后面板相邻的几个口就是同一个带宽是共享的插满不一定都能开。路数分辨率像素格式单路码率估算CPU 占用参考建议1~2 路1920×108030MJPEG40~80Mbps低单机位录制首选2~4 路1280×72030MJPEG25~50Mbps中多机位最稳的一档4~6 路1280×72025MJPEG20~40Mbps中高需要分散到不同控制器6 路以上640×48030YUY260~80Mbps高只做监看别指望细节表里的码率是量级估算不同传感器差异很大实际以 4.1 打出来的BitRate为准。实操建议是先用 720p 把路数跑满确认稳定后再往上调分辨率别一上来就 1080p 四路然后怀疑代码有问题。另外台式机前面板的 USB 口经常通过一根细长的内部排线接到主板供电和信号质量都差多开时优先用后面板直出的口。4.3 回调线程不能碰控件一个只留最新帧的槽位NewFrame是在采集线程上触发的WinForms 的控件只能在 UI 线程碰。用Invoke硬切不是不行但四路 25fps 就是每秒 100 次跨线程调度界面会明显发涩。我一般用「只保留最新帧」的槽位加一个 30~60ms 的 UI 定时器public sealed class LatestFrameSlot : IDisposable { private readonly object _gate new(); private Bitmap? _frame; public void Put(Bitmap frame) { lock (_gate) { _frame?.Dispose(); // 上一帧还没被取走说明 UI 处理不过来直接淘汰 _frame frame; } } public Bitmap? Take() { lock (_gate) { var f _frame; _frame null; // 所有权交给调用方由它负责释放 return f; } } public void Dispose() { lock (_gate) { _frame?.Dispose(); _frame null; } } }然后是 UI 侧的定时器private void UiTimer_Tick(object? sender, EventArgs e) { foreach (var (tag, slot) in _slots) { var frame slot.Take(); if (frame is null) continue; // 这段时间没有新帧保持上一张画面 var box _views[tag]; var old box.Image; box.Image frame; // PictureBox 只存引用不做拷贝 old?.Dispose(); // 换下来的旧帧必须自己释放 } }old?.Dispose()这一行是血泪经验。PictureBox.Image赋新值时不会帮你释放旧图漏了这一句GDI 句柄会一路涨跑到几千个之后整个进程开始报「参数无效」界面上的画面变成一片红叉。任务管理器里看不出来得用 GDI 对象计数器才查得到。用槽位淘汰而不是队列排队是因为监看场景下延迟比完整性重要——积压三帧再慢慢显示画面比实时慢半拍反而更难用。要录像的话另开一条落盘路径别跟预览抢同一份帧。5. 避坑与排查多开场景里五个翻车现场的现象、原因和补救5.1 第二路黑屏事件不触发也不报错现象第一路正常出图第二路Start()之后NewFrame一次都不触发IsRunning却是true日志里干干净净。原因大概率是两颗摄像头插在同一个 USB 控制器下第一路把带宽吃满了第二路在 DirectShow 层协商失败但 AForge 把这层失败吞掉了只在VideoSourceError里吐一句。没挂那个事件就什么都看不到。解决先挂VideoSourceError把e.Description打出来八成能看到VFW_E_NOT_ENOUGH_BANDWIDTH之类的字样。然后降分辨率——1080p 改 720p或者把其中一颗挪到另一个控制器下的口。验证办法是把第二路单独开拔掉第一颗如果能出图就基本确定是带宽问题而不是代码问题。5.2 关窗口之后进程不退设备一直占着现象点关闭界面消失了任务管理器里进程还在。再启动程序报设备被占用必须去任务管理器手动结束。原因Dispose里只调了SignalToStop()没等采集线程真正结束就返回了。进程退出时还有非后台线程活着CLR 就不会结束进程。解决按第 3.4 节的顺序补齐SignalToStop → 后台 WaitForStop(3s) → 兜底 Stop。另外在窗体的FormClosing里遍历所有会话逐个Dispose并且把e.Cancel用起来等释放完再真正关闭。别指望ProcessExit事件那时候已经来不及了。5.3 帧回调里抛异常程序静默卡死现象界面还在画面停了没有任何报错弹窗。日志里也没有记录。原因NewFrame是在采集线程上触发的这个线程没有异常处理边界。回调里任何未捕获的异常都会让采集线程直接退出而 AForge 不会把异常往上抛IsRunning甚至可能还是true。解决回调体整体包一层try/catch捕获后写日志并计数连续失败超过阈值再主动SignalToStop并给界面一个提示。不要在回调里做任何可能抛的事情——数据库写入、文件 IO、Invoke到已关闭的窗体这些都挪到下游线程去做。5.4 分辨率设了 1080p出来的还是 640×480现象代码里明明赋了VideoResolution界面上显示的尺寸没变也没有任何报错。原因两种可能。一是赋值放在了Start()之后AForge 不报错但也不生效。二是你赋的那个VideoCapabilities对象不是从这颗设备的VideoCapabilities数组里拿的——比如从别的设备拷过来或者手动new了一个驱动不认。解决赋值必须在Start之前VideoResolution只能取自该设备自己的VideoCapabilities数组元素不能构造。加一行日志把赋值前后的VideoResolution.FrameSize都打出来一眼就能看出有没有生效。5.5 拔掉再插回去程序再也不认这颗摄像头现象运行中拔掉一路画面停住这是预期的插回去之后重新枚举列表里少了一颗或者Moniker变了。原因一是 USB 掉线后驱动重新注册设备路径变了二是原来的会话对象没有完全释放DirectShow 层还认为这个设备被占用重新枚举时被跳过。解决监听设备变化FilterInfoCollection需要重新构造才有新数据不要缓存枚举前先确保所有已断开会话都走完完整的释放流程。更稳的做法是给每一路加一个「重连」按钮点击时先Dispose旧会话等 500ms再重新枚举匹配同名设备新建会话。别做成自动重连USB 抖动时会把界面刷成幻灯片。6. 长跑验证四路 720p 连续跑满 72 小时要盯的三个指标能出图和能交付是两件事。多开工具真正的验收标准是「连续跑三天不崩、不涨内存、不掉帧」。我一般盯三个指标都是代码里几行计数器就能拿到的。第一是每路的实际帧率。别信AverageFrameRate那个标称值自己数会话里已经有一个FrameCountUI 定时器每秒取一次差值就是实际 fps。掉到标称值的 70% 以下说明下游处理拖慢了采集或者带宽不够。第二是各路径的丢弃帧数。LatestFrameSlot.Put里每次淘汰旧帧就Interlocked.Increment一个计数。预览场景丢帧是正常的但比例长期超过 30% 就意味着你在看「幻灯片」而不是视频流得降分辨率或者减路数。第三是进程的 GDI 对象数和私有内存。跑之前记一次跑 8 小时再记一次如果 GDI 对象数持续增长几乎可以肯定是某个Bitmap没释放——最常见的就是 4.3 节里PictureBox.Image换图时漏了Dispose。// 每路一个定时打印跑长测时直接看趋势 public readonly record struct SessionStats( string Tag, long Frames, long Dropped, double ActualFps) { public override string ToString() ${Tag} fps{ActualFps:F1} frames{Frames} dropped{Dropped} $dropRate{(Frames Dropped 0 ? 0 : (double)Dropped / (Frames Dropped)):P1}; }dropRate这个比例比绝对值有用得多四路各跑各的绝对丢帧数受路数影响比例才能横向比较。另外建议把每路的日志单独写文件用Tag当前缀出问题时能直接定位到「哪一路、什么时候、什么参数」——混在一个日志文件里四路交错输出基本没法看。一个提醒长测不要在开发机上跑。开发机的 USB 口上还插着调试器、加密狗、移动硬盘带宽和供电都不干净跑出来的结论不可信。找一台干净的机器四颗摄像头按不同控制器分散插好锁定分辨率跑满 72 小时再看那三个指标。从那以后我每次交付多路采集的东西都会先强制走一遍 72 小时长测哪怕客户催得再急——出图只要十分钟能不能扛住三天只有跑过才知道。希望帮到你。本文还有配套的精品资源点击获取