简介:面向具备一定 C# 与 .NET 基础、希望把 USB 摄像头接入桌面程序的开发者,这份资源以实例工程的形式演示设备枚举、视频流连接、拍照抓拍、暂停恢复与释放关闭等完整流程,适合做视频采集、图像抓拍类工具或课程练习时参考。压缩包共 38 个文件,约 181KB,包含 6 个 cs 源码文件与 1 个 sln 解决方案、1 个 csproj 工程文件,另有 10 个 dll 依赖库、3 个 exe 生成的程序、2 个 resx 与 2 个 resources 界面资源,以及 pdb、settings、manifest 等配置与调试附属文件,可直接用 Visual Studio 打开运行并对照修改。已有 3913 人学习下载,说明其在摄像头调用这一常见需求上具有较高的参考价值。工程把窗体界面、视频帧事件处理与资源释放逻辑分目录组织,读者可据此理解事件驱动采集的写法,并在此基础上扩展图片保存格式与异常提示。
1. 从一台被办公软件占着的摄像头说起
上周去现场调一个拍照存档的小工具,六台工位机里有两台一开机就报设备占用,第一反应是 USB 线松了,拆了两次机箱才发现,是我自己那版 demo 退出时没把采集对象释放干净。C# 调用 USB 摄像头,写出画面十分钟,写出能连续跑八小时、切分辨率不花屏、抓拍不丢帧、参数改完能存下来的模块,得踩不少坑。这份资源就是把这些坑压成一套能直接跑的项目:设备枚举、实时预览、定点抓拍、曝光白平衡这类参数调节,以及最容易被忽略的释放清理都在里面。做桌面端工控、门禁、检测台这类需要把摄像头接进 .NET 程序的场景都能用,新手照着能把第一个画面跑出来,熟手可以直接看参数协商和释放顺序那几段。
2. 四条调用路线怎么选:AForge、OpenCvSharp、MediaCapture 与 DirectShow 裸调
USB 摄像头在 Windows 上最终都落在同一套底层驱动模型上,但上层封装差得很远。选错路线不是写不出来,而是写到一半发现要调曝光、要切 MJPG、要拿原始帧做算法时,封装层不给你口子,只能推倒重来。这一章先把四条常见路线的账算清楚,再用一次真实探测把设备的能力集摸出来。
2.1 先算一笔账:四条路线各自的代价
我一般按三个维度挑:要不要做图像处理、要不要精细控制相机参数、项目生命周期有多长。只是预览加拍照存盘,AForge 那两个包足够;后面要接模板匹配、条码识别、尺寸测量,OpenCvSharp 更顺;新立项且目标框架能上到net8.0-windows10.0.19041,MediaCapture 的 API 设计最现代;遇到 SDK 都搞不定的工业相机,才轮到 DirectShow 裸调。
| 路线 | 依赖 | 上手成本 | 参数控制能力 | 维护状态 | 典型场景 |
|---|---|---|---|---|---|
| AForge.Video.DirectShow | 两个纯托管 NuGet 包 | 低 | 对 IAMCameraControl / IAMVideoProcAmp 做了封装,够用 | 多年未更新,仍可运行 | 中小型桌面工具、快速验证 |
| OpenCvSharp4 | 托管包 + 原生运行时包 | 中 | 走 CAP_PROP 系列,实际能力取决于后端 | 活跃 | 需要图像处理、算法链路 |
| MediaCapture | WinRT,靠目标框架引入 | 中高 | 属性丰富,异步模型 | 官方维护 | 新项目、UWP 或新式桌面 |
| DirectShowLib 裸调 | 互操作封装 | 高 | 最全,能拿到原始格式协商 | 需要自己兜底 | 特殊相机、封装层不够用时 |
有个容易被忽略的点:AForge 和 OpenCvSharp 在 Windows 上默认都走 DirectShow 或 Media Foundation,同一时刻同一台设备只能被一个进程以独占方式打开。所以路线可以混用,设备不能共享,这一点在第 5 章的占用问题里还会再提。
2.2 枚举设备与能力集:一次探测把底牌摸清
拿到项目第一步不是急着出画面,而是先写个探测程序,把每台设备支持的分辨率、帧率、位深全打出来。很多「切分辨率就崩」的问题,根源是代码里写死了一个相机根本不支持的分辨率组合。
using AForge.Video.DirectShow; using System; using System.Linq; // 枚举所有视频输入设备,MonikerString 是后续打开设备的唯一凭据 var devices = new FilterInfoCollection(FilterCategory.VideoInputDevice); if (devices.Count == 0) { Console.WriteLine("没有检测到视频输入设备"); return; } foreach (FilterInfo dev in devices) { Console.WriteLine($"[设备] {dev.Name} {dev.MonikerString}"); // 构造采集对象只为读取能力集,读完必须释放,否则这台设备就被占住了 using var probe = new VideoCaptureDevice(dev.MonikerString); foreach (var cap in probe.VideoCapabilities .OrderByDescending(c => c.FrameSize.Width * c.FrameSize.Height)) { Console.WriteLine($" {cap.FrameSize.Width}x{cap.FrameSize.Height} " + $"平均{cap.AverageFrameRate}fps 最高{cap.MaximumFrameRate}fps " + $"{cap.BitCount}bit"); } }这段的关键在using。VideoCaptureDevice实现了IDisposable,构造时就已经和底层滤镜图建立了关联,不释放的话下一次Start()会直接失败。我见过有人在枚举循环里 new 了十几个对象不释放,最后只有第一台设备能打开,后面全报错,查了一下午。
VideoCapabilities返回的是相机在驱动层声明的所有格式组合,注意它是「声明」不是「保证」——某些廉价模组会声明 2560x1440,实际跑起来只有 5 帧,这种虚标只能靠实测帧率验证,第 5 章会讲怎么测。
2.3 打开与关闭的正确顺序:Start 只是开始
打开设备的顺序是有讲究的:先挂事件、再设分辨率、最后 Start。反过来的话,第一帧可能在分辨率还没生效时就到了,你会看到预览窗口先闪一下小尺寸再跳变。
var source = new VideoCaptureDevice(devices[0].MonikerString); // 1. 先挑分辨率,挑不到就退回设备默认,别硬设 var target = source.VideoCapabilities .Where(c => c.FrameSize.Width <= 1920 && c.FrameSize.Height <= 1080) .Where(c => Math.Abs((double)c.FrameSize.Width / c.FrameSize.Height - 16.0 / 9) < 0.02) .OrderByDescending(c => c.FrameSize.Width * c.FrameSize.Height) .FirstOrDefault(); if (target != null) source.VideoResolution = target; // 必须在 Start 之前赋值 // 2. 挂事件,注意回调在采集线程,不是 UI 线程 source.NewFrame += Source_NewFrame; // 3. 启动 source.Start(); // 4. 关闭:先通知停止,再等线程退出,最后解事件 source.SignalToStop(); if (!source.WaitForStop(5000)) { // 等不到就强杀,否则进程退出时设备句柄可能残留 source.Stop(); } source.NewFrame -= Source_NewFrame; source.Dispose();WaitForStop那个 5000 毫秒不是随便写的。相机在低帧率模式下(比如曝光时间拉到 500ms),一帧要等半秒才回来,超时太短会在回调还没结束时就把对象释放掉,直接抛访问非法内存。我给的经验值是「最长曝光时间 × 3」,工业场景常设 3000 到 5000。
Stop()和SignalToStop()的区别也值得说一下:前者是立刻粗暴中断,后者是置一个标志位让采集线程自己退出。正常流程走后者,只有在超时兜底时才用前者。这套顺序写进一个CameraSession类里封装起来,后面所有业务代码都只调Open()/Close(),就不会有人漏掉解事件。
3. 预览渲染:把采集线程的帧安全地搬到界面上
预览看起来最简单,实际上翻车率最高。核心矛盾只有一个:帧在采集线程产生,控件只能在 UI 线程碰。中间这一跳怎么走,决定了程序是稳如老狗还是跑两小时就崩。
3.1 为什么 NewFrame 里不能直接碰控件
NewFrame事件的回调跑在 AForge 内部创建的采集线程上,这个线程跟 UI 线程没有任何关系。在里面直接写pictureBox1.Image = e.Frame,轻则抛跨线程异常,重则界面假死——因为 WinForms 的控件句柄不是线程安全的,两个线程同时操作窗口消息队列,行为完全不可预期。
还有一种更隐蔽的写法:在回调里调Control.CheckForIllegalCrossThreadCalls = false把检查关掉。这相当于把安全带剪了开车,调试期看着一切正常,上线后偶发花屏、偶发崩溃,日志里什么都没有,纯玄学。血泪经验是这条开关一次都别碰。
正确的做法是「采集线程只负责拷贝,UI 线程负责显示」。但直接每帧BeginInvoke也有问题:如果 UI 线程正忙(比如在写磁盘),消息会堆积,采集 30 帧积 30 条消息,恢复后界面像快进一样抖一阵。所以要加节流——上一次渲染没完成时,新帧直接丢掉。
3.2 WinForms:双缓冲 PictureBox 与帧复用
先看 WinForms 版本,这也是现场大多数工控机的实际形态。
private Bitmap? _buffer; private readonly object _bufLock = new(); private int _renderPending; private void Source_NewFrame(object sender, NewFrameEventArgs e) { // 采集线程里只做一件事:拷一份出来。e.Frame 是内部复用的位图,出了回调就被改写 lock (_bufLock) { _buffer?.Dispose(); // 上一帧没被取走就丢掉,绝不累积 _buffer = (Bitmap)e.Frame.Clone(); } // 节流:上一次渲染还没跑完,这一帧直接放弃,避免消息队列堆积 if (Interlocked.Exchange(ref _renderPending, 1) == 0 && IsHandleCreated) { BeginInvoke(new Action(RenderFrame)); } } private void RenderFrame() { Bitmap? frame; lock (_bufLock) { frame = _buffer; _buffer = null; // 所有权转移给 UI 线程 } if (frame != null) { var old = pictureBox1.Image; pictureBox1.Image = frame; old?.Dispose(); // 旧图必须还回去,否则 GDI 句柄一路涨 } Interlocked.Exchange(ref _renderPending, 0); }几个参数值得说明。_renderPending用Interlocked.Exchange而不是普通 bool,是因为采集线程只有一个但 UI 线程回写标志存在竞争,用原子操作最省心。pictureBox1.SizeMode建议设成Zoom,配合窗口等比缩放,设成StretchImage在非 16:9 的窗口里会把画面拉扁,标定过的像素尺寸全废。
old?.Dispose()这一行是内存问题的分水岭。PictureBox.Image赋值不会自动释放上一张图,每帧一张 1920x1080 的 24 位图是 6MB,30 帧下来每秒产生 180MB 垃圾,GC 根本追不上,任务管理器里内存曲线会一路爬坡。加上这一行,内存能压到平稳的几十兆。
3.3 WPF:WriteableBitmap 直写后台缓冲
WPF 没有 PictureBox,常见做法是Image控件配BitmapSource。如果每帧都BitmapSource.Create新建对象,等于每帧分配一次非托管内存,效果跟前面不释放一样糟。更合适的是WriteableBitmap——一次分配,之后每帧往它的后台缓冲里写像素。
using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Drawing.Imaging; private WriteableBitmap? _wb; private readonly object _wbLock = new(); private void Source_NewFrame(object sender, NewFrameEventArgs e) { using var frame = (Bitmap)e.Frame.Clone(); // 首帧或分辨率变化时重建,并回 UI 线程挂载到 Image 控件 if (_wb == null || _wb.PixelWidth != frame.Width || _wb.PixelHeight != frame.Height) { var wb = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgr24, null); lock (_wbLock) { _wb = wb; } Dispatcher.BeginInvoke(new Action(() => PreviewImage.Source = wb)); } var target = _wb!; var src = frame.LockBits(new Rectangle(0, 0, frame.Width, frame.Height), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); try { target.Lock(); unsafe { byte* dst = (byte*)target.BackBuffer; byte* s = (byte*)src.Scan0; int rowBytes = frame.Width * 3; // 逐行拷贝,不能整块 memcpy:Bitmap 的 Stride 会按 4 字节对齐, // 而 WriteableBitmap 的 BackBufferStride 是按自身规则算的,两者不一定相等 for (int y = 0; y < frame.Height; y++) { Buffer.MemoryCopy(s + (long)y * src.Stride, dst + (long)y * target.BackBufferStride, target.BackBufferStride, rowBytes); } } target.AddDirtyRect(new Int32Rect(0, 0, frame.Width, frame.Height)); } finally { target.Unlock(); frame.UnlockBits(src); } }AllowUnsafeBlocks需要在 csproj 里打开,这是这段代码唯一的前置条件。逐行拷贝那几行注释是这条路线最容易翻车的地方——网上很多示例图省事用整块Marshal.Copy,宽度不是 4 的倍数时(比如 1366x768)就会出现画面斜切错位,一行错、行行错,看起来像信号干扰,其实是步长对不上。
另外WriteableBitmap必须在 UI 线程创建,所以这里是「采集线程里 new、用 Dispatcher 挂载」。严格说 new 本身也应该在 UI 线程做,我为了少一次跨线程调用才这么写,实际项目里如果遇到InvalidOperationException,把整个重建逻辑包进Dispatcher.Invoke就好,重建频率很低,不心疼这点开销。
4. 拍照与参数控制:分辨率、曝光、白平衡落到代码
预览只是看,业务真正要的是那一下子按下去的抓拍,以及不同光照环境下能调得动的曝光和白平衡。这一章把抓拍的所有权问题和参数调节的时序问题讲透。
4.1 抓拍:拷贝一份再交出去
抓拍最简单的写法是在按钮事件里直接读当前帧,但读不到——UI 线程拿不到采集线程的e.Frame。正确做法是在采集回调里存一份克隆,按钮触发时取走。
private Bitmap? _pendingShot; private readonly object _shotLock = new(); // 采集回调里:始终保留最新一帧作为「待抓拍候选」 private void Source_NewFrame(object sender, NewFrameEventArgs e) { lock (_shotLock) { _pendingShot?.Dispose(); _pendingShot = (Bitmap)e.Frame.Clone(); } // 预览逻辑见上一章 } // 按钮点击时调用 public Bitmap? Snapshot() { lock (_shotLock) { var shot = _pendingShot; _pendingShot = null; // 所有权转移,调用方负责 Dispose return shot; } } // 存盘:显式指定 JPEG 质量,别用默认的 75 public void SaveJpeg(Bitmap bmp, string path, long quality = 92) { var codec = ImageCodecInfo.GetImageEncoders() .First(c => c.FormatID == ImageFormat.Jpeg.Guid); using var ps = new EncoderParameters(1); ps.Param[0] = new EncoderParameter(Encoder.Quality, quality); using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 64 * 1024); bmp.Save(fs, codec, ps); }SaveJpeg里两个参数要留意。quality我一般给 90 到 95,低于 85 在文字和边缘处会出现明显振铃,做 OCR 或尺寸测量的场景直接受影响。FileShare.None是防止两个抓拍任务同时写同一个文件,工控机上的操作员连点两下按钮太常见了,不加这个会抛文件占用异常。
还有一个细节:EncoderParameter的第二个参数必须是long类型,传int会静默失效,存出来的图一直是默认质量。这个问题网上问的人不少,属于典型的「代码没错、结果不对」。
如果是连续抓拍(比如每 200ms 一张做运动检测),上面这种「回调里一直克隆」的方式就浪费了,一张 1080p 图克隆加释放每秒五次,CPU 会明显吃紧。这种情况改成生产者消费者队列:回调里TryAdd到一个BlockingCollection<Bitmap>,限定容量为 3,满了就丢最旧的,后台线程慢慢消费。队列容量一定要限,不限制就是内存泄漏的另一种写法。
4.2 分辨率与像素格式:带宽账要先算
分辨率不是想设多高就设多高,背后是 USB 带宽的硬约束。一张 1920x1080 的 YUY2 未压缩帧是 1920×1080×2 ≈ 4.1MB,30 帧就是 124MB/s。USB 2.0 的理论上限 480Mbps 换算下来约 60MB/s,实际能稳定跑的也就 40MB/s 左右——所以 1080p30 在 USB 2.0 上跑 YUY2 是不可能的,必须走 MJPG 压缩。
这也是为什么同一台相机,用不同软件打开帧率差很多:有的软件自动协商了 MJPG,有的老老实实走 YUY2 然后掉到 10 帧。AForge 的封装层对像素格式控制很弱,通常由驱动自己挑,遇到帧率上不去的情况,换 OpenCvSharp 显式指定 FourCC 更直接。
using OpenCvSharp; // 后端必须显式指定 DSHOW 或 MSMF,默认的 CAP_ANY 可能挑到不合适的后端 using var cap = new VideoCapture(0, VideoCaptureAPIs.DSHOW); if (!cap.IsOpened()) { Console.WriteLine("打开失败,检查设备索引或占用情况"); return; } // 'MJPG' 的整型值,显式设置才能在 USB 2.0 上跑到 1080p30 cap.Set(VideoCaptureProperties.FourCC, (double)FourCC.FromString("MJPG").ToInt32()); cap.Set(VideoCaptureProperties.FrameWidth, 1920); cap.Set(VideoCaptureProperties.FrameHeight, 1080); cap.Set(VideoCaptureProperties.Fps, 30); // 设完必须回读验证,驱动可能悄悄降级成它能支持的最接近值 var realW = cap.Get(VideoCaptureProperties.FrameWidth); var realH = cap.Get(VideoCaptureProperties.FrameHeight); var realFps = cap.Get(VideoCaptureProperties.Fps); Console.WriteLine($"实际生效: {realW}x{realH} @ {realFps}fps");回读验证这一步千万别省。我遇到过一台模组,设 1920x1080 它接受,回读是 1920x1080,但实际出图是 1280x720 拉伸上来的,边缘一圈黑边。这种只能靠肉眼比对画面和标定板确认,代码层面查不出来。
另外VideoCaptureAPIs.DSHOW和MSMF的选择也不只是风格问题。MSMF 对某些 UVC 相机的 MJPG 支持有 bug,会返回空帧;DSHOW 兼容性好但启动慢一点。我的习惯是默认 DSHOW,只有在需要低延迟或新式相机时才试 MSMF。
4.3 曝光、白平衡与增益:Auto 与 Manual 的切换顺序
参数调节的坑主要在时序。直接设曝光值而不先关自动曝光,驱动会把你的值当成一次性的建议值,下一帧自动曝光算法又改回去了,表现出来的现象是「设了没用」。
AForge 对 IAMCameraControl 和 IAMVideoProcAmp 做了封装,用起来还算顺手:
// 先读当前状态,拿到合法范围和当前 flags source.GetCameraProperty(CameraControlProperty.Exposure, out int curExposure, out CameraControlFlags curFlags); // 关键一步:先切成手动,再设值。顺序反了会被自动算法覆盖 source.SetCameraProperty(CameraControlProperty.Exposure, curExposure, CameraControlFlags.Manual); source.SetCameraProperty(CameraControlProperty.Exposure, -6, CameraControlFlags.Manual); // 白平衡属于 VideoProcAmp 那一组,接口不一样 source.GetVideoProperty(VideoProcAmpProperty.WhiteBalance, out int curWb, out VideoProcAmpFlags wbFlags); source.SetVideoProperty(VideoProcAmpProperty.WhiteBalance, 4500, VideoProcAmpFlags.Manual);曝光值的单位不是毫秒,而是一个对数刻度,不同相机范围差异很大(常见是 -13 到 -1,或者 0 到 -11)。所以代码里不能硬编码绝对值,得先读回范围。AForge 没直接暴露范围,要么用IAMCameraControl.GetRange自己 P/Invoke,要么就做成配置项让现场调试人员自己拖滑块试,后者更省事,我一般选后者。
白平衡对颜色一致性要求高的场景必须锁死。自动白平衡在画面里出现大面积单色物体时会把整体色调带偏,同一批产品拍出来颜色不一样,事后做色差检测全废。做法是先用标准灰卡在目标光源下自动白平衡一次,读到数值后写进配置,运行时用手动模式设回去。
增益(Gain)和亮度(Brightness)的区别也值得说一句:增益是模拟信号放大,提亮的同时噪声一起放大;亮度是数字域偏移,提亮但不增加噪声。暗光环境下优先动亮度,实在不够再动增益——这个顺序调反了,画面会噪点密布,看起来像相机坏了。
5. 避坑与排查:设备占用、花屏、帧率虚标的五次翻车
前面讲的都是顺利路径,这一章专门记录实际项目里撞过的坑。每一条都是「现象 → 原因 → 解决」的结构,对号入座比看文档快。
5.1 一打开就报设备被占用
现象:source.Start()抛异常,提示无法启动设备,或者枚举出来的设备列表里明明有相机,打开就是失败。重启电脑能好,过一会儿又复现。
原因:USB 摄像头在 Windows 上是独占设备,任何一个进程打开后,其他进程就拿不到。最常见的占用者是后台常驻的会议软件、聊天工具、浏览器里开着的视频会议页面,它们会静默持有相机句柄。另一个隐形原因是自己的程序异常退出,采集线程没走完释放流程,句柄留在系统里。
解决:第一步,把Open/Close封装成必须成对调用的形式,用try/finally保证异常路径也走释放。第二步,在AppDomain.CurrentDomain.ProcessExit和主窗口FormClosing里都挂一次兜底释放。第三步才是排查外部占用——任务管理器看不出谁占着相机,得用设备管理器看「图像设备」下有没有黄色感叹号,或者直接关掉可疑的后台程序逐个试。这个排查过程确实有点黑匣子,但把释放顺序写对之后,八成以上的「被占用」都不会再出现。
5.2 高分辨率下花屏、绿条、上下撕裂
现象:720p 一切正常,切到 1080p 后画面下半部分变成绿色或者横向条纹,有时候是整屏斜切错位。
原因:三种可能。一是像素格式没协商成 MJPG,驱动降级到 YUY2 但带宽不够,数据丢包;二是预览渲染时步长处理错了,宽度不是 4 的倍数时整块拷贝会导致行错位;三是 AForge 内部缓冲复用,回调外还在读e.Frame,读到一半被下一帧覆盖。
解决:先确认是不是格式问题——用 OpenCvSharp 显式设 MJPG 再跑一次,如果好了就是带宽。如果是斜切错位,回到 3.3 节的逐行拷贝,检查src.Stride和BackBufferStride是否被你当成了同一个值。如果是画面局部撕裂,检查所有对e.Frame的引用是不是都在Clone()之后。这三条按顺序排查,基本能覆盖全部情况。
5.3 标称 30 帧实际只有 5 帧
现象:相机规格写 1080p30,能力集里也声明了 30fps,实际测下来只有 5 到 8 帧,画面明显卡顿。
原因:能力集里声明的帧率是「在理想带宽下」,实际受三件事影响:USB 接口版本(插在 USB 2.0 口上跑 1080p,压缩格式不对就必然掉帧)、曝光时间(低照度下自动曝光把曝光时间拉到 200ms,物理上限就是 5 帧)、还有其他 USB 设备抢带宽(同一控制器上挂着 U 盘、采集卡)。
解决:先算带宽账,1080p30 走 YUY2 需要 124MB/s,USB 2.0 给不了,必须 MJPG;再查曝光,把自动曝光关掉手动设一个短曝光值,看帧率是否回升,回升了就说明是低照度拖累;最后把相机插到独立的 USB 控制器上,别和移动硬盘共用。还有一个容易忽略的:某些延长线是 USB 2.0 的,接在 USB 3.0 口上也只跑 2.0 速率,线材问题占掉帧故障的相当一部分。
5.4 跑两小时内存涨到 2G
现象:启动时内存 80MB,跑一下午涨到 2GB 以上,最后 OutOfMemory 崩掉。用性能监视器看,私有字节一直在涨,GC 回收后也降不下来。
原因:几乎全是Bitmap没释放。常见三处:PictureBox.Image赋值后旧图没 Dispose;采集回调里Clone()出来的图在被取走前就被覆盖,覆盖时忘了释放;NewFrame事件在关闭窗口时没解绑,采集对象还活着,一直在往一个已经销毁的窗体上投帧。
解决:把每一处Bitmap的产生和释放都配成对,Clone()后面必然跟一个Dispose(),所有权转移的地方加注释说明谁负责释放。事件解绑放在Close()里,和SignalToStop挨着写,形成肌肉记忆。验证方法很简单:跑一晚上,第二天看任务管理器的私有字节曲线,平的就是对的,缓慢上升就还有漏点。
5.5 最小化再恢复画面卡死
现象:程序最小化几分钟后还原,预览区一片黑或者定格在最后一帧,按钮还能点,就是不刷新。任务管理器里 CPU 占用为 0。
原因:BeginInvoke在窗口最小化期间投递的消息被系统挂起,节流标志_renderPending被置成 1 之后再也没有重置——因为对应的RenderFrame没执行。恢复窗口后,所有新帧看到标志位是 1,全部丢弃,画面自然不动了。
解决:在窗口Resize或StateChanged事件里检测到从最小化恢复时,强制把_renderPending置 0,并主动投递一次渲染。更稳妥的做法是给节流加超时兜底:投递时记一个时间戳,超过 500ms 还没被消费就允许再次投递。这个坑很隐蔽,因为只有最小化这个特定操作才会触发,正常调试根本发现不了,属于典型的现场才炸的问题。
6. 进阶:多路并发与 24 小时稳定性验证
单路跑通只是及格线,真上产线往往要两路甚至四路同时采,还要经得起连续开机不重启。这一章讲两个我每次交付前必做的动作。
6.1 多摄像头并发:一路一线程与带宽预算
多路的核心约束还是带宽。四路 1080p30 走 MJPG,压缩后每路大约 8 到 15MB/s,加起来 60MB/s,已经逼近 USB 3.0 控制器的实际上限;如果四台相机全插在同一个控制器(也就是同一组相邻的 USB 口)上,必然互相拖累。我的做法是物理上分散到不同控制器,代码上每路独立一个VideoCaptureDevice实例和一个消费线程,互不共享任何缓冲。
public sealed class CameraWorker : IDisposable { private readonly VideoCaptureDevice _device; private readonly BlockingCollection<Bitmap> _queue = new(3); // 容量 3,满则丢 private readonly CancellationTokenSource _cts = new(); private Task? _consumer; public CameraWorker(string moniker) { _device = new VideoCaptureDevice(moniker); _device.NewFrame += OnFrame; } public void Start() { // 每路独立消费线程,写盘和算法处理都在这个线程里,不占 UI _consumer = Task.Run(ConsumeLoop); _device.Start(); } private void OnFrame(object sender, NewFrameEventArgs e) { var copy = (Bitmap)e.Frame.Clone(); // TryAdd 不阻塞,队列满了直接丢弃并释放,绝不让采集线程等 if (!_queue.TryAdd(copy)) copy.Dispose(); } private void ConsumeLoop() { foreach (var bmp in _queue.GetConsumingEnumerable(_cts.Token)) { using (bmp) { // 业务处理:存盘、推理、比对,随便什么 } } } public void Dispose() { _device.SignalToStop(); _device.WaitForStop(8000); _device.NewFrame -= OnFrame; _device.Dispose(); _queue.CompleteAdding(); _cts.Cancel(); _consumer?.Wait(2000); _queue.Dispose(); } }BlockingCollection容量设 3 是权衡结果:1 太容易丢帧,5 以上在 1080p 下就是三十多兆的内存常驻。TryAdd而非Add是关键,Add在队列满时会阻塞采集线程,采集线程一阻塞,驱动层的缓冲很快溢出,表现为整路画面卡住而不是丢几帧——后者可以接受,前者不可接受。
6.2 三个指标跑一晚上:帧率、内存、句柄
功能跑通不等于能交付。我交付前的固定动作是挂一晚上,采三个指标:实际帧率、进程私有字节、GDI 句柄数。前两个看资源泄漏,第三个看 GDI 对象有没有漏释放。
// 每 10 秒采样一次,写 CSV,第二天直接拉曲线看 private async Task MonitorAsync(CancellationToken token) { var proc = Process.GetCurrentProcess(); using var writer = new StreamWriter("stability.csv", append: true); writer.WriteLine("time,fps,privateMB,gdiHandles"); long lastFrames = 0; var lastTime = DateTime.Now; while (!token.IsCancellationRequested) { await Task.Delay(10_000, token); var now = DateTime.Now; long frames = Interlocked.Read(ref _frameCounter); // 采集回调里递增 double fps = (frames - lastFrames) / (now - lastTime).TotalSeconds; proc.Refresh(); writer.WriteLine($"{now:HH:mm:ss},{fps:F1}," + $"{proc.PrivateMemorySize64 / 1024 / 1024}," + $"{proc.HandleCount}"); writer.Flush(); lastFrames = frames; lastTime = now; } }判读标准很明确:帧率曲线应该在目标值上下小幅波动,如果每隔几分钟出现一个掉到 0 的谷底,说明有阻塞;私有字节在跑了两小时后应该基本持平,缓慢上升说明还有Bitmap或事件没释放;GDI 句柄数稳定在几百以内,如果持续上涨,回去查Graphics对象和Font、Pen这些有没有using。
那次现场事故之后,我每接一个新相机型号,都先跑一晚上这套指标采集,看曲线平不平再谈功能。软件这东西,白天看着好好的,出问题的永远是凌晨三点的那一次崩溃。希望帮到你。
本文还有配套的精品资源,点击获取