简介:这份资源面向具备一定C#基础、希望入门计算机视觉的开发者,聚焦于使用EmguCV(OpenCV的.NET封装)调用本地摄像头并提取图像这一典型场景。压缩包共41个文件,约39.31MB,包含5个cs源码文件、15个dll动态库、2个exe可执行程序以及pdb调试符号、xml文档、sln解决方案与csproj工程文件等,覆盖从源码到可运行程序的完整结构,其中Emgu.CV.World、Emgu.CV.UI等核心库均已随包提供,省去额外配置环境的麻烦。资源围绕Capture对象初始化、QueryFrame逐帧捕获、Mat与Image类型转换、灰度化等图像处理以及资源释放等关键环节展开,并配有Windows Forms界面示例,便于读者对照理解摄像头采集的完整链路。目前已有1377人学习下载,适合作为C#图像采集与实时视频处理方向的练手模板,也可在此基础上扩展帧率控制、人脸识别等进阶功能。
1. 摄像头采集这件事,EmguCV 到底帮你省了多少事
很多人第一次在 C# 里接摄像头,第一反应是去翻 DirectShow 或者 MediaFoundation 的接口,结果被 COM 组件和回调指针绕得头晕。EmguCV 的价值就在于,它把 OpenCV 的VideoCapture封装成了托管对象,你不需要写一行非托管代码,就能拿到每一帧的Mat或Image。这个项目标题说的“打开自带摄像头,提取图像”,本质上就是三件事:枚举设备、抓帧、把帧转成后续能处理的格式。听起来简单,但实际落地时,摄像头索引选错、帧率不稳、内存泄漏这三个坑,几乎每个人都会踩一遍。如果你正在做机器视觉的入门 Demo,或者需要一个稳定的图像采集前端,这套东西值得你花半小时跟一遍。
2. 环境搭起来:从 NuGet 包到第一个能跑的项目
2.1 选 EmguCV 而不是裸 OpenCV 的理由
在 C# 里做图像处理,可选方案其实不多。裸 OpenCV 需要自己写 P/Invoke 或者用 C++/CLI 桥接,维护成本高;AForge.NET 虽然轻量,但图像算法库的深度和更新频率都跟不上;EmguCV 本质上是 OpenCV 的 .NET 封装,API 命名和 C++ 版几乎一一对应,你查 OpenCV 的文档就能直接套用。更重要的是,它提供了Mat、Image<,>、UMat这些托管类型,GC 能管到大部分内存,不用手动cvReleaseImage。
选版本时注意一点:EmguCV 的版本号和 OpenCV 是绑定的。比如 EmguCV 4.7 对应 OpenCV 4.7,你如果要用某个 OpenCV 的新特性,得先确认 EmguCV 有没有跟进。我一般会选比最新版低一个 minor 的版本,稳定性更好,网上能搜到的资料也多。
2.2 用 NuGet 把依赖装干净
打开 Visual Studio,新建一个 .NET Framework 或者 .NET Core 的控制台项目都行。右键项目 → 管理 NuGet 程序包 → 浏览 → 搜索Emgu.CV,你会看到几个包:
| 包名 | 作用 | 是否必装 |
|---|---|---|
| Emgu.CV | 核心托管库 | 是 |
| Emgu.CV.Bitmap | Bitmap 与 Mat 互转 | 是 |
| Emgu.CV.runtime.windows | Windows 原生 DLL | 是 |
| Emgu.CV.runtime.ubuntu | Linux 原生库 | 按需 |
装完Emgu.CV.runtime.windows之后,NuGet 会自动把opencv_videoio_ffmpeg*.dll、opencv_world*.dll这些原生库复制到输出目录。如果你只装了Emgu.CV没装 runtime 包,运行时会报DllNotFoundException,这是新手最常见的翻车点。
# 用 Package Manager Console 装也可以,一条命令搞定 Install-Package Emgu.CV -Version 4.7.0.5276 Install-Package Emgu.CV.Bitmap -Version 4.7.0.5276 Install-Package Emgu.CV.runtime.windows -Version 4.7.0.5276版本号要统一,三个包版本不一致会出现类型加载失败。装完之后,在项目属性里把平台目标设成 x64,因为 EmguCV 的 Windows 原生库默认只提供 64 位版本,用 AnyCPU 或者 x86 都会在运行时找不到 DLL。
2.3 初始化 VideoCapture 并抓第一帧
下面这段代码是最小可运行版本,直接复制到Program.cs的Main方法里就能跑:
using Emgu.CV; using Emgu.CV.CvEnum; using System; namespace CameraCaptureDemo { class Program { static void Main(string[] args) { // 索引 0 通常是自带摄像头,外接 USB 摄像头可能是 1 或 2 using (VideoCapture capture = new VideoCapture(0, VideoCapture.API.DShow)) { if (!capture.IsOpened) { Console.WriteLine("摄像头打开失败,检查索引或驱动"); return; } // 设置分辨率,注意不是所有摄像头都支持任意分辨率 capture.Set(CapProp.FrameWidth, 1280); capture.Set(CapProp.FrameHeight, 720); capture.Set(CapProp.Fps, 30); using (Mat frame = new Mat()) { // 先抓几帧丢弃,等自动曝光和白平衡稳定 for (int i = 0; i < 5; i++) { capture.Read(frame); } if (!frame.IsEmpty) { CvInvoke.Imwrite("first_frame.jpg", frame); Console.WriteLine($"抓帧成功,尺寸:{frame.Width}x{frame.Height}"); } } } } } }VideoCapture的构造函数第二个参数指定后端 API。Windows 上常用VideoCapture.API.DShow或VideoCapture.API.MSMF,前者兼容性好,后者延迟低但部分老摄像头不支持。不传这个参数的话,EmguCV 会按默认顺序尝试,有时候会选到一个不稳定的后端,导致帧率忽高忽低。
capture.Set设置分辨率时,摄像头驱动会返回一个最接近的支持值,不保证和你设的完全一致。设完之后最好用capture.Get(CapProp.FrameWidth)读回来确认一下。Fps这个参数在部分摄像头上是只读的,设了也没用,实际帧率取决于硬件。
抓帧前先读 5 帧丢弃,这个习惯能避开自动曝光调整带来的前几帧过暗或过亮问题。Mat对象用using包住,确保非托管内存及时释放。
3. 把帧取出来之后:格式转换与连续采集循环
3.1 Mat 与 Bitmap 互转的两种路径
抓到Mat之后,你大概率要把它显示到 WinForms 的PictureBox或者 WPF 的Image控件上。PictureBox只认Bitmap,所以需要转换。EmguCV 提供了扩展方法,但要注意引用Emgu.CV.Bitmap命名空间:
using Emgu.CV.Bitmap; // 扩展方法所在命名空间 // Mat -> Bitmap Bitmap bmp = frame.ToBitmap(); // Bitmap -> Mat Mat mat = bmp.ToMat();ToBitmap()内部会做一次颜色空间转换,如果Mat是 BGR 三通道,转出来就是标准的 24 位 RGB Bitmap。如果Mat是灰度图,转出来是 8 位索引格式,直接赋给PictureBox可能会偏色。稳妥做法是先CvInvoke.CvtColor转成 BGR 再转 Bitmap。
另一种路径是用Image<Bgr, byte>这个泛型类,它本身就继承自Bitmap,可以直接赋值:
using (Image<Bgr, byte> img = frame.ToImage<Bgr, byte>()) { pictureBox1.Image = img.ToBitmap(); }ToImage<TColor, TDepth>()会复制一份数据,所以frame可以继续复用,不会因为img被释放而失效。这个复制操作有开销,1280x720 的帧大概多花 2-3 毫秒,做实时显示够用,做高帧率处理就要考虑直接操作Mat了。
3.2 连续采集循环的线程模型
摄像头采集不能放在 UI 线程里死循环,否则界面直接卡死。常见做法是开一个后台线程或者用Task跑采集循环,通过事件或者Invoke把帧推给 UI:
private VideoCapture _capture; private CancellationTokenSource _cts; private Mat _frame = new Mat(); private void StartCapture() { _cts = new CancellationTokenSource(); _capture = new VideoCapture(0, VideoCapture.API.DShow); _capture.Set(CapProp.FrameWidth, 1280); _capture.Set(CapProp.FrameHeight, 720); Task.Run(() => { while (!_cts.Token.IsCancellationRequested) { if (_capture.Read(_frame) && !_frame.IsEmpty) { // 克隆一份再传给 UI,避免下一帧覆盖正在显示的数据 Mat clone = _frame.Clone(); this.Invoke(new Action(() => { using (Mat display = clone) { pictureBox1.Image?.Dispose(); pictureBox1.Image = display.ToBitmap(); } })); } else { Thread.Sleep(10); // 读失败时稍作等待,避免空转吃满 CPU } } }, _cts.Token); }这里有几个细节值得说。_capture.Read(_frame)是阻塞调用,摄像头没数据时它会等,所以循环里不需要额外Sleep。但读失败的情况(比如设备被拔出)会立刻返回 false,这时候不加延时就会变成死循环空转,CPU 直接飙到 100%。
传给 UI 之前Clone()一份是必须的。_frame在下一轮循环会被覆写,如果直接把_frame的引用丢给 UI 线程去转 Bitmap,可能转了一半数据就被改了,画面出现撕裂。Clone()的开销和ToImage差不多,但逻辑更清晰。
pictureBox1.Image?.Dispose()这行是防止旧 Bitmap 堆积。PictureBox不会自动释放上一张图,如果你每秒推 30 帧,几秒钟就能吃掉几百 MB 内存。这个坑我见过不止一次,表现是程序跑几分钟后直接OutOfMemoryException。
3.3 参数调优:分辨率、帧率与曝光
摄像头的默认参数往往不是最优的。分辨率太高帧率上不去,太低又看不清细节。我一般会按用途分三档:
| 用途 | 分辨率 | 帧率 | 备注 |
|---|---|---|---|
| 人脸检测 | 640x480 | 30 | 够用且快 |
| 二维码扫描 | 1280x720 | 15-30 | 需要细节 |
| 高清录制 | 1920x1080 | 30 | 确认摄像头支持 |
曝光控制分自动和手动。自动曝光在光线变化时会反复调整,导致画面忽明忽暗。如果场景光线稳定,建议关掉自动曝光:
capture.Set(CapProp.AutoExposure, 0.25); // 0.25 表示手动模式,具体值因驱动而异 capture.Set(CapProp.Exposure, -6); // 负值通常是调暗,正值调亮CapProp.AutoExposure这个枚举在不同摄像头上的行为不一致,有的驱动用 0 表示手动,有的用 0.25,还有的用 1。最稳妥的办法是先用capture.Get读当前值,再试着设一个接近的值,观察画面变化。如果设了没反应,说明驱动不支持这个属性,只能接受自动曝光。
4. 避坑排查:摄像头采集的五个血泪教训
4.1 现象:程序启动报 DllNotFoundException
原因几乎只有一个:原生库没复制到输出目录。Emgu.CV.runtime.windows这个包在安装时会把 DLL 放到runtimes/win-x64/native/下面,但有些项目类型(比如老式 .NET Framework 项目)不会自动复制。解决方法是手动检查输出目录里有没有opencv_world*.dll和opencv_videoio_ffmpeg*.dll,没有的话在.csproj里加一条:
<ItemGroup> <None Include="runtimes\win-x64\native\*.dll"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup>或者更简单,把平台目标从 AnyCPU 改成 x64,NuGet 的构建脚本就会正确复制。
4.2 现象:VideoCapture 打开成功但读出来全是空帧
这种“打开成功但没数据”的情况最迷惑人。常见原因有三个:摄像头被其他程序占用(比如微信、钉钉在后台开着)、索引选错了(0 是虚拟摄像头,1 才是真实摄像头)、后端 API 不兼容。排查顺序是先关掉所有可能占用摄像头的软件,然后换索引试,最后换后端:
// 依次尝试不同后端 foreach (var api in new[] { VideoCapture.API.DShow, VideoCapture.API.MSMF, VideoCapture.API.Any }) { using (var cap = new VideoCapture(0, api)) { if (cap.IsOpened && cap.Read(testMat) && !testMat.IsEmpty) { Console.WriteLine($"可用后端:{api}"); break; } } }4.3 现象:画面颜色偏蓝或偏红
这是通道顺序问题。OpenCV 默认用 BGR 顺序,而Bitmap用 RGB。ToBitmap()内部会做转换,但如果你手动用Marshal.Copy从Mat.Data拷数据到Bitmap,就会跳过转换,导致红蓝通道互换。解决方法是统一走ToBitmap(),或者手动CvInvoke.CvtColor(frame, frame, ColorConversion.Bgr2Rgb)之后再拷。
4.4 现象:跑几分钟后内存暴涨
前面提过PictureBox.Image不自动释放的问题,这里再补充一个:Mat对象如果不用using或者不手动Dispose(),非托管内存不会立刻回收。GC 管的是托管堆,Mat内部的数据指针在非托管堆上,GC 触发时才会通过析构函数释放。如果你每秒创建几十个Mat,GC 还没跑,内存就爆了。养成习惯:所有Mat、Image<,>都用using包住,或者显式调Dispose()。
4.5 现象:帧率远低于设定值
设了 30fps 实际只有 10fps,先看分辨率是不是太高。USB 2.0 摄像头在 1080p 下带宽不够,自动降到 10fps 是正常现象。换 USB 3.0 接口或者降分辨率。另一个原因是capture.Read在 UI 线程的Invoke上等太久,UI 线程忙着刷新界面,采集线程被阻塞。解决办法是把图像处理和显示分开,采集线程只负责抓帧入队,另一个线程从队列取帧做处理和显示。
5. 进阶技巧:用 Mat 池和帧队列把采集做稳
5.1 用对象池减少 Mat 分配
连续采集场景下,每帧都new Mat()再Dispose()会产生大量内存碎片。我一般会预分配一个Mat池,循环复用:
public class MatPool { private readonly ConcurrentBag<Mat> _pool = new ConcurrentBag<Mat>(); private readonly int _width, _height; public MatPool(int width, int height, int initialCount = 5) { _width = width; _height = height; for (int i = 0; i < initialCount; i++) { _pool.Add(new Mat(_height, _width, DepthType.Cv8U, 3)); } } public Mat Rent() { if (_pool.TryTake(out Mat mat)) return mat; return new Mat(_height, _width, DepthType.Cv8U, 3); } public void Return(Mat mat) { if (mat != null && !mat.IsEmpty) _pool.Add(mat); } }ConcurrentBag是线程安全的,采集线程和 UI 线程可以同时借还。Mat的构造函数参数依次是行数、列数、深度、通道数,这里预分配的是 720p 三通道。池的大小根据实际并发帧数调整,一般 5-10 个够用。
5.2 用 BlockingCollection 做帧队列
采集线程和显示线程之间用一个有界队列解耦,队列满了就丢帧,保证采集不阻塞:
private BlockingCollection<Mat> _frameQueue = new BlockingCollection<Mat>(boundedCapacity: 3); // 采集线程 while (!_cts.Token.IsCancellationRequested) { Mat frame = _matPool.Rent(); if (_capture.Read(frame) && !frame.IsEmpty) { if (!_frameQueue.TryAdd(frame, 0)) // 队列满则丢弃当前帧 { _matPool.Return(frame); } } else { _matPool.Return(frame); Thread.Sleep(10); } } // 显示线程 foreach (Mat frame in _frameQueue.GetConsumingEnumerable(_cts.Token)) { using (Mat display = frame.Clone()) { this.Invoke(new Action(() => { pictureBox1.Image?.Dispose(); pictureBox1.Image = display.ToBitmap(); })); } _matPool.Return(frame); }boundedCapacity: 3意味着最多缓存 3 帧,UI 处理不过来时新帧直接丢弃,不会让内存无限增长。TryAdd的超时参数设 0,不等待,立刻返回。这个模型在 1080p 30fps 下跑一整天,内存曲线是平的。
5.3 验证采集稳定性的方法
写完采集代码后,别急着接业务逻辑,先跑一个 10 分钟稳定性测试。记录三个指标:实际帧率、内存占用、丢帧数。实际帧率用Stopwatch统计每秒Read成功次数;内存占用用Process.GetCurrentProcess().WorkingSet64每秒采样一次;丢帧数就是TryAdd失败的次数。三个指标都平稳,说明采集层没问题。我自己的习惯是每次改完采集参数,都强制跑一遍这个测试,确认没有内存泄漏和帧率抖动再往下做。希望这套东西能帮你少走点弯路。
本文还有配套的精品资源,点击获取