OpenCvSharp4.1例程实战:Mat、Canny与视频链路调参排错
简介:面向 C# 开发者的 OpenCvSharp4.1 官方例程合集,适合已具备一定 .NET 基础、希望对照官方示例学习 OpenCV 4.1 图像处理与计算机视觉 API 的读者。包内共 176 个文件,以 60 个 cs 源码和少量 vb 示例为主体,辅以 jpg、png、bmp 等示例图像、mp4 演示视频、项目配置、解决方案及 prototxt 等模型描述文件,压缩包约 37.25MB,目录按示例与工程文件组织,便于检索和直接运行调试。已有 13192 人学习下载,说明其在 C# 视觉入门与示例查阅场景中具有一定参考价值。读者可从中获得图像读写、二值化处理等基础案例的完整工程结构,对照源码理解 OpenCvSharp 的调用方式、参数设置与常见排错思路,也可将其作为课程实验、项目原型或自有应用的起步模板。
1. OpenCvSharp4.1 例程包到底装了什么:先跑通再拆原理
拿到 OpenCvSharp4.1 官方例程的第一晚,我建议先别动代码,原样跑一遍比读十页文档管用。它是 OpenCV 4.1 的 C# 封装配套示例集合,覆盖 Mat 读写与像素访问、色彩空间转换、滤波与边缘检测、轮廓分析、摄像头取流和视频写出,再往外一圈是 WinForms/WPF 的显示封装。适合两类人:想把手上 Python 视觉脚本迁到 C# 桌面端的开发者,以及需要在 .NET 项目里嵌图像处理、又不想自己写 P/Invoke 的工程师。下面按「跑通—拆解—调参—排错」的顺序,把拆包时的判断和参数选择写清楚,能照着复现。
2. 环境搭起来:四个包的职责边界与第一个出图的 Mat 程序
例程包本身不带运行环境,能不能跑起来几乎全看 NuGet 引用对不对。这一章先把包结构讲清楚,再建一个最小工程验证链路通不通,最后讲 Mat 的内存模型——这块不清楚,后面所有例程都会以内存暴涨的形式还回来。
2.1 四个包的职责边界与常见引错
OpenCvSharp 的包拆得很细,职责基本是这样的:
| 包名 | 作用 | 缺了它会怎样 |
|---|---|---|
| OpenCvSharp4 | 托管层 API,Mat、Cv2、VideoCapture 等 | 编译期就报找不到类型 |
| OpenCvSharp4.runtime.win | 原生 OpenCvSharpExtern.dll 与 ffmpeg 视频解码库 | 编译能过,运行时 DllNotFoundException |
| OpenCvSharp4.Extensions | Mat 与 System.Drawing.Bitmap 互转 | WinForms 显示这一环断掉 |
| OpenCvSharp4.WpfExtensions | Mat 与 WPF BitmapSource 互转 | WPF 下拿不到 WriteableBitmap |
关键点在于版本对齐:托管包和 runtime 包必须是同一个 4.1 分支版本。C# 侧调用的导出函数签名跟着托管包走,原生 OpenCvSharpExtern.dll 的导出表跟着 runtime 包走,两者错位最典型的症状是EntryPointNotFoundException,而且报错信息指向一个看起来毫不相干的函数名,第一次遇上会以为是包坏了。
另一个硬约束是位数。原生 dll 分 x86 和 x64,进程位数必须一致。C# 工程默认是 AnyCPU,在 64 位系统上跑成 64 位进程,但一旦勾了「首选 32 位」,进程变成 32 位,加载 x64 的 dll 直接失败。我一般做法是显式把 PlatformTarget 钉死成 x64,不给运行时留选择余地。
2.2 从零建工程:三条命令加一个 csproj
用命令行建工程最快,避免在 IDE 里点来点去点到某个默认选项上。
# 建一个控制台工程,名字随便起 dotnet new console -n MatDemo cd MatDemo # 托管包与原生运行库包,版本都跟 4.1 分支对齐 dotnet add package OpenCvSharp4 -v 4.1.* dotnet add package OpenCvSharp4.runtime.win -v 4.1.*版本号这里用4.1.*浮动写法,具体补丁号以包管理器里 4.1 分支实际的最新版为准,不要跨大版本混装。工程文件里再补一行平台目标:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>netcoreapp3.1</TargetFramework> <PlatformTarget>x64</PlatformTarget> <!-- 钉死位数,避免 AnyCPU 在 32 位上翻车 --> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <!-- 例程里有指针遍历像素的写法 --> </PropertyGroup> </Project>AllowUnsafeBlocks不是必须,但例程里访问像素有几个地方用的是Mat.DataPointer加unsafe指针,先开着能省一次重编译。目标框架方面,4.1 这一代在 .NET Framework 4.6.1 以上和 .NET Core 3.x 上都能跑,桌面端拿 WinForms/WPF 做壳的选前者也行,逻辑基本不用改。
2.3 第一个能出图的程序:Mat 从读到显示
不要一上来就跑最复杂的那个例程,先用二十行代码确认读图、处理、显示、保存四条通路都通。
using OpenCvSharp; // 读图:flags 决定通道数,Color 是 3 通道 BGR using var src = Cv2.ImRead("sample.jpg", ImreadModes.Color); if (src.Empty()) { // 路径错、文件损坏、编码不受支持都会走到这里,提前退出比后面崩在 CvtColor 里好排查 Console.WriteLine("读图失败,检查路径或换个格式"); return; } // 灰度与边缘:中间结果都放在独立的 Mat 里,方便单独保存查看 using var gray = new Mat(); Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY); using var edges = new Mat(); Cv2.Canny(gray, edges, 50, 150); // 显示:窗口名不要用中文,部分平台的 highgui 对宽字符支持不一致 Cv2.ImShow("src", src); Cv2.ImShow("edges", edges); Cv2.WaitKey(0); // 0 表示无限等待按键,阻塞在这一行直到窗口有输入 Cv2.DestroyAllWindows(); // 不调用的话,后续循环里 ImShow 会不停新建窗口几个参数值得记一下:ImreadModes.Color强制三通道,即使原图是灰度或带 alpha 也会被转成 3 通道;Canny的三个参数分别是输入、输出和两个阈值,后文会专门讲怎么定这两个数;WaitKey(0)返回按键码,传 0 是无限等,传 1 就是等 1 毫秒,视频播放循环里必须传非零值,这一点后面还会碰到。
这段代码能跑通,说明托管包、原生 dll、highgui 三条链路都在位。跑不通的话,问题基本就落在第 5 章那几条上。
2.4 Mat 的内存模型:为什么到处都在 using
Mat 对象本身在托管堆上,像素数据在非托管内存里,靠引用计数管理。GC 只管得到那个壳,管不到像素缓冲区,所以Dispose必须显式调用,using写法是最省心的方式。更麻烦的是 Mat 支持视图:从一个 Mat 上切一块 ROI,新 Mat 和原 Mat 共享同一片像素内存。
using var src = Cv2.ImRead("sample.jpg", ImreadModes.Color); // 这个 Mat 只是视图,不拷贝像素,指向 src 内部的一块区域 using var roi = new Mat(src, new Rect(100, 100, 200, 200)); // Clone 才是真正申请新内存、复制像素 using var copy = roi.Clone(); roi.SetTo(Scalar.All(0)); // src 上的对应区域一起变黑 // copy 不受影响搞不清这一点,常见后果是「我明明只改了缩略图,原图也花了」。判断方法很简单:构造函数带new Rect(...)或者new Range(...)的那种,多半是视图;带.Clone()、.CopyTo()的,才是独立内存。写循环处理时,视图的好处是省内存,坏处是生命周期互相牵连——视图还没释放,原 Mat 的引用计数就降不到零,那片内存就一直挂着。
至于例程目录怎么读,我的习惯是按依赖关系从简到繁来,而不是按字母序:
| 典型分组 | 覆盖内容 | 建议顺序 |
|---|---|---|
| 基础图像 | ImRead/ImWrite、像素访问、Mat 运算 | 第一 |
| 图像处理 | 色彩空间、阈值、滤波、形态学、边缘 | 第二 |
| 轮廓与几何 | FindContours、包围盒、多边形拟合 | 第三 |
| 视频与摄像头 | VideoCapture、VideoWriter、帧循环 | 第四 |
| 界面与显示 | WinForms/WPF 控件绑定、缩放与刷新 | 第五 |
| 检测类 | 级联分类器、DNN 推理加载 | 最后 |
包内实际目录可能略有出入,但依赖关系不会变。先把基础图像那组跑完,能省掉后面大量「到底是 API 用错还是环境没通」的反复猜。
3. 例程逐类拆解:ImRead、CvtColor、Canny、FindContours 的参数怎么定
这一章是例程包里最值钱的部分,也是大多数人跑完就跳过、后面写业务代码时又回头翻的部分。图像处理的参数没有万能值,但每一个都有可以量化的定法,下面按调用链顺序讲。
3.1 读写与色彩空间:通道顺序和 HSV 的取值范围
OpenCV 的默认通道顺序是 BGR,不是 RGB。C# 里从 Bitmap 转过来、或者从摄像头抓帧,色序都是 BGR,直接送给 WPF 或 WinForms 显示会和原图对不上,蓝色发红。转换方式在显示环节做,不要在算法入口做。
using var src = Cv2.ImRead("sample.jpg", ImreadModes.Color); using var hsv = new Mat(); Cv2.CvtColor(src, hsv, ColorConversionCodes.BGR2HSV); // HSV 里 H 在 OpenCV 中被压到 0~179,不是 0~360 // 所以要取「红色」范围,得用 0~10 和 170~179 两段,而不是 0~30 和 330~360 using var mask1 = new Mat(); using var mask2 = new Mat(); Cv2.InRange(hsv, new Scalar(0, 80, 60), new Scalar(10, 255, 255), mask1); Cv2.InRange(hsv, new Scalar(170, 80, 60), new Scalar(179, 255, 255), mask2); using var redMask = mask1 | mask2; // 两段合并三个参数的含义:H 是色相,OpenCV 为了塞进 8 位无符号数除以了 2,所以上限是 179;S 是饱和度,0 到 255,低于 80 的基本是灰白区域;V 是明度,低于 60 接近纯黑。这三个下限不是随手写的,室内弱光场景 V 要往下调到 40 左右,否则暗部目标全丢;强光过曝场景 S 要往下调,否则高光区域饱和度被压扁。
ImreadModes那几个常用值也顺便记一下:Color强制三通道,Grayscale单通道,Unchanged保留原始通道和位深——读 16 位 TIFF 或带 alpha 的 PNG 时只有Unchanged不会丢数据,其余模式都会做隐式转换,精度掉了自己都不知道。
3.2 滤波与边缘:两个阈值到底怎么定
高斯模糊的核尺寸必须是奇数,传偶数会直接抛异常。sigma 传 0 表示由核尺寸反推,这是最常用的组合,因为手动调 sigma 和核尺寸两个参数很容易互相打架。
using var src = Cv2.ImRead("sample.jpg", ImreadModes.Grayscale); // ksize 必须是奇数;sigmaX 传 0 由 ksize 推导 using var blur = new Mat(); Cv2.GaussianBlur(src, blur, new Size(5, 5), 0); // Canny 的双阈值:低于低阈值直接判非边缘,高于高阈值直接判边缘,中间地带看连通性 using var edges = new Mat(); Cv2.Canny(blur, edges, 50, 150);两个阈值的经典比例是 1:2 到 1:3,问题在于绝对数值随场景变化极大。一个可量化的定法是用灰度均值做基准:
Cv2.MeanStdDev(blur, out Scalar mean, out Scalar stddev); // 以均值上下浮动 33% 作为双阈值,再夹到合法区间 double lower = Math.Max(0, (1.0 - 0.33) * mean.Val0); double upper = Math.Min(255, (1.0 + 0.33) * mean.Val0); using var autoEdges = new Mat(); Cv2.Canny(blur, autoEdges, lower, upper); Console.WriteLine($"lower={lower:F1}, upper={upper:F1}");MeanStdDev输出两个 Scalar,这里只取第一个通道的Val0,灰度图正好只有这一个通道有意义。0.33 这个系数来自经验:偏低会把纹理噪声都拉成边缘,偏高会丢掉弱对比轮廓。调试阶段建议把算出来的两个数打印出来,下次遇到同类场景直接从这组数起步,比盲调快得多。
模糊核尺寸和 Canny 是联动的。核太大,细边缘在模糊阶段就没了,怎么调阈值都找不回来;核太小,噪声进到 Canny 里,阈值再高也会有零星碎边。常见做法是先用 5×5 起步,边缘太碎把核加到 7×7,边缘太粗回到 3×3。
3.3 形态学与轮廓:hierarchy 和面积过滤
形态学的两个基本操作是开运算和闭运算,前者去小噪点,后者补小孔洞,结构元素尺寸决定能处理多大的缺陷。
// 开运算:先腐蚀后膨胀,去掉细小噪点 using var kernel = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(3, 3)); using var opened = new Mat(); Cv2.MorphologyEx(edges, opened, MorphTypes.Open, kernel); // 闭运算:先膨胀后腐蚀,填补轮廓内部的小断裂 using var closed = new Mat(); Cv2.MorphologyEx(opened, closed, MorphTypes.Close, kernel);GetStructuringElement的形状参数里,Rect适合矩形目标,Ellipse适合圆形或自然目标,Cross适合细线状结构。尺寸必须是奇数,3×3 是最常用的起点,处理的是像素级的毛刺;要到 7×7 以上才能填补肉眼可见的缺口,但同时会把相邻目标粘在一起,这一步要拿实际图反复看。
找轮廓的签名里有三个容易记错的点:
// 输入图建议传 clone,虽然 OpenCV 3.2 之后 FindContours 不再修改输入,但跨版本保险 using var input = closed.Clone(); Cv2.FindContours( input, out Point[][] contours, out HierarchyIndex[] hierarchy, RetrievalModes.External, // 只要最外层轮廓 ContourApproximationModes.ApproxSimple); // 压缩共线点,只保留拐点 foreach (var contour in contours) { double area = Cv2.ContourArea(contour); if (area < 200) continue; // 面积过滤,去掉噪点轮廓 Rect box = Cv2.BoundingRect(contour); double perimeter = Cv2.ArcLength(contour, true); // 面积与周长平方的比值可以粗略判断形状紧致程度,接近圆的目标这个值偏大 Console.WriteLine($"area={area:F0} box={box} perimeter={perimeter:F0}"); }RetrievalModes的选择直接影响结果:External只返回最外层,适合数目标个数;Tree返回完整层级,父子关系存在HierarchyIndex的Parent、FirstChild字段里,适合做嵌套结构分析,比如找某个外框里面的小孔。ApproxSimple会把一条直线上的中间点全部压缩掉,点数能降一个数量级,做面积和周长计算时精度损失可以忽略,但如果你要拿点集做模板匹配,就得换成ApproxNone。
面积阈值 200 这个数不是固定的,它和图像分辨率强相关。判断方法:先把所有轮廓面积打印出来排序,噪点通常集中在个位数到几十,真实目标会有一两个明显的数量级跳跃,阈值就取在跳跃中间。
4. 摄像头与视频链路:VideoCapture 打开失败、帧循环与 WPF 渲染
图像处理是静态的,视频链路是动态的,问题类型完全不同。静态图调不通最多是结果不对,视频链路调不通就是黑屏、卡死、内存一路涨。这一章把取流、帧循环、界面刷新、写出四段拆开。
4.1 VideoCapture 打开失败的排查顺序
// 第二个参数指定后端,Windows 上 DSHOW 兼容性最好,MSMF 打开更快但设备兼容性略差 using var cap = new VideoCapture(0, VideoCaptureAPIs.DSHOW); if (!cap.IsOpened()) { Console.WriteLine("打开摄像头失败"); return; } // 分辨率要在打开之后、读帧之前设置,顺序反了很多设备会静默忽略 cap.Set(VideoCaptureProperties.FrameWidth, 1280); cap.Set(VideoCaptureProperties.FrameHeight, 720); cap.Set(VideoCaptureProperties.FPS, 30); // 设置不一定生效,读回来确认实际值 Console.WriteLine($"实际 {cap.FrameWidth}x{cap.FrameHeight} @ {cap.Fps}");排查顺序我一般是这样:先确认设备索引,0 是默认摄像头,多设备时逐个试 0 到 3;再看后端,DSHOW和MSMF换着试,某些采集卡只认其中一个;然后看是不是被别的程序占用,摄像头是独占资源,另一个进程开着预览就抢不到;最后看分辨率,设了一个设备不支持的尺寸,有的驱动会直接失败而不是回退到最近的可用值。Set之后再Get一遍确认,这一步能省掉大量「代码明明设了 1080p 为什么出来是 640」的困惑。
4.2 帧循环的写法与退出条件
using var frame = new Mat(); while (true) { // Read 内部是 Grab + Retrieve,返回 false 表示流结束或取帧失败 if (!cap.Read(frame) || frame.Empty()) { Console.WriteLine("取帧失败,可能是设备断开或文件读完"); break; } // 处理放在这里,注意不要在里面 new 大对象,循环里每帧分配会触发频繁 GC Cv2.CvtColor(frame, frame, ColorConversionCodes.BGR2GRAY); Cv2.ImShow("live", frame); // 这里必须是 1 而不是 0,传 0 会阻塞到按键,视频就变成逐帧步进了 if (Cv2.WaitKey(1) == 27) break; // 27 是 ESC }循环体里有三个细节:Read复用了同一个frame对象,不会每帧新建 Mat,这是有意的,避免每帧分配非托管内存;WaitKey(1)除了控制播放节奏,还负责处理 highgui 的窗口消息,不调用的话窗口会假死;退出条件除了 ESC,还要考虑流自然结束,所以Read的返回值必须判断,不能只靠异常。
文件播放时节奏控制另说。摄像头是实时的,WaitKey(1)就够;播本地视频想按原速回放,得按 FPS 算延时,WaitKey((int)(1000 / fps)),直接传 1 会快放。
4.3 在 WinForms 和 WPF 上刷新画面
Cv2.ImShow只适合调试,真做工具得嵌到界面里。WinForms 用 PictureBox,转换靠 Extensions 包:
// WinForms:每帧转成 Bitmap 塞给 PictureBox var bmp = OpenCvSharp.Extensions.BitmapConverter.ToBitmap(frame); // 上一帧的 Image 必须手动释放,否则 GDI 句柄会一路涨到系统上限 var old = pictureBox1.Image; pictureBox1.Image = bmp; old?.Dispose();WPF 走的是另一条路,转成WriteableBitmap直接绑到 Image 控件:
// WPF:需要 OpenCvSharp4.WpfExtensions var wb = OpenCvSharp.WpfExtensions.WriteableBitmapConverter.ToWriteableBitmap(frame); imageControl.Source = wb;WPF 这里有个跨线程的坑:取流通常放在后台线程,但给Image.Source赋值必须在 UI 线程。最省事的做法是后台线程只负责处理和转换,转好的WriteableBitmap通过Dispatcher.BeginInvoke抛回 UI 线程赋值。BeginInvoke而不是Invoke,是因为同步等待在帧率高的时候会把后台线程拖住,反而掉帧。
性能上还有一条:BitmapConverter.ToBitmap每帧都会新建一个 Bitmap,30fps 下每秒 30 次分配加释放,GC 压力不小。分辨率上到 1080p 以后,建议改成预分配一个固定尺寸的 Bitmap,用LockBits直接写像素,虽然代码多几行,但帧率会稳很多。
4.4 VideoWriter 的编码与尺寸约束
// 四字符编码:XVID 兼容性好,MJPG 文件大但几乎所有播放器都认 using var writer = new VideoWriter( "out.avi", FourCC.XVID, 25, // 帧率,要和写入节奏一致 new Size(1280, 720), // 必须与每帧尺寸完全一致 true); // 是否彩色 if (!writer.IsOpened()) { Console.WriteLine("VideoWriter 初始化失败,多半是编码器不可用"); return; } // 写帧 writer.Write(frame); // 结束必须释放,否则文件尾索引不完整,播放器打不开 writer.Release();三个硬约束:帧尺寸必须和构造时完全一致,差一个像素都会静默丢帧,最后得到一个 0 字节或者时长对不上的文件;帧率参数只写进文件头,不影响实际写入速度,写快了就是快放;FourCC.XVID在部分精简版系统上可能找不到对应编码器,这时换FourCC.MJPG,它是 Motion-JPEG,几乎不会失败,代价是文件体积大好几倍。
5. 避坑与排查:五个把人劝退的现场
例程跑不通的原因高度集中,下面五条按我遇到的频率排序,每条都按现象、原因、解决三段写。
5.1 原生库加载失败与位数不匹配
现象:编译通过,一运行就抛DllNotFoundException: OpenCvSharpExtern或者BadImageFormatException。
原因:前者是 runtime 包没装,或者原生 dll 没被复制到输出目录;后者是位数不匹配,32 位进程去加载 64 位 dll。还有一种隐蔽情况:机器上装过别的 OpenCV 版本,系统 PATH 里有同名 dll,加载到了错误的那份。
解决:先看输出目录下有没有OpenCvSharpExtern.dll和opencv_videoio_ffmpeg*.dll,没有就重新装 runtime 包并清理重建;有的话用dumpbin /headers看一眼架构;把 PlatformTarget 显式设成 x64,不要依赖 AnyCPU 的默认行为;排查 PATH 污染,必要时在代码里显式指定原生库搜索目录。
5.2 读视频返回空帧与中文路径
现象:VideoCapture的IsOpened()返回 true,但Read一直返回 false;或者ImRead对某些图片一直返回空 Mat,换个路径就好了。
原因:视频打不开多半是缺 ffmpeg 解码库,runtime 包里带的那份没被复制过去;图片读不到基本是路径含非 ASCII 字符,原生层的文件打开用的是窄字符接口,编码一错就找不到文件。
解决:视频先确认 ffmpeg 的 dll 在输出目录;图片路径要么改成纯英文,要么用Cv2.ImDecode(File.ReadAllBytes(path), ImreadModes.Color)绕开原生文件接口——这个写法把读文件交给 .NET,解码交给 OpenCV,中文路径问题直接消失,我在所有交付项目里都这么处理。
5.3 Mat 未释放导致内存一路涨
现象:跑几分钟内存从几百兆涨到几个 G,任务管理器里非托管内存持续增长,GC 也压不下去。
原因:Mat 的像素数据在非托管内存里,GC 只回收托管壳,不回收像素缓冲区。循环里new Mat()而不Dispose,或者用 ROI 视图把原 Mat 的引用计数钉住,都会造成泄漏。另一种常见情况是异常路径跳过了Dispose。
解决:所有 Mat 一律用using,包括临时中间结果;循环里复用同一个 Mat 而不是每帧新建;用Cv2.GetTickCount之类的手段做压力测试,跑一千帧看内存是否稳定;视图用完立刻释放,需要保留就Clone出独立内存。
5.4 WaitKey 不调用导致窗口假死
现象:ImShow的窗口白屏、拖动没反应、点关闭按钮也关不掉,整个进程像卡住了。
原因:highgui 的窗口消息要靠WaitKey驱动,不调用它,窗口只是创建了但不进消息循环。这一条在新手代码里出现频率极高,属于典型的「玄学」问题——代码看不出错,就是不响应。
解决:任何ImShow之后必须跟一个WaitKey,静态图用WaitKey(0),视频循环用WaitKey(1);程序退出前调DestroyAllWindows();如果是在后台线程里显示,注意 highgui 的窗口操作要放在同一个线程里,跨线程调用会出现更难查的问题。
5.5 WPF 图像不更新与跨线程异常
现象:后台线程更新了 Mat,界面上的图一动不动;或者直接抛「调用线程无法访问此对象,因为另一个线程拥有该对象」。
原因:WPF 的 UI 元素有线程亲和性,后台线程不能直接赋值Image.Source;另一种情况是每帧都赋了新的WriteableBitmap,但 UI 线程被别的逻辑占满,渲染队列积压,看上去就是不刷新。
解决:转换在后台做,赋值用Dispatcher.BeginInvoke抛回 UI 线程;WriteableBitmap可以复用同一个实例,改内容后调Lock/AddDirtyRect/Unlock,比每帧新建省一大截;如果帧率还是上不去,检查是不是把耗时处理放在了 UI 线程。
6. 把例程改成自己的工具:一条可复用的处理管线
例程的价值不在代码本身,在它给出了每条调用链的标准写法。真正落地时,把这些散落的调用收成一个管线类,参数从配置文件读,比每次复制粘贴强得多。
我一般的组织方式是一个IImageStep接口加若干实现,管线负责串联,每步的开关和参数写在配置里:
public interface IImageStep { string Name { get; } Mat Process(Mat input); // 返回新 Mat,调用方负责释放 } public sealed class Pipeline { private readonly List<IImageStep> _steps = new(); public void Add(IImageStep step) => _steps.Add(step); public Mat Run(Mat src) { var current = src.Clone(); // 入口先 Clone,避免改到调用方的图 foreach (var step in _steps) { var next = step.Process(current); current.Dispose(); // 上一步的中间结果立刻释放 current = next; } return current; } }这套写法的好处是每步都能单独开关、单独计时。加一个Stopwatch包在Process外面,跑一遍就知道瓶颈在哪一步:多数时候是形态学或者大核模糊,而不是看起来最复杂的轮廓分析。
| 步骤 | 关键参数 | 调参依据 |
|---|---|---|
| 灰度转换 | 通道顺序 BGR2GRAY | 输入若是 RGB 要先转序 |
| 高斯模糊 | ksize 奇数、sigma 为 0 | 边缘碎就加大核 |
| Canny | 高低阈值 1:2~1:3 | 用灰度均值 ±33% 起步 |
| 形态学开 | 核 3×3 到 7×7 | 噪点尺寸决定核大小 |
| 轮廓过滤 | 面积阈值 | 按面积分布的数量级跳跃取 |
验证方法我固定用两步:一是拿一张标准图跑全流程,把每一步的中间结果ImWrite出来存成序列,肉眼过一遍,找出哪一步开始不对;二是跑一千帧压力测试,看内存曲线是否平稳、单帧耗时是否在预算内。这两步走完,参数才敢往业务里带。
血泪经验是别在管线里做隐式转换。初期为了省事,我在某一步里直接把 BGR 当 RGB 处理,单测看不出来,上线后所有颜色判定的阈值全偏,排查了大半天。从那以后我每接一个 C# 视觉需求,都会先把中间结果落盘、把每步耗时打出来,再谈调参。希望帮到你。
本文还有配套的精品资源,点击获取