C# WinForms 摄像头采集:AForge.NET 拍照与避坑指南
简介本资源面向具备一定C#基础的Winform开发者与计算机视觉入门者聚焦使用Aforge框架调用摄像头并完成拍照这一常见需求帮助读者快速搭建可运行的摄像头采集与拍照Demo。包内为1个PDF文档压缩包约177KB内容以图文与代码示例为主涵盖Aforge框架简介、Nuget引用配置、窗体控件设计以及摄像头枚举、分辨率获取、视频源连接与拍照等关键实现环节并附有可直接参考的C#代码片段。目前已有860人学习下载适合希望将摄像头拍照能力集成到安防、医疗、零售等场景应用中的开发者也可作为计算机视觉项目前期验证的参考材料便于对照代码理解Aforge在Winform中的实际用法与常见处理思路。1. 从一次产线工控机的翻车说起为什么我还在用 AForge 抓摄像头去年帮一个做视觉检测的朋友救急产线上一台工控机跑着 WinForms 程序需要定时抓取工位摄像头的画面存档。他之前用某商业 SDK授权到期后弹窗锁死整条线停了两个小时。我过去看了一眼需求其实简单到离谱打开摄像头、预览、点一下按钮存一张 JPG。这种场景我一般不会上 OpenCVSharp 那种重家伙直接 AForge.NET 走起半小时改完收工。AForge.NET 是个老牌的 C# 计算机视觉与人工智能库里面的AForge.Video和AForge.Video.DirectShow两个命名空间专门负责视频采集底层封装的是 DirectShow。它最大的好处是纯托管、零外部依赖、NuGet 一装就能用对 WinForms 和 WPF 都友好。你要做的只是把FilterInfoCollection列出来的摄像头设备绑到一个下拉框用户选一个VideoCaptureDevice一开NewFrame事件里把Bitmap往PictureBox上一丢拍照就是顺手Clone()一份存盘的事。适合谁做上位机、工控 HMI、实验室数据采集、教学演示的 C# 开发者。不适合谁需要高帧率、多路并发、GPU 加速的场景那还是老老实实上 OpenCVSharp 或者厂商 SDK。这篇就把我这些年用 AForge 抓摄像头的完整流程、参数细节和踩过的坑一次讲透。2. 环境搭建与设备枚举从 NuGet 包到摄像头列表2.1 装包与项目配置AForge 在 NuGet 上的包名分得很细拍照场景只需要两个AForge和AForge.Video.DirectShow。后者依赖前者装的时候 NuGet 会自动带上。我一般用 Package Manager Console 装版本号不写死让它拉最新的稳定版# 在 Visual Studio 的 Package Manager Console 里执行 Install-Package AForge Install-Package AForge.Video.DirectShow如果你习惯用 .NET CLI等价命令是dotnet add package AForge dotnet add package AForge.Video.DirectShow装完检查一下引用列表AForge.Video.DirectShow下面应该能看到AForge.Video.DirectShow.dll。有个细节要注意AForge 的包目标框架比较老如果你项目是 .NET 6/7/8NuGet 可能会提示兼容性警告但实际在 Windows 上跑没问题因为它底层调的是系统 DirectShow不依赖特定运行时。我一般会忽略这个警告但如果你项目开了TreatWarningsAsErrors记得把 NU1701 排除掉。提示AForge 系列包最后更新停在好几年前但功能稳定不需要追新。真正要留意的是你的项目平台目标必须显式设为 x86 或 x64不能是 AnyCPU否则 DirectShow 的 COM 互操作在某些机器上会报COMException。2.2 枚举摄像头设备DirectShow 枚举设备靠的是FilterInfoCollection传入FilterCategory.VideoInputDevice就只列摄像头不会把音频设备混进来。下面这段代码我一般放在窗体构造函数或者Load事件里using AForge.Video.DirectShow; // 声明为窗体级字段后面开流和切换设备都要用 private FilterInfoCollection _videoDevices; private VideoCaptureDevice _videoSource; private void EnumerateCameras() { // 只枚举视频输入设备排除音频采集设备 _videoDevices new FilterInfoCollection(FilterCategory.VideoInputDevice); if (_videoDevices.Count 0) { // 没有摄像头时给出明确提示不要静默失败 MessageBox.Show(未检测到任何摄像头设备请检查连接后重试。); return; } // 清空下拉框避免重复枚举时叠加 cmbCameras.Items.Clear(); foreach (FilterInfo device in _videoDevices) { // device.Name 是设备友好名device.MonikerString 是唯一标识 cmbCameras.Items.Add(device.Name); } // 默认选中第一个省得用户还要手动点 cmbCameras.SelectedIndex 0; }逻辑说明FilterInfoCollection构造时会去问 DirectShow 要设备列表Count为 0 说明系统层面就没认到摄像头这时候别急着往下走先提示用户。FilterInfo有两个关键属性Name是给人看的设备名MonikerString是给 DirectShow 用的唯一标识字符串。实际开流的时候我建议用MonikerString而不是Name因为有些机器上两个同型号摄像头Name会重名用MonikerString才能精确指定。参数说明FilterCategory.VideoInputDevice这个枚举值决定了枚举范围换成AudioInputDevice就是麦克风别搞混。cmbCameras是 WinForms 的ComboBox如果你用 WPF换成ComboBox控件逻辑一样。2.3 设备热插拔的处理工控现场经常有人拔了摄像头再插回去程序如果只在启动时枚举一次后面就抓瞎了。我的做法是在窗体上放一个「刷新设备」按钮点击时先停掉当前流再重新枚举private void btnRefresh_Click(object sender, EventArgs e) { // 先停流否则设备被占用重新枚举可能拿不到 StopCamera(); EnumerateCameras(); } private void StopCamera() { if (_videoSource ! null _videoSource.IsRunning) { // 发停止信号等待内部线程退出 _videoSource.SignalToStop(); _videoSource.WaitForStop(); _videoSource null; } }这里SignalToStop和WaitForStop必须成对出现。只调SignalToStop是异步的设备可能还没释放你就去枚举了结果新列表里看不到刚插上的设备。WaitForStop会阻塞到采集线程真正退出虽然会卡一下 UI但比出玄学问题强。如果嫌卡可以放到Task.Run里但记得回来更新 UI 要Invoke。3. 开流、预览与拍照NewFrame 事件里的完整链路3.1 启动视频流选好设备后用MonikerString构造VideoCaptureDevice然后订阅NewFrame事件最后调Start()。这三步顺序不能乱先订阅再启动否则第一帧可能丢。private void StartCamera() { if (cmbCameras.SelectedIndex 0) return; // 用 MonikerString 精确定位设备避免重名问题 string moniker _videoDevices[cmbCameras.SelectedIndex].MonikerString; _videoSource new VideoCaptureDevice(moniker); // 设置期望的分辨率具体支持哪些要看设备能力 _videoSource.DesiredFrameSize new Size(1280, 720); _videoSource.DesiredFrameRate 30; // 先订阅事件再启动 _videoSource.NewFrame OnNewFrame; _videoSource.Start(); } private void OnNewFrame(object sender, NewFrameEventArgs eventArgs) { // 注意这个回调在采集线程上不是 UI 线程 Bitmap frame (Bitmap)eventArgs.Frame.Clone(); // 用 Invoke 切回 UI 线程更新 PictureBox if (picPreview.InvokeRequired) { picPreview.Invoke(new Action(() { // 释放旧图避免 GDI 对象泄漏 Image old picPreview.Image; picPreview.Image frame; old?.Dispose(); })); } else { Image old picPreview.Image; picPreview.Image frame; old?.Dispose(); } }逻辑说明NewFrame事件的eventArgs.Frame是一张复用的BitmapAForge 内部会不断往同一块内存写新帧。所以你绝对不能直接把eventArgs.Frame赋给PictureBox.Image否则下一帧来了你看到的图就变了而且多线程访问会直接崩。必须Clone()一份出来。Clone()出来的Bitmap归你管用完要Dispose不然跑一晚上 GDI 对象数能飙到几万程序直接卡死。参数说明DesiredFrameSize和DesiredFrameRate是「期望值」不是「强制值」。摄像头驱动会挑一个最接近的支持格式。比如你写 1280x720但设备只支持 640x480 和 1920x1080它可能给你 1920x1080。想确认实际生效的分辨率可以在NewFrame里打印eventArgs.Frame.Width和Height看一眼。DesiredFrameRate同理设 30 但设备只给 15 是常事。3.2 拍照存盘拍照的本质就是在某一帧上Clone()一份然后Save成文件。我一般把当前帧先存到一个字段里按钮点击时再存盘这样避免在事件回调里做 IO 阻塞采集线程。// 窗体级字段保存最新一帧的副本 private Bitmap _latestFrame; private void OnNewFrame(object sender, NewFrameEventArgs eventArgs) { Bitmap frame (Bitmap)eventArgs.Frame.Clone(); // 更新最新帧引用旧的释放掉 Bitmap oldFrame _latestFrame; _latestFrame frame; oldFrame?.Dispose(); // 预览更新逻辑同上略 } private void btnCapture_Click(object sender, EventArgs e) { if (_latestFrame null) { MessageBox.Show(还没有收到视频帧请确认摄像头已启动。); return; } // 按时间戳命名避免覆盖 string fileName $capture_{DateTime.Now:yyyyMMdd_HHmmss_fff}.jpg; string fullPath Path.Combine(Application.StartupPath, Captures, fileName); // 确保目录存在 Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); // 用 Clone 再存避免存盘期间采集线程改写原图 using (Bitmap snapshot (Bitmap)_latestFrame.Clone()) { // 第二个参数指定 JPEG 质量不传会用默认值 snapshot.Save(fullPath, ImageFormat.Jpeg); } // 可选在界面上给个反馈 lblStatus.Text $已保存{fileName}; }逻辑说明_latestFrame始终持有最新一帧的副本拍照时再Clone一次是为了防止存盘过程中采集线程继续改写_latestFrame指向的内存。虽然_latestFrame本身已经是副本但Save是 IO 操作耗时可能几十毫秒这期间新帧来了会把_latestFrame换掉你正在存的图就可能被释放。多Clone一次代价很小换来的是稳定。参数说明ImageFormat.Jpeg是存盘格式想存 PNG 就换ImageFormat.Png无损但文件大。JPEG 质量默认是 75 左右想控制质量得用EncoderParameters这个后面进阶部分讲。文件名用fff毫秒后缀防止一秒内连拍覆盖。3.3 关流与资源释放程序退出或者切换设备时必须把流关干净否则摄像头指示灯一直亮着下次打开还可能报「设备被占用」。private void Form1_FormClosing(object sender, FormClosingEventArgs e) { StopCamera(); _latestFrame?.Dispose(); }StopCamera就是前面写的SignalToStopWaitForStop。这里有个血泪经验如果你在FormClosing里只调SignalToStop不调WaitForStop程序退出时采集线程可能还在跑进程会残留任务管理器里能看到一个僵尸进程。加上WaitForStop就干净了。4. 避坑与排查那些年 AForge 抓摄像头翻过的车4.1 现象程序启动报 COMExceptionHRESULT 0x80070005原因项目平台目标设成了 AnyCPU在 64 位系统上以 64 位进程运行但某些摄像头驱动的 DirectShow 滤镜只注册了 32 位版本COM 组件加载失败。解决把项目平台目标显式改成 x86 或 x64跟驱动匹配。不确定驱动位数就两个都试一下工控机上 x86 兼容性通常更好。改完记得清理解决方案重新生成光改配置有时候不生效。4.2 现象预览画面卡顿、延迟越来越大原因NewFrame回调里做了耗时操作比如直接Save存盘、或者Invoke到 UI 线程时 UI 正忙导致采集线程被阻塞帧在缓冲区里堆积。解决回调里只做Clone和赋值存盘、图像处理全部挪到按钮事件或者独立线程。Invoke如果 UI 忙会阻塞可以改用BeginInvoke但要注意帧率太高时BeginInvoke会排队反而更卡。我的做法是在回调里判断一下如果上一帧还没处理完就跳过这一帧丢帧比堆积强。4.3 现象跑几个小时后程序崩溃报 OutOfMemoryException原因PictureBox.Image每次赋新图时旧图没释放GDI 对象持续泄漏。Windows 单进程 GDI 对象上限默认是 10000跑满就崩。解决每次赋新图前把旧图Dispose掉就是前面代码里old?.Dispose()那一步。另外_latestFrame换新的时候也要释放旧的。想确认泄漏可以在任务管理器里打开「GDI 对象」列盯着数字看正常应该稳定在几百一直涨就是漏了。4.4 现象切换摄像头后新设备打不开提示被占用原因旧设备的VideoCaptureDevice没有WaitForStop就置空了底层采集线程还在跑设备句柄没释放。解决切换前必须走完整的SignalToStopWaitForStop流程确认IsRunning变成 false 再构造新设备。如果还是不行可能是驱动层面的释放延迟加一个 200 毫秒的Thread.Sleep再开新流虽然土但管用。4.5 现象DesiredFrameSize 设了没效果实际分辨率不对原因DesiredFrameSize只是期望值设备驱动会挑最接近的支持格式。有些摄像头只支持特定几种分辨率组合你设的它不支持就给你换一个。解决先查设备支持哪些格式。VideoCaptureDevice有个VideoCapabilities属性是个VideoCapabilities数组遍历打印FrameSize和AverageFrameRate就能看到全部支持项。选一个设备真正支持的尺寸去设别拍脑袋写。// 打印设备支持的所有视频格式 foreach (VideoCapabilities cap in _videoSource.VideoCapabilities) { System.Diagnostics.Debug.WriteLine( $支持{cap.FrameSize.Width}x{cap.FrameSize.Height} {cap.AverageFrameRate}fps); }5. 进阶技巧JPEG 质量控制与定时连拍5.1 控制 JPEG 压缩质量默认Save出来的 JPEG 质量大概 75做存档够用但要做图像分析可能嫌糊。想精确控制质量得用EncoderParametersusing System.Drawing.Imaging; private void SaveJpegWithQuality(Bitmap bmp, string path, long quality) { // 获取 JPEG 编码器 ImageCodecInfo jpegEncoder ImageCodecInfo.GetImageEncoders() .FirstOrDefault(c c.FormatID ImageFormat.Jpeg.Guid); if (jpegEncoder null) { // 理论上不会发生但防御一下 bmp.Save(path, ImageFormat.Jpeg); return; } // 构造质量参数quality 范围 0-100 using (EncoderParameters parameters new EncoderParameters(1)) { parameters.Param[0] new EncoderParameter(Encoder.Quality, quality); bmp.Save(path, jpegEncoder, parameters); } }逻辑说明ImageCodecInfo.GetImageEncoders()返回系统所有图像编码器按FormatID筛出 JPEG 那个。EncoderParameters是个参数容器Encoder.Quality是质量参数值越高文件越大。我一般存档用 85做后续处理用 95再高收益就不明显了。参数说明quality传long类型范围 0 到 100。传 0 会得到极小但极糊的图传 100 文件可能比原图还大。实测 85 是画质和体积的甜点。5.2 定时连拍有些场景要每隔几秒拍一张比如做延时记录。用System.Windows.Forms.Timer最简单但要注意别在 Tick 里做重活否则 UI 卡private System.Windows.Forms.Timer _captureTimer; private void InitTimer() { _captureTimer new System.Windows.Forms.Timer(); // 间隔 5000 毫秒即 5 秒一张 _captureTimer.Interval 5000; _captureTimer.Tick (s, e) btnCapture_Click(s, e); } private void btnStartTimedCapture_Click(object sender, EventArgs e) { _captureTimer.Start(); lblStatus.Text 定时连拍已启动每 5 秒一张。; } private void btnStopTimedCapture_Click(object sender, EventArgs e) { _captureTimer.Stop(); lblStatus.Text 定时连拍已停止。; }逻辑说明Timer的Tick在 UI 线程上触发直接调拍照逻辑没问题因为拍照本身只是Clone加Save几十毫秒内能完成。但如果你的存盘路径是网络盘IO 可能慢那就得把存盘挪到Task.Run里避免 Tick 堆积。参数说明Interval单位是毫秒最小有效值大概 15 毫秒再小没意义。连拍间隔根据场景定做延时摄影可以设 1000 到 5000做监控存档可以设 60000。5.3 一个验证小技巧写完拍照功能别急着上产线。我一般会做一个「连拍 100 张」的测试按钮跑完之后去任务管理器看 GDI 对象数有没有回落再看内存占用是否稳定。如果 GDI 数一直涨说明有泄漏回去查Dispose。这个习惯帮我提前拦下过好几次问题比上线后半夜被叫起来强。从那以后我每次用 AForge 抓摄像头都会先把Dispose链路和WaitForStop走一遍再交付。希望帮到你。本文还有配套的精品资源点击获取