☰
C#多路USB摄像头同时预览:Afroge多设备采集与避坑指南
2026/10/17 11:03:06 网站建设 项目流程

简介:这是一份基于C#与AForge组件开发的USB摄像头多开工具源码包,面向需要在Windows平台实现多路视频采集的开发者,适用于视频会议、监控系统、直播录制等需多摄像头同步操作的场景。压缩包共56个文件,约7.35MB,包含6个cs源文件、1个sln解决方案与1个csproj工程文件,以及AForge.dll、AForge.Video.DirectShow.dll等15个依赖库,另有exe可执行程序、resx资源、pdb调试符号与manifest清单等,工程结构完整,可直接编译运行。工具支持同时打开多个USB摄像头、自由选择各摄像头输出分辨率,并通过布局管理实现9画面均匀分布与同步播放,涉及多线程视频流处理、UI设计与硬件兼容性等要点。目前已有1538人学习下载,适合希望研究C#多媒体采集、DirectShow设备交互与多画面拼接的开发者参考借鉴。

1. 多路 USB 摄像头同时预览:这个 C# 工具到底解决了谁的刚需

做过视觉采集的同行大概率都遇到过这种场景:手头有四五个 USB 摄像头,想在同一台机器上同时打开预览,结果要么画面卡成幻灯片,要么干脆第二个设备就报错打不开。市面上不少 Demo 只支持单路,稍微改一改就翻车,底层 DirectShow 的枚举逻辑、跨线程回调、句柄释放,每一处都是坑。这份 C# USB 摄像头多开工具,核心就是用 C# 封装多路 USB 摄像头的枚举、打开、预览与释放流程,基于 Afroge 这类 .NET 视频采集组件做设备管理,让多路画面能稳定跑在同一进程里。它适合做机器视觉预研、多角度监控原型、教学演示采集的开发者,尤其是那些不想从零啃 DirectShow 互操作、又想快速验证多路方案的人。你拿到手能直接跑起来看效果,也能顺着源码改分辨率、帧率和设备索引。

2. 多路采集的底层逻辑:设备枚举、句柄分配与线程模型

2.1 为什么单路能跑、多路就崩

很多人第一次做多路采集,思路很朴素:把单路 Demo 的代码复制几份,每份 new 一个采集对象,各自绑定一个 PictureBox。跑起来才发现,第一路正常,第二路要么黑屏,要么直接抛异常。根子在于 USB 带宽和驱动层的设备独占机制。USB 2.0 总线的理论带宽是 480 Mbps,但实际可用往往只有一半左右,一路 1080p@30fps 的未压缩 YUV 数据就能吃掉接近 1 Gbps,早就超了。所以多路同时跑,必须靠 MJPG 这类压缩格式把单路带宽压下来,否则物理层就不答应。

另一个隐性问题是设备句柄。DirectShow 体系里,每个采集设备对应一个 filter graph,graph 里的 Source Filter 会独占设备。如果你在同一个进程里反复创建、销毁 graph,而释放顺序不对,驱动层可能还挂着上一次的句柄,第二次打开就返回“设备已被占用”。这不是代码写错了,是资源回收没做干净。常见做法是每路采集独立一个线程,线程内完成 graph 的构建、运行和销毁,主线程只负责 UI 更新,避免跨线程操作 COM 对象引发玄学崩溃。

2.2 Afroge 组件在多路场景下的角色

Afroge 是一套 .NET 下的视频采集封装库,它把 DirectShow 那套又臭又长的 COM 接口调用包成了相对友好的 C# 类。你不需要自己写ICaptureGraphBuilder2的互操作声明,也不用管IAMStreamConfig怎么设分辨率,它提供了FilterInfoCollection做设备枚举、VideoCaptureDevice做单设备控制。多路场景下,关键点在于:每个VideoCaptureDevice实例必须对应一个真实的物理设备,不能两个实例绑同一个索引。枚举的时候用MonikerString做唯一标识,比用设备名靠谱,因为同名摄像头插两个是常有的事。

