news 2026/10/17 6:59:35

C# USB摄像头多开工具:Afroge实现多路采集与录制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C# USB摄像头多开工具:Afroge实现多路采集与录制

简介:这是一份基于C#与AForge组件开发的USB摄像头多开工具源码包,面向需要在Windows平台实现多路摄像头同步采集的开发者,适用于视频会议、监控预览、直播录制等场景。项目核心围绕多摄像头同时开启、分辨率自由选择、9画面均匀布局与多线程视频流同步处理展开,涉及DirectShow设备交互、Windows Forms界面设计及错误兼容处理等知识点,适合具备一定C#与.NET基础的读者研究学习。压缩包共56个文件,约7.35MB,包含15个dll依赖库、6个cs源码文件、6个exe可执行程序,以及resx资源、manifest清单、csproj与sln工程文件等,结构完整可直接编译运行。目前已有1538人学习下载,读者可借此掌握多路视频流同步、窗口动态布局与AForge实际调用方式,并参考其工程组织与排错思路进行二次开发。

1. 一个让我少买三个摄像头的 C# USB 多开工具

上个月帮朋友做一个多工位行为采集的小系统,需求很朴素:一台工控机上插四个 USB 摄像头,同时预览、同时录制,还要能按工位编号分别存文件。我第一反应是找现成的商业软件,结果要么按路数收费,要么只支持两路,要么延迟高得离谱。后来干脆自己用 C# 写了一个 USB 摄像头多开工具,核心就是 Afroge 这套采集库加上 WinForm 的界面壳子,四个 1080P 摄像头同时跑,CPU 占用稳定在 30% 上下,延迟控制在 200ms 以内。这个项目适合两类人:一类是手上有一堆 USB 摄像头、想在一台机器上统一管理的开发者;另一类是想学 C# 视频采集、DirectShow 封装、多线程渲染的入门选手。它不依赖任何云服务,纯本地跑,插上就能用,改起来也简单——源码结构清晰,核心逻辑不到两千行,二次开发的门槛很低。

2. 项目定位与技术选型:为什么是 Afroge 而不是 OpenCVSharp

2.1 这个工具到底解决什么问题

先说清楚场景。很多做机器视觉、行为分析、考场监控、产线质检的朋友,手上都有多个 USB 摄像头,但市面上的软件要么只支持单路,要么多路时资源调度一塌糊涂。这个工具的目标很明确:在一台 Windows 机器上,同时打开多个 USB 摄像头,每个摄像头独立预览、独立录制、独立截图,互不干扰。它不涉及网络推流,不做 AI 识别,就是一个纯粹的本地多路采集工具。你可以把它理解成一个「多路摄像头管理器」,插上几个就开几路,关掉哪路就释放哪路。

为什么不用 OpenCVSharp?我试过。OpenCVSharp 的 VideoCapture 在单路场景下没问题,但多路 USB 摄像头同时打开时,DirectShow 的枚举和绑定会打架,经常出现「第一个能开,第二个报设备占用」的情况。而且 OpenCVSharp 的帧回调是阻塞式的,四路同时跑,UI 线程直接被拖死。Afroge 不一样,它底层封装的是 DirectShow 的 IBaseFilter 和 ISampleGrabber,每个摄像头实例独立管理自己的 Filter Graph,互不干扰。更关键的是,Afroge 的 NewFrame 事件是异步触发的,你可以把帧数据直接丢给后台线程处理,UI 只负责画,不卡。

2.2 技术栈拆解:WinForm + Afroge + 多线程

整个项目的技术栈很薄,薄到你可以一晚上读完:

层级技术选型作用
界面层WinForm主窗口、设备列表、预览面板、按钮控制
采集层Afroge.Video枚举 USB 设备、打开/关闭摄像头、帧回调
渲染层PictureBox + Bitmap将帧数据绘制到界面,支持多面板布局
录制层AForge.Video.FFMPEG将帧序列编码为 MP4 文件
并发层Task + ConcurrentDictionary管理多路采集任务,避免线程冲突

