简介基于C#的视频监控上位机程序面向需要快速搭建桌面端监控展示层的开发者与学习者。程序源码完整下载后无需额外开发即可编译运行连接摄像头便能自动识别并显示实时监控画面适合安防监控、远程值守及智能分析等场景的入门实践。实现上基于.NET框架底层可选用Windows自带的Media Foundation完成视频流捕获、编码与硬件加速也可引入AForge.NET开源库实现滤波、边缘检测、颜色空间转换等图像处理操作若需远程传输还可结合System.Net相关类或第三方库扩展HTTP、RTSP、WebRTC等协议支持覆盖多媒体处理、图像分析与网络通信多个技术环节。压缩包大小约5.28MB上游未提供文件清单因此具体文件总数与类型暂无法确认。已有313人学习下载对于希望弄清C#视频采集链路、上位机界面组织以及摄像头实时预览实现思路的读者是一份轻量且便于直接对照调试的参考代码包。1. C#视频监控一条从摄像头到画面的链路四个环节都断不得C#视频监控的第一反应多半是“打开摄像头摆一个预览窗口”。但真正联调一次就会发现设备枚举、帧回调、渲染、编码录像这四个环节里任何一个出问题最后的界面都是黑屏或者录像文件打不开。这篇内容围绕一份完整的C#视频监控Demo展开把四个环节的选型、代码和调试经验一次讲透按最容易翻车的顺序走先选型再打通链路最后把血泪坑单列出来。适合刚接触.NET视频开发的同事也适合马上要接手监控维护项目的新人照着顺序把代码敲一遍半小时就能让第一个摄像头出画面。2. 技术选型DirectShow、Media Foundation、OpenCV的C#封装怎么搭才不会返工2.1 三条采集路线的边界条件做C#视频采集第一件事不是写代码而是先确定走哪条采集通道。常见方案分三类第一类是DirectShow老牌方案在.NET里通常用AForge.Video.DirectShow接口简单、设备兼容性覆盖面广尤其工控现场的老USB摄像头、VGA采集卡很多驱动至今只认DirectShow系统相机App能出图它基本都能枚举到。第二类是Media Foundation从Vista之后被主推的替代方案API更现代但第三方封装良莠不齐而且部分老摄像头驱动没有提供MFT入口枚举结果比DirectShow少一截。第三类是OpenCV系比如OpenCvSharp里封装的VideoCapture写起来最短但OpenCV在Windows上默认还是走DirectShow后端设备参数暴露不完全真要调曝光、调增益还得绕回DirectShow层去处理。以Windows 10/11上的USB摄像头监控项目为例我一般把DirectShow作为采集主通道原因很直接工业现场摄像头和老设备的驱动更新频率低DirectShow是兼容性最好的交集。Media Foundation适合确定现场没有老设备、并且在纯.NET 6环境下做视频处理的场景。OpenCvSharp则适合把监控和图像识别放在一起的项目比如要做轮廓检测、模板匹配直接用Mat结构会比AForge的Bitmap更顺手。还有一个容易忽略的细节摄像头输出的像素格式并不统一常见的有YUY2、MJPG、RGB24、NV12。DirectShow枚举的媒体类型里分辨率和色彩空间是绑定的如果你用OpenCV默认配置去打开拿到MJPG解压后又不做格式协商处理出来的画面就是偏色的。相比之下AForge里通过VideoCapabilities能看到分辨率、帧率和BitCount选型过程更透明。这也是我把AForge作为采集层、OpenCV作为处理层的直接原因——各管一段职责清晰。2.2 环境准备目标框架、NuGet包与运行库搭配我建议的典型组合是.NET Framework 4.7.2或.NET 6以上搭配AForge.Video 2.2.5、AForge.Video.DirectShow 2.2.5、OpenCvSharp4.Windows 4.8.0录制端另接FFmpeg子进程。这个组合的核心思想是“采集”和“编码”分离AForge负责拿到Bitmap帧OpenCvSharp负责格式转换和图像处理FFmpeg负责压缩落盘。为什么不用AForge自带的AVI编码因为AForge.Video.FFMPEG只有32位版本文件写到4GB以上会出各种怪问题而且对H.264的支持很受限。单独拉起一个FFmpeg进程用管道喂原始帧能绕开所有位数和容器限制这也是我后来稳定使用的办法。在工程配置层面有两点需要提前统一。第一CPU架构优先选x64除非你的采集卡SDK只提供32位DLL第二如果用了OpenCvSharp4.Windows它会把native dll自动带到输出目录调试很省事但发布时注意不要把多余的opencl组件和转换模块一起塞进安装包。这里给一个环境检查清单先确认摄像头能在系统自带相机App里出图再确认设备管理器里能看到对应驱动节点最后才在IDE里跑枚举。三步检查可以排除掉八成“代码没问题但就是黑屏”的情况。NuGet包的安装命令很简单但版本锁定很关键Install-Package AForge.Video.DirectShow -Version 2.2.5 Install-Package OpenCvSharp4.Windows -Version 4.8.0.20230708 Install-Package System.Drawing.Common -Version 8.0.0这里有个版本陷阱AForge包停留在2.2.5已经不再更新和现代.NET的兼容性靠的是自身代码足够简单。OpenCvSharp4.Windows需要固定一个具体版本号因为不同小版本之间的native dll文件名不同升级时如果不清干净bin目录经常出现“加载OpenCvSharp native库失败”的异常。System.Drawing.Common在.NET 6之后被标记为仅支持Windows如果项目是Windows Forms安装这个包没有副作用。关于目标框架如果你不需要旧控件的兼容性我建议直接选.NET 6/8的Windows窗体模板。它在高DPI下的表现比.NET Framework好很多同样一个PictureBox在1920x1080的125%缩放下Framework版本经常出现画面边缘模糊和异常裁剪.NET 6的窗体做了缩放处理观感明显更干净。如果项目受制于老系统只能在Framework上跑那务必把AutoScaleMode设为Dpi并在96dpi环境下做窗口布局否则拿到高分屏上字号和控件位置会散掉。2.3 采集参数到底怎么定分辨率、帧率与像素格式的匹配我把采集参数定义为三个变量分辨率、帧率、像素格式。这三个值不是独立选的由摄像头的输出能力和DirectShow的VideoCapabilities共同决定。以常见1080P摄像头为例它可能同时支持1920x108030fpsMJPG、1920x10805fpsYUY2、1280x72060fpsYUY2。如果你直接按默认启动往往拿到的是YUY2下的低帧率模式CPU还要额外做YCbCr到RGB的转换画面流畅度反而更差。在AForge里枚举VideoCapabilities之后一般这样配置// 得到当前设备支持的所有能力组合 var caps videoCaptureDevice.VideoCapabilities; foreach (var cap in caps) { Console.WriteLine(${cap.FrameSize.Width}x{cap.FrameSize.Height}, ${cap.AverageFrameRate}fps, {cap.BitCount}bit); } // 选择最接近1080P的一项AForge中的BitCount对应转换后的RGB位数 videoCaptureDevice.VideoResolution caps .Where(c c.FrameSize.Width 1920 c.FrameSize.Height 1080) .FirstOrDefault(c c.BitCount 24);这段代码的逻辑是先看设备支持哪些组合再按分辨率加BitCount双重筛选。重点说明一下BitCount它表示DirectShow转换成Bitmap后的RGB位数而不是摄像头输出的压缩格式。如果某项BitCount为16说明该模式下驱动只给出16位RGB色彩精度会打折我一般只选BitCount等于24的项省掉后续颜色转换这一层。选定之后Start()启动画面才能稳定在目标分辨率和帧率。关于MJPG和YUY2的选择MJPG格式的摄像头在输出时直接把JPG帧给到USBCPU不需要做压缩运算占用率低但每帧数据量大。YUY2格式是裸流数据量翻倍但色彩还原更好。监控项目我通常优先选MJPG30fps因为长时间运行更省电而且录像本身还要再过一遍编码器源是MJPG还是YUY2对最终画质影响不大。只有做精密测量时才会切到RGB24或YUY2避免JPG有损压缩产生的噪点干扰后续算法。还有一个容易被忽略的参数是AForge的DesiredFrameIntervalvideoCaptureDevice.DesiredFrameInterval 100; // 单位毫秒它控制的是采集器主动丢弃多余帧的时间间隔100ms即每秒钟最多向回调递交10帧。在多人并发预览时能显著降低CPU占用但不影响摄像头本身的采集频率。注意这个属性在Start之后设置部分驱动会忽略它实际帧率要以NewFrame回调触发的间隔为准。我为此写过一个简单的统计工具在回调里每秒数一下触发次数超过阈值就打印警告。用过后你才会发现标称30fps的设备往往实际只有25fps这属于驱动节流不是代码问题。3. 链路打通从设备枚举到录像落盘WinForm监控主流程全代码3.1 设备枚举不要写死索引用MonikerString定位枚举是所有监控程序的第一步。AForge的写法很固定using AForge.Video.DirectShow; private Liststring _deviceMonikers new Liststring(); private void LoadDeviceList() { var devices new FilterInfoCollection(FilterCategory.VideoInputDevice); if (devices.Count 0) { MessageBox.Show(没有枚举到摄像头请检查驱动和连接。); return; } for (int i 0; i devices.Count; i) { var item devices[i]; comboBoxDevices.Items.Add(item.Name); _deviceMonikers.Add(item.MonikerString); } }逻辑说明FilterCategory.VideoInputDevice指向系统注册表里的“视频输入设备”分类Windows把所有可用DirectShow过滤器注册在这里。MonikerString是设备的唯一标识形如device:sw:{xxxx-xxxx}...即使插拔USB口或更换摄像头这个字符串通常不变。如果按索引0、1来选择一旦设备接入顺序变化录的就是另一路画面。参数细节部分摄像头在系统里会枚举出两个节点一个是视频流一个是音频流或UVC控制节点。FilterInfoCollection默认只列主分类但有厂商驱动会把控制节点也注册成输入设备。出现这种情况要在代码里过滤名称包含Control或Audio的项或者先把设备列表导出到文本对比后再处理。注意枚举完成后_deviceMonikers的顺序和comboBox的下标必须一一对应否则选A设备启动B设备这属于低级错误但我在项目里见过不少次。3.2 实时预览NewFrame回调与PictureBox的跨线程绑定枚举只是第一步真正写预览时核心是处理采集线程和UI线程的关系。看这段代码private VideoCaptureDevice _capture; private void StartPreview() { if (comboBoxDevices.SelectedIndex 0) return; var selectedMoniker _deviceMonikers[comboBoxDevices.SelectedIndex]; _capture new VideoCaptureDevice(selectedMoniker); _capture.NewFrame OnFrame; _capture.Start(); } private void OnFrame(object sender, NewFrameEventArgs e) { var frame (Bitmap)e.Frame.Clone(); if (pictureBoxPreview.InvokeRequired) { pictureBoxPreview.BeginInvoke(new ActionBitmap(UpdatePreview), frame); return; } UpdatePreview(frame); } private void UpdatePreview(Bitmap bmp) { var old pictureBoxPreview.Image; pictureBoxPreview.Image bmp; if (old ! null) old.Dispose(); }这段代码的关键在于NewFrame事件工作在采集线程不能直接写UI控件。InvokeRequired用来判断是否需要跨线程切回UI线程BeginInvoke以异步方式把帧交还给主线程避免UI卡顿。Clone()是必须的——事件返回的Bitmap是采集器内部缓冲如果不复制下一帧到来时前一帧内存会被覆盖最终看到的是跳帧花屏。参数的取舍Clone出来的是独立Bitmap提交给UI线程后采集线程可以立刻处理下一帧两者互不干扰。UpdatePreview里先取旧图引用再把新图赋给PictureBox最后释放旧图这个顺序不能反。很多人直接在这个回调里把事件里的Frame拿来Dispose结果程序崩溃这就是引用计数没处理好。规范做法就是回调里Clone渲染后由UI线程释放旧帧这套模式能保证预览长时间运行不涨内存。如果你需要限制预览帧率可以在回调开头做时间戳判断private long _lastPreviewTick; private void OnFrame(object sender, NewFrameEventArgs e) { long tick Environment.TickCount64; if (tick - _lastPreviewTick 33) return; // 33ms以上才处理约30fps _lastPreviewTick tick; // 其余逻辑同上 }这里用33ms作为阈值是因为30fps对应的帧间隔约33.3ms。如果你用24fps阈值应该是42ms。相比DesiredFrameInterval这个时间戳方式更可控因为后者在某些驱动里有“跳过事件但回调仍触发”的毛病。我之前排查一个系统卡顿问题折腾两天最后发现是DesiredFrameInterval失效导致每帧都被处理CPU跑满。3.3 抓帧保存把当前帧写到磁盘别忘记拍照瞬间的锁抓帧不能在UI线程随便截取PictureBox.Image因为此刻采集线程可能正写入同一块内存。安全做法是让预览环节统一维护一份“最后一帧快照”UI只操作快照副本。我常用的组合是lock加Cloneprivate readonly object _frameLock new object(); private Bitmap _lastFrame; private void OnFrame(object sender, NewFrameEventArgs e) { var frame (Bitmap)e.Frame.Clone(); lock (_frameLock) { if (_lastFrame ! null) _lastFrame.Dispose(); _lastFrame frame; } } private void SaveSnapshotButton_Click(object sender, EventArgs e) { Bitmap snapshot; lock (_frameLock) { if (_lastFrame null) return; snapshot (Bitmap)_lastFrame.Clone(); } string path D:\capture\snap_ DateTime.Now.ToString(yyyyMMdd_HHmmss_fff) .jpg; snapshot.Save(path, ImageFormat.Jpeg); snapshot.Dispose(); }这段代码的要点_lastFrame始终是最新的一帧拍照按钮通过lock获取它的副本保证抓图瞬间不会和实时预览抢同一块资源。文件名里加了毫秒避免同一秒内多次点击覆盖。抓图之后的JPG编码是GDI内置实现默认质量约90%。如果你想控制体积可以用ImageCodecInfo的Encoder.Quality参数手动设到70%体积能缩一半监控场景下的文字清晰度几乎不受影响。多路监控场景下建议把快照文件名按设备ID分目录并且定期清理超过N天的文件否则一年下来10路摄像头能占掉几十GB。我做过的监控项目里最大的教训就是硬盘被抓图填满后系统直接卡死后来每次上线都会强制检查磁盘剩余空间并在程序里加自动清理。3.4 录像落盘用FFmpeg子进程吃帧绕开三十二位AVI编码限制录像和抓帧最大的区别是抓帧是偶发操作录像必须连续处理每帧。如果直接把编码逻辑塞进NewFrame回调稍慢一点的编码器会直接拖垮采集线程。我的方案是把每帧Bitmap转成像素字节流写入FFmpeg进程的StandardInput编码压缩完全交给FFmpeg采集线程只做内存拷贝。private Process _encoder; private Stream _inputStream; private void StartRecord(string outputPath, int fps, int width, int height) { _encoder new Process(); _encoder.StartInfo.FileName ffmpeg.exe; _encoder.StartInfo.UseShellExecute false; _encoder.StartInfo.RedirectStandardInput true; _encoder.StartInfo.CreateNoWindow true; _encoder.StartInfo.Arguments $-f rawvideo -pix_fmt bgr24 -s {width}x{height} -r {fps} $-i pipe:0 -c:v libx264 -pix_fmt yuv420p -crf 23 -preset fast $\{outputPath}\; _encoder.Start(); _inputStream _encoder.StandardInput.BaseStream; } private void WriteFrame(Bitmap bmp) { var rect new Rectangle(0, 0, bmp.Width, bmp.Height); var bmpData bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); try { byte[] buffer new byte[bmpData.Stride * bmpData.Height]; Marshal.Copy(bmpData.Scan0, buffer, 0, buffer.Length); _inputStream.Write(buffer, 0, buffer.Length); } finally { bmp.UnlockBits(bmpData); } }逻辑说明-f rawvideo告诉FFmpeg输入是无压缩RGB帧-pix_fmt bgr24对应C#里LockBits的Format24bppRgb注意内存顺序是BGR而非RGB。-s参数必须和真实帧宽度高度一致分辨率写错会直接导致输出文件花屏或无法播放。-i pipe:0表示从标准输入读取原始帧编码器收齐一帧后按-r指定的帧率封装时间戳。参数取舍crf 23是H.264默认质量档越小画质越好、体积越大。视频监控画面相对静态我会开到crf 26来压体积。preset fast表示编码速度档慢档压缩效率高但会拖累实时性监控不需要每帧都极限处理fast够用。输出格式这里以mp4为例如果要兼容老播放器可以换AVI容器但编码要改成mpeg4因为老播放器不认mp4里的h264。注意FFmpeg的路径建议用全路径或放到程序同目录不要依赖PATH环境变量。在有杀毒软件的环境里StandardInput管道可能被拦截导致录制文件长度为0验证时先录一个1秒小片段试水。录制停止时先关闭输入流再等待进程退出顺序不能反private void StopRecord() { _inputStream.Close(); _encoder.WaitForExit(5000); _encoder.Close(); }如果先杀进程最后一段帧数据会丢失文件尾部损坏。WaitForExit超时后要再检查一次进程是否退出有时FFmpeg在写回关键帧时会卡一下多等两秒再强制Kill更稳妥。4. 避坑手册C#视频监控最常见的五个翻车现场4.1 现象预览窗口黑屏摄像头指示灯却在亮原因摄像头驱动正常系统里也能列出设备但实际启动的是错误的分辨率或像素格式组合。比如设备只支持1920x1080的MJPG而你强制设成YUY2驱动在底层不会报错返回的帧数据却是空的于是表现为黑屏。解决在Start之前遍历VideoCapabilities打印所有可用组合选择与目标最接近且BitCount24的项。我习惯写一个自动匹配函数优先按目标分辨率匹配其次按BitCount匹配两者都找不到就取第一个可用项。另外还要检查设备是否被其他App占用——Windows相机应用和浏览器标签页同时占用摄像头时Start不会抛异常但回调永远不会触发这也是“指示灯亮但黑屏”的另一个常见原因。4.2 现象枚举列表正常但一点Start就崩溃原因最常见的是设备在枚举后被拔掉创建DirectShow过滤器时返回无效对象还有一个典型坑是开发环境是x86摄像头驱动只提供x64版本启动时直接抛“类未注册”异常。解决选择设备后做二次校验尝试创建过滤器并调用GetName失败就从列表移除。架构问题优先改用x64。注意很多国产UVC摄像头驱动在x86下是正常的反而是采集卡SDK只有32位DLL所以判断要具体到项目不要一刀切。4.3 现象长时间运行后内存持续上涨几小时后画面逐渐花屏原因内存泄漏几乎都是预览或快照里的Bitmap没有Dispose。在3.2中已经强调Clone与Dispose的配合但实际大部分人会在NewFrame回调里反复赋值new出来的Bitmap几小时后GDI句柄耗尽系统开始花屏。解决在回调里用GC.GetTotalMemory(false)把内存值打到日志并配合任务管理器观察GDI对象数量。一旦发现每秒增长优先检查三处PictureBox.Image是否每次都释放旧值、事件里的Frame是否每次Clone后才使用、抓图按钮里是否创建了未释放的MemoryStream。建议把这类检查写进自动化测试模拟运行6小时内存波动不超过100MB才放行。4.4 现象录制生成的MP4无法播放或只有声音没有画面原因写入FFmpeg的像素格式与编码器参数不匹配。常见是把bgr24的帧流给了-pix_fmt yuv420p却忘记在输入侧声明格式另一种是分辨率设错帧的实际宽高比和-s参数不一致导致编码器丢帧。解决严格保持三处一致LockBits时的PixelFormat、FFmpeg输入侧的-pix_fmt、输出编码器参数。我还会在正式录制前先输出一个1秒测试片段确认文件能被播放器打开再进入正式录像状态测试片段跑完就删。4.5 现象在Windows服务里调用摄像头接口预览始终无法启动原因Windows服务运行在Session 0隔离会话中摄像头设备绑定的是当前登录用户的会话服务进程访问不到设备节点。这个问题在无人值守工控机、远程监控网关里特别常见。解决监控采集端不要做成Windows Service改成开机自启的桌面程序或者用任务计划程序在“用户登录时”触发运行这样能直接进入交互式会话。如果一定要服务形态至少把采集逻辑放进独立进程并配置成“允许服务与桌面交互”但Windows 10之后这种做法兼容性越来越差建议直接放弃。这个坑我在集成项目里踩过后来统一改成任务计划拉起EXE的方案线上再也没出现过黑屏。5. 进阶技巧把画面从PictureBox搬进WPF渲染管线再看1080P验证法5.1 WriteableBitmap替代PictureBox降低长尾开销PictureBox在长时间监控和窗口缩放时的表现不够稳主要问题是每次显示都要新建Bitmap再交给GDI绘制CPU和内存持续跳动。升级方案是把预览画布换成WPF的WriteableBitmap核心是复用一块系统内存帧拷贝只在LockBits时发生一次渲染直接走WPF的合成管线。关键代码如下_writeableBitmap new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgr24, null); _previewImage.Source _writeableBitmap; // 每来一帧只复制像素到BackBuffer var data frame.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); _writeableBitmap.Lock(); try { IntPtr src data.Scan0; IntPtr dst _writeableBitmap.BackBuffer; for (int y 0; y height; y) { CopyMemory(dst, src, stride); src IntPtr.Add(src, data.Stride); dst IntPtr.Add(dst, _writeableBitmap.BackBufferStride); } _writeableBitmap.AddDirtyRect(new Int32Rect(0, 0, width, height)); } finally { _writeableBitmap.Unlock(); frame.UnlockBits(data); }这里的要点是WriteableBitmap的BackBuffer必须由我们自己填充而且BackBufferStride不一定等于图宽所以不能整块memcpy而要按行拷贝。AddDirtyRect告诉WPF哪些区域需要重新合成。这套方案在1080P/30fps下CPU占用比PictureBox低20%左右窗口拉伸时的边缘也清晰很多。参数上KeepBgr24作为BackBuffer格式时注意Bitmap的Format24bppRgb内存顺序是BGR而WPF的Bgr24正是同一顺序可以直接复制不用交换通道。5.2 1080P长时间稳定性的验证法接手别人的监控代码后我习惯先跑一个四小时验证流程持续预览加上每30秒抓一帧、每10分钟录一段30秒短视频同时记录内存、GDI对象、CPU、磁盘IO四个指标。人离开机器去干别的回来直接看曲线。这套做法能快速暴露内存泄漏、句柄增长、编码器偶发卡死这些隐藏问题。测试框架可以简单到用一个定时任务遍历前面写的StartRecord和StopRecord方法只要出现一次“写入失败”或“进程未退出”立刻把堆栈和帧计数写进日志。还有一点把预览和录像放进同一线程会串行导出时掉帧严重。正确方式是预览线程只管预览录像帧另起队列由独立编码线程消费。队列长度要设上限比如500帧满了就丢弃最旧帧避免内存无限增长。最后每次现场部署我都强制开启系统NTP时间同步否则多路录像的时间戳对不上回放时一帧一帧检查会发现时间轴全乱。这些经验是我第一次做多路监控时被一个“看起来任何问题都没有”的黑屏界面折腾到凌晨才固化下来的习惯。从那以后每写一个采集端我都会强制自己把“枚举→启动→拉流→抓帧→录像→释放”全链路走一遍并把每个环节的日志等级打全再交给用户。希望帮到你。本文还有配套的精品资源点击获取