选 Afroge 而不是自己从零封装,理由很实际:它的NewFrame事件已经帮你处理了帧回调的线程调度,你只需要在事件里把 Bitmap 丢给 UI 就行。如果自己写,光是ISampleGrabber的回调缓冲管理就够喝一壶。但要注意,Afroge 的帧事件默认在采集线程触发,直接更新 WinForms 控件会抛跨线程异常,必须用Invoke或BeginInvoke转回 UI 线程。这个点在单路 Demo 里经常被忽略,因为单路时偶尔不崩,多路一上来就原形毕露。

2.3 多路初始化的代码骨架

下面这段代码演示了枚举设备、按索引分配多路采集对象的基本流程。注意每路用独立的VideoCaptureDevice,并且把MonikerString存下来做标识。

using AForge.Video; using AForge.Video.DirectShow; using System.Drawing; using System.Windows.Forms; public class MultiCameraManager { // 保存所有可用设备的 MonikerString,作为唯一标识 private FilterInfoCollection _devices; // 每路采集对象独立存放,key 用设备 MonikerString private Dictionary<string, VideoCaptureDevice> _activeCameras = new Dictionary<string, VideoCaptureDevice>(); // 枚举当前机器上所有 USB 视频设备 public void EnumerateDevices() { // 第二个参数指定只枚举视频输入设备,避免混入音频设备 _devices = new FilterInfoCollection(FilterCategory.VideoInputDevice); if (_devices.Count == 0) { MessageBox.Show("未检测到任何 USB 摄像头,请检查驱动和连接"); return; } // 打印设备清单,方便按索引分配 for (int i = 0; i < _devices.Count; i++) { Console.WriteLine($"索引 {i}: {_devices[i].Name} | Moniker: {_devices[i].MonikerString}"); } } // 按设备索引启动一路采集,frameCallback 由调用方传入用于刷新 UI public void StartCamera(int deviceIndex, Action<Bitmap> frameCallback) { if (deviceIndex < 0 || deviceIndex >= _devices.Count) throw new ArgumentOutOfRangeException(nameof(deviceIndex)); string moniker = _devices[deviceIndex].MonikerString; if (_activeCameras.ContainsKey(moniker)) return; // 该设备已在采集中,避免重复打开 var camera = new VideoCaptureDevice(moniker); // 尝试设置 MJPG 格式降低 USB 带宽占用,多路场景必须做 camera.VideoResolution = camera.VideoCapabilities .FirstOrDefault(c => c.FrameSize.Width == 640 && c.FrameSize.Height == 480) ?? camera.VideoCapabilities[0]; camera.NewFrame += (sender, eventArgs) => { // 克隆一份 Bitmap,避免采集线程复用缓冲区导致画面撕裂 Bitmap frame = (Bitmap)eventArgs.Frame.Clone(); frameCallback?.Invoke(frame); }; camera.Start(); _activeCameras[moniker] = camera; } // 停止并释放指定设备,顺序不能乱 public void StopCamera(int deviceIndex) { string moniker = _devices[deviceIndex].MonikerString; if (_activeCameras.TryGetValue(moniker, out var camera)) { camera.SignalToStop(); camera.WaitForStop(); // 等待采集线程真正退出再移除引用 camera.NewFrame -= null; // 实际项目中应保存委托引用再解绑 _activeCameras.Remove(moniker); } } }

逻辑说明:EnumerateDevices用FilterCategory.VideoInputDevice过滤掉音频设备,避免枚举出麦克风之类的干扰项。StartCamera里先检查_activeCameras是否已存在同一 Moniker,防止重复打开同一物理设备导致句柄冲突。帧回调里做Clone()是关键,Afroge 的Frame对象在事件返回后可能被复用,不克隆直接丢给 UI 会看到画面撕裂或花屏。StopCamera里SignalToStop加WaitForStop是标准释放流程,少了WaitForStop,采集线程可能还在跑,设备句柄没释放,下次打开就报占用。

参数说明:VideoCapabilities列表里挑 640x480 是权衡后的选择,多路场景下分辨率越高,USB 带宽和 CPU 解码压力越大。如果设备支持 MJPG,Afroge 会自动协商压缩格式,但前提是VideoCapabilities里确实有对应条目。有些廉价摄像头只暴露 YUY2,那就只能降分辨率或降帧率来凑。WaitForStop默认超时是 5 秒,如果设备卡死,这里会阻塞 UI,实际项目里建议放到后台线程调用。

3. 从零跑通多路预览:工程配置、分辨率协商与 UI 绑定

3.1 工程引用与目标框架选择

拿到源码包后,第一步是确认目标框架。Afroge 对 .NET Framework 的支持比较成熟,建议用 .NET Framework 4.7.2 或 4.8 建 WinForms 项目,不要一上来就上 .NET 6+,因为 Afroge 的 NuGet 包在 .NET Core 体系下对 DirectShow 的兼容性有差异,容易出现枚举不到设备的情况。新建项目后,通过 NuGet 安装 Afroge 包,或者直接引用源码包里附带的 DLL。如果源码包里有packages文件夹,说明用的是旧式引用方式,检查HintPath是否指向正确位置。

平台目标必须设为 x86 或 x64,不能是 AnyCPU。DirectShow 的 COM 组件是位数敏感的,AnyCPU 在 64 位系统上默认跑 64 位进程,如果摄像头驱动只注册了 32 位 filter,就会枚举不到设备。我一般直接锁 x64,除非确认驱动只有 32 位版本。这个点在单路 Demo 里不明显,因为很多人本机恰好能跑,换台机器就翻车。

3.2 分辨率与帧率的协商策略

多路场景下,分辨率协商不是越高越好。假设你有 4 路摄像头,每路 640x480@30fps 的 MJPG 流,USB 控制器带宽勉强够用;如果每路都上 1280x720,大概率第三路就开始丢帧。协商逻辑应该按优先级降级:先试 640x480 MJPG,不行退 640x480 YUY2,再不行退 320x240。下面这段代码展示了如何按优先级挑选能力项。

// 按优先级挑选最合适的分辨率,优先 MJPG 压缩格式 private VideoCapabilities PickBestCapability(VideoCaptureDevice camera) { var caps = camera.VideoCapabilities; // 优先级从高到低:640x480 MJPG > 640x480 任意 > 320x240 任意 var preferred = caps.FirstOrDefault(c => c.FrameSize.Width == 640 && c.FrameSize.Height == 480 && c.BitCount <= 24); // MJPG 通常位深较低,用位深做粗略筛选 if (preferred != null) return preferred; var fallback = caps.FirstOrDefault(c => c.FrameSize.Width == 640 && c.FrameSize.Height == 480); if (fallback != null) return fallback; // 最后兜底:选面积最小的,保证能出画面 return caps.OrderBy(c => c.FrameSize.Width * c.FrameSize.Height).First(); }

逻辑说明:BitCount用来粗略区分压缩格式,MJPG 的位深通常比 YUY2 低,但这不是绝对可靠的判断依据,更稳妥的方式是看c.Format或直接尝试设置后检查实际生效格式。OrderBy兜底选最小面积,是为了保证多路都能出画面,哪怕画质差一点,也比某一路直接黑屏强。实际项目里我会把协商结果打印到日志,方便排查“为什么这路只有 320x240”。

参数说明:FrameSize是结构体,包含 Width 和 Height。BitCount是每像素位数,24 位通常对应 RGB 或 MJPG 解码后的格式,16 位可能是 YUY2。不要死记这些对应关系,不同驱动上报的值可能不一致,以实际运行结果为准。

3.3 UI 绑定与跨线程刷新

WinForms 的 PictureBox 不是线程安全的,Afroge 的NewFrame在采集线程触发,直接赋值pictureBox.Image会抛InvalidOperationException。正确做法是用BeginInvoke把赋值操作转回 UI 线程。下面是一个典型的多路 UI 刷新封装。

// 每路摄像头对应一个 PictureBox,用字典管理映射关系 private Dictionary<string, PictureBox> _cameraToBox = new Dictionary<string, PictureBox>(); private void OnFrameArrived(string moniker, Bitmap frame) { if (!_cameraToBox.TryGetValue(moniker, out var box)) return; // 控件未创建句柄或已释放时直接跳过,避免崩溃 if (box.IsDisposed || !box.IsHandleCreated) return; box.BeginInvoke(new Action(() => { // 释放上一帧,防止 GDI 对象泄漏 var old = box.Image; box.Image = frame; old?.Dispose(); })); }

逻辑说明:BeginInvoke是异步的,不会阻塞采集线程,适合高频帧回调。IsDisposed和IsHandleCreated双重检查是血泪经验,窗口关闭瞬间采集线程可能还在推帧,不检查直接崩。old?.Dispose()必须做,否则每秒 30 帧的 Bitmap 对象堆积起来,几分钟就能吃掉几个 G 内存,任务管理器里看到内存曲线一路飙升。

参数说明:moniker作为字典 key,保证每路画面绑定到正确的 PictureBox。如果 UI 上有多个 PictureBox,初始化时按设备索引建立映射,不要用设备名做 key,同名设备会冲突。

4. 多路采集避坑清单:从设备占用到内存泄漏的五个真实翻车点

4.1 现象:第二路打开报“设备已被占用”,第一路正常

原因:第一路采集对象没有正确释放,或者释放时没有等待采集线程退出。DirectShow 的 graph 销毁是异步的,SignalToStop只是发信号,WaitForStop才是真正等线程结束。很多人只调了前者,设备句柄还挂在驱动层,第二路自然打不开。

解决:每次停止采集必须成对调用SignalToStop和WaitForStop,并且把VideoCaptureDevice引用置空,让 GC 能回收。如果是在窗体关闭时释放,把释放逻辑放到FormClosing事件里,并且加超时保护,避免某个设备卡死导致整个程序退不出去。

4.2 现象:多路跑几分钟后内存暴涨,程序卡死

原因:帧回调里创建的 Bitmap 没有释放。Afroge 的NewFrame事件参数里的Frame对象,如果你直接赋给 PictureBox 而不克隆,事件返回后对象可能被复用;如果你克隆了但没释放旧帧,Bitmap 对象就会堆积。GDI 对象有数量上限,单进程默认一万个左右,超了就卡死。

解决:每次赋值新帧前,先Dispose旧帧。如果 UI 刷新频率跟不上采集频率,可以在回调里做丢帧处理,比如用一个标志位判断上一帧是否还在处理,是就跳过当前帧。不要试图在采集线程里直接操作 UI,跨线程Invoke本身也有开销,高频调用会拖慢采集。

4.3 现象:四路摄像头只能出三路,第四路黑屏

原因:USB 带宽不够。四路 640x480 YUY2 未压缩流,每路约 147 Mbps,四路加起来接近 600 Mbps,超过 USB 2.0 控制器的实际可用带宽。如果摄像头不支持 MJPG,或者 Afroge 协商时没选到压缩格式,就会带宽溢出。

解决:强制协商 MJPG 格式,如果设备不支持,降分辨率到 320x240,或者把帧率从 30fps 降到 15fps。另一个思路是把摄像头分散到不同的 USB 控制器上,主板后置的 USB 口通常分属不同控制器,前置面板的口往往共享一个。用 USBTreeView 这类工具能看到设备挂在哪个控制器下。

4.4 现象:画面颜色不对,偏绿或偏紫

原因:色彩空间转换问题。有些摄像头输出 YUY2 格式,Afroge 在转 Bitmap 时如果像素格式匹配不对,就会偏色。这不是代码逻辑错误,是驱动上报的格式和实际输出不一致。

解决:在StartCamera里显式设置camera.VideoResolution后,检查camera.VideoResolution的实际值是否和设置一致。如果不一致,说明驱动没接受你选的格式,换一个能力项再试。偏色严重时,可以在 UI 层做简单的通道补偿,但治本还是选对格式。

4.5 现象:程序退出后摄像头指示灯还亮着

原因:采集线程没有随进程退出而终止,设备句柄被残留线程持有。常见于在子线程里启动采集,但退出时只关了主窗体,没通知子线程停止。

解决:在FormClosing里遍历所有活跃采集对象,逐个SignalToStop加WaitForStop。如果某个设备卡住,设置一个总超时,比如 3 秒,超时后强制Environment.Exit。另外,不要在Dispose里做耗时操作,WinForms 的Dispose调用时机不确定,容易和采集线程死锁。

5. 进阶:动态增删设备与帧率自适应的小技巧

多路采集跑通之后,下一步往往是动态增删。比如运行中拔掉一个摄像头,程序不能崩,要能检测到设备移除并清理对应资源。Afroge 本身不直接提供设备热插拔事件,常见做法是起一个后台定时器,每隔两秒重新枚举一次FilterInfoCollection,对比 MonikerString 列表,发现少了就停止对应采集,多了就刷新 UI 上的可选设备列表。这个轮询间隔别设太短,枚举设备本身有开销,两秒是个折中值。

帧率自适应是另一个实用技巧。多路场景下,如果某一路因为带宽不足开始丢帧,与其让它拖累整体,不如主动降帧。可以在帧回调里记录时间戳,计算实际帧率,如果连续三秒低于目标帧率的 70%,就触发降级逻辑:先尝试降分辨率,不行再降帧率。下面是一个简单的帧率统计片段。

// 每路维护独立的帧率统计器 private Dictionary<string, Queue<DateTime>> _frameTimestamps = new Dictionary<string, Queue<DateTime>>(); private void TrackFrameRate(string moniker) { if (!_frameTimestamps.ContainsKey(moniker)) _frameTimestamps[moniker] = new Queue<DateTime>(); var queue = _frameTimestamps[moniker]; queue.Enqueue(DateTime.Now); // 只保留最近两秒的时间戳,避免队列无限增长 while (queue.Count > 0 && (DateTime.Now - queue.Peek()).TotalSeconds > 2) queue.Dequeue(); double fps = queue.Count / 2.0; if (fps < 15) // 低于 15fps 认为需要降级 { // 实际项目中在这里触发降分辨率或降帧率逻辑 Console.WriteLine($"{moniker} 当前帧率 {fps:F1},建议降级"); } }

逻辑说明:用Queue存时间戳,每次回调入队,然后踢掉两秒前的旧数据,剩下的数量除以 2 就是近似帧率。这个统计不精确,但足够触发降级判断。fps < 15这个阈值按实际需求调,如果目标就是 30fps,可以设成 20。降级逻辑不要直接在帧回调里执行,因为改分辨率需要停止再启动采集,放到后台任务里做,避免阻塞采集线程。

参数说明:TotalSeconds > 2里的 2 是统计窗口,窗口越大帧率越平滑但反应越慢,窗口越小反应快但抖动大。我一般用 2 秒,兼顾两者。queue.Count / 2.0里的 2.0 对应窗口秒数,如果改成 3 秒窗口,这里也要改成 3.0。

还有一个容易忽略的点:设备名称。多路场景下,UI 上显示“USB Camera”这种通用名,用户根本分不清哪路是哪路。可以在枚举时把 MonikerString 的后几位截出来做后缀,或者让用户手动给每路起别名,存到配置文件里。下次启动按 MonikerString 匹配,别名就能恢复。这个功能不复杂,但能省掉大量“哪路是哪个”的沟通成本。

从那以后我每次做多路采集,都强制先跑一遍带宽估算:把计划的分辨率、帧率、格式列出来,算一下总带宽有没有超过控制器上限,再动手写代码。这个习惯帮我省掉了无数次“为什么第四路黑屏”的排查时间。希望帮到你。

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

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

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

立即咨询