Afroge 本身是 .NET 2.0 时代的老库,但胜在稳定。它的 VideoCaptureDevice 类封装了 DirectShow 的枚举和连接逻辑,你只需要调用new VideoCaptureDevice(monikerString)就能拿到一个设备实例。monikerString 是 DirectShow 的设备唯一标识,通过FilterInfoCollection枚举出来。每个设备实例可以独立设置分辨率、帧率、输入格式,然后订阅 NewFrame 事件,在事件里拿 Bitmap 帧。

多线程这块,我一开始踩过坑。最早我把帧回调直接写在 UI 线程里,结果四路一开,界面直接假死。后来改成每个摄像头一个独立的后台 Task,帧数据通过 ConcurrentQueue 传给 UI 线程,UI 用 Timer 定时刷新。这样采集和渲染解耦,CPU 占用从 80% 降到 30%。

2.3 目录结构:一眼看清模块边界

源码目录结构如下,没有花里胡哨的分层,就是按功能拆:

UsbCameraMultiTool/ ├── Forms/ │ ├── MainForm.cs # 主窗口,设备列表 + 预览面板 │ └── CameraPanel.cs # 单个摄像头预览控件 ├── Core/ │ ├── CameraDevice.cs # 摄像头设备封装,打开/关闭/帧回调 │ ├── CameraManager.cs # 多设备管理器,负责枚举和调度 │ └── Recorder.cs # 录制模块,帧写入 MP4 ├── Utils/ │ ├── FrameBuffer.cs # 帧缓冲队列,解决生产消费速度不匹配 │ └── DeviceHelper.cs # DirectShow 设备枚举辅助 ├── Resources/ │ └── icons/ # 界面图标 └── Program.cs # 入口

Core 目录是核心,CameraDevice 负责单个设备的生命周期,CameraManager 负责多设备的统一调度,Recorder 负责录制。Forms 目录是界面,CameraPanel 是一个自定义控件,每个摄像头对应一个 Panel,里面放 PictureBox 和状态标签。Utils 里的 FrameBuffer 是关键,它用 ConcurrentQueue 做帧缓冲,防止采集过快导致 UI 卡顿。

3. 核心模块拆解:从设备枚举到帧渲染的完整链路

3.1 设备枚举:拿到所有 USB 摄像头的 monikerString

Afroge 枚举设备靠的是 FilterInfoCollection,传入 FilterCategory.VideoInputDevice 就能拿到所有视频输入设备。代码很短:

