简介:这是一份面向C#桌面开发与多媒体处理学习者的USB摄像头多开示例工程,基于AForge.NET组件实现多路USB摄像头的同时开启、分辨率选择与九画面均匀排布,可用于视频会议、监控预览、直播录制等需要多摄像头同步的场景。压缩包共56个文件,约7.35MB,包含sln与csproj解决方案及项目文件、Form1等cs窗体源码、AForge系列dll依赖库、exe可执行程序与pdb调试符号,另有resx界面资源、settings配置和cache缓存等编译中间文件,可对照源码理解DirectShow设备枚举、多线程视频流处理与WinForms界面布局。该资源已有1538人学习下载,适合作为多摄像头采集工具的入门参考与二次开发起点,读者可从中获取设备驱动调用、画面拼接与窗口动态调整的实现思路,并借鉴其错误处理与兼容性设计,快速搭建自己的多路预览程序。
1. 四路 USB 摄像头一起开:C# 多开工具卡在哪一步
一个工位上插四支同型号 USB 摄像头,程序一跑,第一路正常,第二路黑屏,第三路直接抛异常——这是 C# USB摄像头多开 最常见的开局。它要解决的不是“多开几个窗口”,而是同一台机器上多个 DirectShow 采集源并存时的设备枚举、带宽分配、线程调度与句柄释放四件事。适合做质检工位多角度录制、实验台同步采集、多机位预览的人;只要单路预览的,用不上这套东西。
2. 设备枚举与选型:为什么同型号四支相机不能靠“名字”区分
拿到“多开”这个需求,很多人的第一反应是写一个循环,把设备列表里的每一项都 new 一个采集对象出来。跑起来才发现,四支同型号相机在系统里报的友好名一模一样,全是USB Camera,配置存下去之后谁也认不出谁。这一步没打牢,后面分辨率、帧率、重连全是糊涂账。
2.1 FilterInfoCollection 里真正该拿的是 MonikerString
AForge 这套库对 DirectShow 的封装里,设备枚举入口是FilterInfoCollection,配合FilterCategory.VideoInputDevice。集合里每一项是FilterInfo,它有两个字段值得分清:Name是驱动上报的友好名,给人看的;MonikerString是设备实例的 moniker,给程序看的,包含设备路径信息。四支同型号相机Name完全一致,MonikerString不同,所以持久化配置必须以 moniker 为主键。
using AForge.Video.DirectShow; // VideoInputDevice 只列视频输入设备,别和 AudioInputDevice 混用 var devices = new FilterInfoCollection(FilterCategory.VideoInputDevice); for (int i = 0; i < devices.Count; i++) { // Name:驱动友好名,同型号多支会重复,不能当唯一键 // MonikerString:设备实例标识,做配置持久化和去重用它 Console.WriteLine($"[{i}] {devices[i].Name}"); Console.WriteLine($" {devices[i].MonikerString}"); }这段代码解决的是“我到底看见了几支相机、它们分别是谁”。参数上没什么可调的,但使用顺序有讲究:枚举只在程序启动、或者收到设备变更消息之后做,不要在每次开流前都枚举一遍。DirectShow 枚举本身要创建 COM 对象,在 4 路以上、反复开关窗的场景里,频繁枚举会明显拖慢界面响应,也会让系统里残留一批未释放的 moniker 引用。
一个必须接受的现实是:MonikerString不是永久不变的。用户把相机从机箱前面板挪到后面板、换了一根延长线、或者中间加了个不带外接电源的集线器,设备路径就可能变一个字符。所以配置里最好同时存一份Name加“同型号中的序号”作为兜底,moniker 匹配失败时走兜底重绑,而不是直接报“设备未找到”让用户重新配一遍。
2.2 三条技术路线:AForge、OpenCvSharp、MediaCapture 怎么选
C# 做多路 USB 采集,能走的路就那么几条,选型不看谁更“现代”,看的是你能接受哪种失败方式。
| 路线 | 上手成本 | 多开表现 | 分辨率/像素格式控制 | 适合场景 |
|---|---|---|---|---|
| AForge.Video.DirectShow | 低,纯托管封装,事件式出帧 | 稳定,各路独立线程 | 分辨率走VideoCapabilities,MJPG 要自己碰IAMStreamConfig | 工位多开、长时间常驻、WinForms/WPF 桌面端 |
| OpenCvSharp + DSHOW 后端 | 中,API 简洁 | 稳定,但设备用索引 0..n 表示 | Set(FourCC)一行搞定 | 需要顺带做图像处理、算法验证 |
| Windows.Media.Capture | 高,要引 WinRT 组件 | 受共享模式与生命周期约束,多开较别扭 | 通过 MediaEncodingProfile 配置 | 面向现代应用框架的新项目 |
我一般会给这类桌面多开工具选 AForge:它出帧是事件回调,天然按设备分线程,四路互不阻塞;缺点是像素格式得自己下探到 DirectShow 接口层。OpenCvSharp 的优势是格式设置极其省事,缺点是设备索引用整数,你没法保证索引 0 永远是“工位左前”那支,重插一次顺序就可能变,做长期运行的工位程序要额外做一层索引到 moniker 的映射。MediaCapture 那一套更现代,但在需要同时开四路的场景里,生命周期管理会让人怀疑人生。
提示:输入里给的关键词写作 Afroge,实际对应的就是 AForge 这套 .NET 视觉库,NuGet 上搜的时候别按错拼写。
2.3 最小可跑的多开骨架
选型定下来,先写一个能跑通的单路会话对象,再把它放进循环里。关键点是:一个会话对应一个VideoCaptureDevice,事件里立刻克隆帧,停止时按固定顺序收尾。
public sealed class CameraSession : IDisposable { private readonly VideoCaptureDevice _device; public event Action<Bitmap> FrameArrived; public CameraSession(string monikerString) { _device = new VideoCaptureDevice(monikerString); _device.NewFrame += OnNewFrame; } private void OnNewFrame(object sender, NewFrameEventArgs e) { // e.Frame 是库内部复用的位图,回调返回后随时会被下一帧覆盖 // 不 Clone 直接往 UI 塞,典型症状是花屏、撕裂、随机 GDI+ 崩溃 var copy = (Bitmap)e.Frame.Clone(); FrameArrived?.Invoke(copy); } public void Start() { _device.Start(); // 内部自建采集线程,不占用调用方线程 } public void StopSafe(int timeoutMs = 3000) { _device.NewFrame -= OnNewFrame; // 先解绑,避免停止过程中还往队列里灌帧 if (!_device.IsRunning) return; _device.SignalToStop(); // 请求采集线程退出 // WaitForStop 没有超时重载,驱动不配合时会一直卡住,后台等 + 超时兜底 bool finished = Task.Run(() => _device.WaitForStop()).Wait(timeoutMs); if (!finished) _device.Stop(); // 强制停,之后必须重新枚举设备才能再开 } public void Dispose() => StopSafe(); }Start()之前还可以做两件事:从_device.VideoCapabilities里挑一个匹配宽高的能力项赋给VideoResolution,以及设DesiredFrameRate。注意DesiredFrameRate这个属性在不少 UVC 驱动上会被直接忽略,设了 30 实际跑 15 的情况很常见,别把它当硬约束用。四路循环创建时,每创建一路就Start()一路,中间不要用Thread.Sleep硬等,AForge 的启动是异步的,等也等不来帧;要判断是否真的出帧,用计数器在NewFrame里累加,两秒后看谁还是 0。
3. 分辨率与像素格式:把 YUY2 和 MJPG 的带宽账算清楚
四路能不能开成,八成取决于带宽,而不是 CPU 或代码写得好不好。USB 2.0 的理论带宽标称 480 Mbps,实际可用大约只有 280~320 Mbps,折合 35~40 MB/s;而且同一个根集线器下的所有端口是共享这条总线的。很多人插了四支相机在机箱后面板四个口上,以为占了四个通道,其实那四个口经常挂在同一个控制器下面。
3.1 一张表看清为什么 1080p 四开必挂
未压缩的 YUY2 每像素 2 字节,这个数字乘上分辨率和帧率就是硬带宽,压不下去。
| 分辨率 | 像素格式 | 帧率 | 单帧大小 | 单路码率 | 四路合计 |
|---|---|---|---|---|---|
| 640×480 | YUY2 | 30 | 约 0.55 MB | 约 132 Mbps(16.5 MB/s) | 约 66 MB/s |
| 1280×720 | YUY2 | 30 | 约 1.84 MB | 约 442 Mbps | 约 220 MB/s |
| 1920×1080 | YUY2 | 30 | 约 3.96 MB | 约 995 Mbps | 约 500 MB/s |
| 1920×1080 | MJPG | 30 | 压缩帧,大小浮动 | 约 20~40 Mbps | 约 10~20 MB/s |
看第二行就够了:单路 720p 的 YUY2 已经超过 USB 2.0 的实际可用带宽,四路根本不用讨论。所以多开工具的第一条工程约束是——超过两路,就必须把像素格式压到 MJPG(或相机自带的其他压缩格式),分辨率控制在 720p 以内;要 1080p 多开,那就得确保相机和端口都在 USB 3.0 控制器上,并且分散到不同控制器。
3.2 用 IAMStreamConfig 指定 MJPG,别指望分辨率属性帮你选
AForge 的VideoCapabilities只暴露宽高和帧率,不暴露像素格式。也就是说你把VideoResolution设成 1920×1080,驱动很可能给你协商成 YUY2,然后带宽直接爆掉,表现是绿屏、卡在第一帧、或者干脆Start()之后没反应。要指定 MJPG,得拿到底层IBaseFilter,找到输出 pin,QueryInterface 出IAMStreamConfig,再遍历能力集挑 subtype 为 MJPG 的那一项。
using AForge.Video.DirectShow.Internals; // IAMStreamConfig、VideoInfoHeader 在这里 using System.Runtime.InteropServices; private static bool ForceMjpg(IBaseFilter filter, int width, int height) { // 采集设备的视频输出 pin 通常只有一个,取第一个 Output 方向即可 var config = GetStreamConfig(filter); if (config == null) return false; config.GetNumberOfCapabilities(out int count, out int capSize); IntPtr caps = Marshal.AllocCoTaskMem(capSize); try { for (int i = 0; i < count; i++) { config.GetStreamCaps(i, out VideoStreamConfigCaps _, caps); var header = (VideoInfoHeader)Marshal.PtrToStructure(caps, typeof(VideoInfoHeader)); bool sizeOk = header.BmiHeader.Width == width && header.BmiHeader.Height == height; // 'MJPG' 四字符码按小端读进来就是 0x47504A4D bool mjpgOk = header.BmiHeader.Compression == 0x47504A4D; if (!sizeOk || !mjpgOk) continue; config.SetFormat(caps); // 必须在 Start() 之前调用,Start 之后再设无效 return true; } } finally { Marshal.FreeCoTaskMem(caps); } return false; }两个参数值得说清楚:GetNumberOfCapabilities返回的是“能力项个数 + 每项结构体字节数”,后者必须用来分配非托管内存,不能拍脑袋写Marshal.SizeOf(typeof(VideoInfoHeader)),两者不一定相等;header.BmiHeader.Compression是 FourCC 码,0表示未压缩(YUY2/NV12 之类),非 0 才是压缩格式,判断压缩格式只能靠这个字段,BitCount看不出来。
如果不想碰 interop,用 OpenCvSharp 走 DSHOW 后端也能达到同样效果,而且顺序极其关键:
using OpenCvSharp; var cap = new VideoCapture(0, VideoCaptureAPIs.DSHOW); // 必须先设 FOURCC 再设分辨率:反过来的话驱动已经按 YUY2 协商完,后面改不动 cap.Set(VideoCaptureProperties.FourCC, FourCC.FromString("MJPG")); cap.Set(VideoCaptureProperties.FrameWidth, 1920); cap.Set(VideoCaptureProperties.FrameHeight, 1080); cap.Set(VideoCaptureProperties.Fps, 30);这个顺序颠倒是我踩过最久的坑之一:先设宽高,相机老老实实按未压缩格式协商,然后你在设 FourCC 时接口返回true,看着像成功了,实际格式压根没变,四路一开就全绿。
3.3 帧率设了不生效的三种情况
第一种是自动曝光。低照度下相机为了保亮度会自动拉长曝光时间,帧率从 30 掉到 15 甚至 10,代码里一点错没有,就是画面变慢。这类工位场景要么补光,要么在相机属性页里关掉自动曝光并手动固定。
第二种是重新协商覆盖。通过 AForge 的VideoResolution换分辨率时,库内部会重新走一遍格式协商,可能把你刚设好的 MJPG 打回 YUY2。稳妥做法是分辨率也只通过IAMStreamConfig一次设完,不要在中间再动VideoResolution。
第三种最容易被误判成“代码性能问题”:多开时帧率下降、CPU 却不高。这往往是总线带宽或控制器中断合并导致的,表现为设备端照常出帧、主机端周期性丢一批。判断方法是单开一路看帧率是否正常,正常就说明是带宽问题,回去降分辨率或换压缩格式,别急着优化渲染代码。
4. 出帧线程与帧队列:多开卡死、花屏、句柄暴涨的真实来源
四路都能出帧之后,接下来是把它接到界面上。这一步出的问题最像玄学:单路怎么跑都没事,四路一起开,界面过十几秒就僵住,任务管理器里句柄数一路涨到几万,最后 GDI+ 抛异常。根因几乎都在线程模型和帧的所有权上。
4.1 NewFrame 回调跑在采集线程上,不是 UI 线程
VideoCaptureDevice内部起自己的线程,NewFrame就在这个线程里触发。在 WinForms 里直接给PictureBox.Image赋值,在 WPF 里直接改Image.Source,轻则界面不刷新,重则抛跨线程访问异常。更隐蔽的问题是:如果你在回调里做耗时操作——缩放、转灰度、写文件、打日志——采集线程会被你堵住,帧在驱动侧堆积,最后表现成延迟越来越大,鼠标动一下画面才跳一下。
所以回调里只做一件事:把帧的所有权转移出去。业务处理全部挪到消费端。判断标准很简单——NewFrame里除了 Clone 和入队,不应该出现任何await、任何 IO、任何锁。
4.2 有界队列加丢旧帧:宁可丢帧,不要堆积
渲染端跟不上采集端是常态,四路 1080p 尤其明显。队列必须设上限,满了就丢最旧的一帧——多开预览场景里,“看到最新画面”比“一帧不丢”重要得多。无界队列的结果就是内存和句柄一起涨,最后程序被系统杀掉。
private readonly BlockingCollection<Bitmap> _queue = new BlockingCollection<Bitmap>(new ConcurrentQueue<Bitmap>(), 2); // 容量 2 足够 public void Push(Bitmap frame) { while (!_queue.TryAdd(frame)) { // 队满先取出最旧的一帧并释放,否则句柄只增不减 if (_queue.TryTake(out var old)) old.Dispose(); else break; } } // 渲染端:用 30ms 的 UI 定时器拉取,而不是每来一帧就 Invoke 一次 private void RenderTick(object sender, EventArgs e) { if (!_queue.TryTake(out var frame)) return; var old = _picture.Image; _picture.Image = frame; // 交接所有权 old?.Dispose(); // 上一帧在这里释放,别留在采集线程里 }容量参数我一般给 2,最多 3。给 1 会在渲染抖动时频繁丢帧,看起来一顿一顿;给 10 以上就等于把延迟写死在代码里。Dispose的位置是这套结构里最关键的:帧一旦入队,释放责任就转移给了消费端,采集端不再管;如果消费端拿到帧后没释放,句柄数会以每秒几十个的速度稳定上涨,跑一晚上必崩。
4.3 释放顺序:SignalToStop、WaitForStop、解绑事件,一个都不能少
关窗后摄像头指示灯常亮、进程退不干净、再开一次报设备被占用,这三件事基本同源:设备对象没有被真正释放。正确的收尾顺序是解绑NewFrame、SignalToStop()、等采集线程退出。少了WaitForStop,采集线程还在跑,COM 对象还活着,驱动就认为设备仍被占用。
热插拔还要多一层。相机被拔掉时 AForge 会抛VideoSourceError,此时那个VideoCaptureDevice实例已经废了,不能只调一次Start()就指望恢复,必须丢掉旧对象、重新枚举、按 moniker 或兜底规则重建会话。监听系统的设备变更消息(WM_DEVICECHANGE里的节点变更事件)比定时轮询省资源,但要注意消息可能在很短时间内连发好几条,需要做个几百毫秒的防抖,否则会连续重建四次设备。
5. 避坑与排查:五个把多开搞崩的现场与处理顺序
下面五条基本覆盖了这类工具 90% 的工单,按“先怀疑什么、再看什么”的顺序排。
5.1 设备层:开得起来但没画面
现象:四路里第一路正常,第二路Start()不报错,帧计数永远是 0,界面一片黑。换一支不同型号的相机插上去,又正常了。
原因:同型号相机的驱动在 DirectShow 层只允许单实例,或者两路被分到了同一个 USB 控制器且带宽已经不够协商。前者是驱动行为,代码改不动;后者是资源问题。
解决:先把两支同型号相机分别插到物理上属于不同控制器的端口(机箱前面板和后面板通常就不是同一个),再试。仍然不行就说明是单实例驱动,只能换型号混插,或者换到 Media Foundation 后端。这个结论要靠排除法得到,别在代码里死磕。
5.2 帧与内存:花屏、撕裂、句柄暴涨
现象:画面随机出现横向撕裂、半帧旧半帧新,跑十几分钟后 GDI+ 抛“参数无效”或进程直接退出。
原因:直接把e.Frame交给 UI,回调返回后这块内存被下一帧复用;或者克隆了但消费端从不Dispose。
解决:回调里强制 Clone,消费端在替换图像后立即释放上一帧。上线前挂个计数器观察Process.GetCurrentProcess().HandleCount,稳定运行一小时句柄数应该基本持平,缓慢上升就说明有帧没释放。
5.3 带宽与格式:多开全绿、卡首帧
现象:单开 1080p 正常,开到第三路时前两路也开始卡,画面发绿或者停在第一帧不动。
原因:像素格式实际是 YUY2,带宽早就超了;代码里设的 MJPG 没生效,或者设置顺序错了。
解决:在Start()之后读回实际协商到的格式校验一次,不要相信自己设进去的值。压缩格式没生效就回去检查SetFormat是否在启动前调用、FourCC 顺序是否颠倒。这一步做完还是绿,就降分辨率。
5.4 生命周期:关了窗设备还被占用
现象:关闭程序再打开,提示设备已被占用;任务管理器里进程还在,摄像头灯亮着。
原因:只调了Stop()没等采集线程退出,或者NewFrame事件没解绑导致对象被事件源持有。
解决:按 4.3 的顺序收尾,并且在窗体关闭事件里对所有会话统一走一遍StopSafe。程序退出前加一道检查:所有会话的IsRunning必须为 false。
5.5 配置漂移:换个口就“设备未找到”
现象:昨天配好的四路,今天换了根延长线,配置文件里的 moniker 全部匹配不上。
原因:MonikerString里含设备路径信息,换端口、换线、加集线器都可能变。
解决:配置里同时存Name和“同型号中的序号”,moniker 匹配失败时用Name分组、按发现顺序重绑,并在界面上提示“已自动重绑,请确认画面”。这比让用户重配四路要友好得多。
6. 进阶:用配置文件加启动自检,把多开变成可交付的采集端
工具能跑之后,真正决定它能不能交付给别人的,是“换一台机器还能不能跑”。我习惯把每一路的参数外置成配置,并在启动时做一轮自检,把问题在打开界面之前暴露出来,而不是等用户点到第三路才发现黑屏。
配置结构大概长这样,moniker 做键,友好名做兜底,格式和帧率显式写死:
public sealed class CameraConfig { public string Alias { get; set; } // 工位别名,如 "left-front" public string Moniker { get; set; } // 主键,换口会变 public string FallbackName { get; set; } // 兜底匹配用的友好名 public int Width { get; set; } = 1280; public int Height { get; set; } = 720; public string FourCC { get; set; } = "MJPG"; // 超过两路就别留空 public int Fps { get; set; } = 25; // 留余量,别顶着 30 设 }自检逻辑是:按配置逐路建会话、启动、统计三秒内的实际帧数,然后和期望帧率比。低于期望的六成,就直接标黄并不允许进入正式采集界面,同时在日志里写清是“协商帧率不符”还是“完全无帧”——这两种情况的排查方向完全不同,前者查带宽和格式,后者查设备和驱动。
| 自检项 | 判定标准 | 不达标先查什么 |
|---|---|---|
| 设备是否枚举到 | 按 moniker 或兜底规则命中 | 端口是否变更、驱动是否装上 |
| 实际像素格式 | 与配置的 FourCC 一致 | SetFormat调用时机、设置顺序 |
| 实际分辨率 | 与配置一致 | 驱动是否支持该组合 |
| 三秒实际帧数 | 不低于期望帧率的 60% | 带宽、光照导致的自动曝光 |
| 句柄数增长 | 空跑五分钟基本持平 | 帧是否漏了Dispose |
一个具体技巧:把自检结果写成一行文本贴在界面角落,比如4/4 路就绪,1080p MJPG,实测 24/25/23/25 fps。现场出问题时,用户拍一张截图给你,你就能判断是带宽不够还是某一路没起来,比让他描述“有点卡”强太多。
这套东西我从单路一路改到四路,最早那版是没有WaitForStop的,测试时关窗再开十次就必定占用,只能重启电脑。从那以后我每次加采集源,都强制先把停止路径和句柄计数跑一遍,再动业务代码。希望帮到你。
本文还有配套的精品资源,点击获取