using AForge.Video.DirectShow; public List<CameraInfo> EnumerateDevices() { var devices = new List<CameraInfo>(); // 枚举所有视频输入设备 var collection = new FilterInfoCollection(FilterCategory.VideoInputDevice); foreach (FilterInfo info in collection) { devices.Add(new CameraInfo { Name = info.Name, // 设备名称,如 "USB Camera 01" Moniker = info.MonikerString // DirectShow 唯一标识 }); } return devices; }

逻辑说明:FilterInfoCollection 是 Afroge 对 DirectShow 枚举器的封装,FilterCategory.VideoInputDevice 指定只枚举视频输入设备。每个 FilterInfo 包含 Name 和 MonikerString,Name 是显示给用户看的,MonikerString 是打开设备时用的。参数方面,FilterCategory 还可以选 AudioInputDevice 枚举麦克风,但这里不需要。

注意:有些 USB 摄像头在 Windows 上会被识别成「USB Video Device」这种通用名称,多个同型号摄像头名称可能一样。这时候不能靠 Name 区分,必须用 MonikerString。我一般会在界面上显示 Name + MonikerString 后四位,方便用户区分。

3.2 打开设备与帧回调:NewFrame 事件里的生产消费模型

打开一个摄像头实例,核心是设置分辨率、订阅 NewFrame、启动采集:

public void OpenDevice(string moniker, int width, int height) { // 创建采集设备实例 _device = new VideoCaptureDevice(moniker); // 查找支持目标分辨率的格式 var caps = _device.VideoCapabilities; var target = caps.FirstOrDefault(c => c.FrameSize.Width == width && c.FrameSize.Height == height); if (target != null) { _device.VideoResolution = target; // 设置分辨率 } // 订阅帧回调 _device.NewFrame += OnNewFrame; // 启动采集,内部会创建独立线程 _device.Start(); } private void OnNewFrame(object sender, NewFrameEventArgs eventArgs) { // 克隆帧,避免被采集线程回收 var frame = (Bitmap)eventArgs.Frame.Clone(); // 丢入缓冲队列,由 UI 线程消费 _frameBuffer.Enqueue(frame); }

逻辑说明:VideoCaptureDevice 构造函数接收 monikerString,创建实例。VideoCapabilities 返回设备支持的所有格式列表,每个格式包含分辨率、帧率、像素格式。设置 VideoResolution 后,设备会按这个格式输出帧。NewFrame 事件在采集线程触发,eventArgs.Frame 是一个 Bitmap,但它是复用的,下一次回调会覆盖,所以必须 Clone。Clone 后的帧丢进 FrameBuffer,UI 线程从队列里取,取完 Dispose,避免内存泄漏。

参数说明:width 和 height 是目标分辨率,常见 USB 摄像头支持 640x480、1280x720、1920x1080。如果设备不支持目标分辨率,target 为 null,此时会用默认格式,可能导致画面拉伸。我一般会在界面上让用户手动选分辨率,选完再打开。

3.3 多路管理:CameraManager 如何调度四个实例

CameraManager 负责管理多个 CameraDevice,核心是一个 ConcurrentDictionary:

public class CameraManager { // 设备ID -> 设备实例 private ConcurrentDictionary<string, CameraDevice> _devices = new ConcurrentDictionary<string, CameraDevice>(); public bool AddCamera(string moniker, int width, int height) { var device = new CameraDevice(); if (!device.OpenDevice(moniker, width, height)) return false; // 用 moniker 作为 key,保证唯一 return _devices.TryAdd(moniker, device); } public void RemoveCamera(string moniker) { if (_devices.TryRemove(moniker, out var device)) { device.Close(); // 释放 DirectShow 资源 } } public void CloseAll() { foreach (var key in _devices.Keys) { RemoveCamera(key); } } }

逻辑说明:ConcurrentDictionary 保证多线程下增删设备安全。AddCamera 先创建设备实例并打开,成功后再加入字典。RemoveCamera 先从字典移除,再调用 Close 释放资源。Close 内部会停止采集、取消事件订阅、释放 Filter Graph。如果不先移除再关闭,可能出现 UI 线程还在读设备状态、后台线程已经释放资源的竞态。

参数说明:moniker 是设备唯一标识,作为字典 key 最合适。width 和 height 是分辨率,每个设备可以不同。实际项目中,我一般会限制最多同时开 8 路,再多 USB 带宽扛不住,会出现掉帧。

3.4 录制模块:把帧序列写成 MP4

录制用 AForge.Video.FFMPEG 的 VideoFileWriter,核心是设置编码参数、逐帧写入:

using AForge.Video.FFMPEG; public class Recorder { private VideoFileWriter _writer; private bool _isRecording; public void Start(string filePath, int width, int height, int fps) { _writer = new VideoFileWriter(); // 打开输出文件,设置编码参数 _writer.Open(filePath, width, height, fps, VideoCodec.MPEG4); _isRecording = true; } public void WriteFrame(Bitmap frame) { if (!_isRecording) return; // 写入一帧,内部会做编码 _writer.WriteVideoFrame(frame); } public void Stop() { _isRecording = false; _writer?.Close(); // 关闭文件,写入索引 _writer?.Dispose(); } }

逻辑说明:VideoFileWriter 是 FFmpeg 的 .NET 封装,Open 方法接收文件路径、宽、高、帧率、编码器。VideoCodec.MPEG4 是常用编码,兼容性好,文件大小适中。WriteVideoFrame 逐帧写入,内部会做 YUV 转换和编码。Stop 时调用 Close 写入文件索引,否则 MP4 无法播放。

参数说明:fps 要和采集帧率一致,否则播放速度不对。width 和 height 要和帧的实际尺寸一致,否则会拉伸。我一般会在录制前检查帧尺寸,如果和设置不符,先缩放再写入。

4. 避坑与常见问题:多路 USB 采集的血泪经验

4.1 现象:第二个摄像头打开报「设备已被占用」

原因:USB 摄像头在 Windows 上默认是独占模式,一个设备只能被一个进程打开。但更常见的情况是,第一个摄像头关闭时没有正确释放 DirectShow 的 Filter Graph,导致设备句柄残留。

解决:在 Close 方法里,先调用_device.SignalToStop(),再调用_device.WaitForStop(),最后置 null。SignalToStop 通知采集线程停止,WaitForStop 阻塞等待线程退出。两步都做完,设备才真正释放。我一般在 RemoveCamera 里加一个 200ms 的延迟,确保系统回收句柄。

4.2 现象:四路同时开,界面卡死,鼠标都动不了

原因:帧回调在采集线程触发,如果直接在回调里更新 PictureBox,相当于在非 UI 线程操作控件,WinForm 会抛跨线程异常,或者用 Invoke 导致 UI 线程被频繁占用。

解决:用 FrameBuffer 做缓冲,采集线程只负责入队,UI 线程用 Timer 定时出队并刷新。Timer 间隔设 30ms,相当于 33fps,足够流畅。FrameBuffer 内部用 ConcurrentQueue,入队和出队无锁,性能很好。

4.3 现象:录制文件播放时花屏、绿屏

原因:VideoFileWriter 的 Open 参数里,width 和 height 必须和实际帧尺寸完全一致。如果摄像头输出 640x480,你按 1280x720 打开,编码器会按错误尺寸解析帧数据,导致花屏。

解决:在 Start 录制前,先拿一帧出来,读它的 Width 和 Height,用这个值去 Open。如果帧尺寸和预期不符,先做一次缩放。另外,fps 也要和实际采集帧率一致,否则播放速度会快或慢。

4.4 现象:跑一段时间后内存暴涨,最后 OutOfMemory

原因:NewFrame 里的 eventArgs.Frame 是复用的,如果不 Clone 直接存起来,下一帧会覆盖。但 Clone 之后如果忘记 Dispose,Bitmap 对象会堆积,GC 回收不及时就爆内存。

解决:FrameBuffer 出队后,UI 刷新完立即 Dispose。录制模块写入帧后也立即 Dispose。我一般会在 FrameBuffer 里加一个最大长度限制,比如 10 帧,超过就丢弃最旧的,防止生产过快消费过慢。

4.5 现象:某些摄像头枚举不到,或者打开后黑屏

原因:部分 USB 摄像头走的是 UVC 协议,但驱动没装好,或者被其他进程占用。另外,Afroge 只支持 DirectShow 设备,如果摄像头只提供 Media Foundation 接口,枚举不到。

解决:先用系统自带的「相机」应用测试摄像头是否正常。如果系统能打开,Afroge 枚举不到,可能是驱动问题,换一个 USB 口或者重装驱动。如果枚举到了但黑屏,检查 VideoCapabilities 里有没有合适的分辨率,有些摄像头默认输出 YUY2 格式,Afroge 需要额外转换。

5. 二次开发与改造:从能跑到好用

5.1 加一个「快照」功能:一键保存当前帧

快照功能很简单,在 CameraPanel 里加一个按钮,点击时从 FrameBuffer 取最新一帧,存成 JPG:

private void OnSnapshotClick(object sender, EventArgs e) { var frame = _frameBuffer.PeekLatest(); // 取最新帧,不出队 if (frame == null) return; var path = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), $"snapshot_{DateTime.Now:yyyyMMdd_HHmmss}.jpg"); frame.Save(path, ImageFormat.Jpeg); // 保存为 JPG }

逻辑说明:PeekLatest 返回队列里最新的一帧,不删除。Save 方法用 JPG 编码器保存,质量默认 75%。如果要调质量,用 EncoderParameters 设置。注意 frame 是 Clone 过的,保存完要 Dispose。

5.2 加一个「定时录制」:按时间段自动分段

定时录制需要两个参数:录制时长和分段间隔。我一般用 System.Timers.Timer 做调度:

private System.Timers.Timer _recordTimer; public void StartScheduledRecording(int segmentMinutes) { _recordTimer = new System.Timers.Timer(segmentMinutes * 60 * 1000); _recordTimer.Elapsed += (s, e) => { // 停止当前文件,开新文件 _recorder.Stop(); var newPath = GeneratePath(); _recorder.Start(newPath, _width, _height, _fps); }; _recordTimer.AutoReset = true; _recordTimer.Start(); }

逻辑说明:Timer 每隔 segmentMinutes 触发一次,先 Stop 当前录制,再 Start 新文件。GeneratePath 按时间戳生成文件名,保证不重复。注意 Timer 回调在后台线程,操作 Recorder 时要加锁,防止和帧写入线程冲突。

5.3 改造建议:把 WinForm 换成 WPF 或 Avalonia

WinForm 的 PictureBox 在四路以上时,刷新会有闪烁。如果要做更复杂的界面,建议换成 WPF。WPF 的 Image 控件支持硬件加速,四路 1080P 同时渲染也不卡。Afroge 的采集层不用改,只需要把帧数据转成 WriteableBitmap,绑定到 Image 的 Source。Avalonia 跨平台,如果你以后想跑在 Linux 上,可以试试,但 Afroge 依赖 DirectShow,Linux 下要换 Video4Linux 的封装。

5.4 一个我踩过的坑:别在 UI 线程里做帧格式转换

最早我在 UI 线程里把 Bitmap 转成 byte[] 再画,结果四路一开,CPU 直接飙到 90%。后来改成在采集线程里转好,UI 只负责画 Bitmap,CPU 降到 30%。血泪经验:帧格式转换、缩放、编码这些重活,全部丢给后台线程,UI 线程只做最轻的绘制。从那以后我每次加新功能,都先问一句:这个操作能不能放后台?希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 6:59:35

SnowNLP情感分析与词云实战:豆瓣评论打分机制与避坑指南

简介&#xff1a;这份资源面向Python数据挖掘与自然语言处理初学者&#xff0c;围绕豆瓣《肖申克的救赎》影评展开情感分析与词云可视化实践&#xff0c;帮助读者掌握SnowNLP库的调用方式&#xff0c;理解中文文本从分词、情感打分到结果呈现的完整流程&#xff0c;适合作为课程…

作者头像 李华
网站建设 2026/10/17 6:59:11

ImageNet数据集下载与PyTorch加载实战:从注册到训练全流程

1. 内容整体设计与思路拆解ImageNet这个数据集&#xff0c;做计算机视觉的同行应该都不陌生。很多人第一次接触它是在读论文的时候&#xff0c;看到某个模型在ImageNet上刷新了准确率&#xff0c;于是自己也想去跑一跑、试一试。但真到动手下载的时候&#xff0c;才发现事情没那…

作者头像 李华
网站建设 2026/10/17 6:58:40

自适应遗传算法驱动风光场景生成与电动汽车并网调度优化

做优化调度方向的同学&#xff0c;大概率都经历过这么一幕&#xff1a;手里拿着一套标准算例&#xff0c;风、光出力用的是固定数字&#xff0c;电动汽车充电负荷也是拍脑袋给的曲线&#xff0c;结果跑出来的调度方案在答辩或审稿意见里反复被同一个问题拷问——你凭什么说这个…

作者头像 李华
网站建设 2026/10/17 6:57:30

JDK17 Linux Arm64 安装配置与调优:从解压部署到systemd服务化

简介&#xff1a;面向Linux Arm64平台的JDK 17完整发行包&#xff0c;适合在ARM服务器、云原生节点及嵌入式设备上编写、编译与运行Java应用的开发者。该版本包含密封类、记录类、instanceof模式匹配、增强型Switch表达式等新特性&#xff0c;并针对低暂停垃圾收集器做了优化&a…

作者头像 李华