尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

WinForms+OpenCvSharp玉米粒计数:连通域与分水岭实战指南

WinForms+OpenCvSharp玉米粒计数:连通域与分水岭实战指南 简介基于C# WinForm与OpenCVSharp实现的玉米粒计数演示源码面向.NET桌面应用开发者和图像处理入门者可用于农业场景中的自动计数与分析。压缩包共收录57个文件包含9个C#源码、11个DLL依赖库、与深度学习推理相关的prototxt及caffemodel模型文件以及配置、资源文件等整体大小约148.85MB。项目在VS2019、.NET Framework 4.7.2环境下以x64 Debug方式运行OpenCVSharp版本为4.8.0便于开发者直接编译调试。代码结构完整涵盖图像分割、特征提取与计数等关键模块并配套详细博客讲解实现细节。已有336人学习下载适合希望结合C#与OpenCVSharp解决实际视觉问题的开发者参考。1. 玉米粒计数演示源码为什么WinForms加OpenCvSharp是桌面端最不折腾的方案做粮食质检、农业考种或者种子交易的人对“数玉米粒”这件事一定不陌生一袋样品倒在黑板上人工一粒一粒数几百粒下来眼睛酸不说两个人数的结果还不一定一致。用C# winform搭一个上位机小工具再挂上openvsharpOpenCvSharp的常见叫法做图像处理核心思路就是把“肉眼看”换成“程序数”加载图片、灰度化、二值化、统计连通域几十毫秒就能给出数量。这套方案不需要高配显卡、不需要训练数据集只要一张散落不重叠的玉米粒照片就能跑而且同样适用于大豆、小麦、药片、螺丝垫片等颗粒计数场景。所以这类演示源码一直是C#入门者做图像处理练手的热门方向——它把OpenCV里最常用的几个函数串成了一条完整链路跑通了你对Mat、阈值、连通域这些概念就全通了。这篇按我实际做过的方案来讲怎么搭工程、怎么定参数、粘连颗粒怎么拆、哪些地方会翻车最后怎么验证你的计数结果敢不敢交给别人用。新手可以照着一路敲下来熟手可以直接跳到第4章和第5章看分割与避坑。2. 搭建WinForms加OpenCvSharp工程装包、显示图像、避免界面假死任何OpenCvSharp项目的第一步都是装对包。很多人直接在NuGet里搜OpenCvSharp然后装完就跑结果运行时报DllNotFoundException这个错几乎每个初学者都遇到过。2.1 NuGet包怎么选OpenCvSharp4和OpenCvSharp4.Windows缺一不可新建一个WinForms项目.NET Framework 4.7.2或.NET 6/8都行。在NuGet包管理器里搜openvsharp相关的结果通常需要装两个包OpenCvSharp4托管程序集和OpenCvSharp4.Windows原生DLL。前者提供C#接口后者才是真正干活的C库。只装前者程序启动时会抛“无法加载DLL OpenCvSharpExtern”。// 在csproj里确认包引用以PackageReference为例 PackageReference IncludeOpenCvSharp4 Version* / PackageReference IncludeOpenCvSharp4.Windows Version* /逻辑说明Version*表示取最新稳定版实际项目里建议固定版本号避免原生DLL和托管层版本不一致。有些旧教程让你装OpenCvSharp3或OpenCvSharp2那些是老APICv2调用方式虽然兼容但Mat的内存管理细节差很多新项目直接上4.x系列。参数说明如果目标客户机器是32位Windows需要在项目属性里把平台目标改成x86。OpenCvSharp4.Windows的原生DLL放在runtimes文件夹下按架构分目录你打包发布时要检查x64或x86目录里的OpenCvSharpExtern.dll有没有被复制到输出目录。这一步不检查后面在客户机器上跑必炸。2.2 最小界面布局一个按钮、一个PictureBox、一个Label界面不用花哨真正做上位机的人都知道winform界面美化可以后期再做先把流程跑通。我习惯放三个控件btnOpen打开图片按钮pictureBox1显示原图或结果图lblResult显示计数结果按钮点击事件里用OpenFileDialog选择图片然后用Cv2读取。using OpenCvSharp; private Mat src; // 当前处理的图像 private Mat gray; // 灰度图 private Mat result; // 标注结果 private void btnOpen_Click(object sender, EventArgs e) { using (var ofd new OpenFileDialog()) { ofd.Filter 图片文件|*.jpg;*.jpeg;*.png;*.bmp; if (ofd.ShowDialog() ! DialogResult.OK) return; // 直接以灰度图读入省掉一次颜色转换 src new Mat(ofd.FileName, ImreadModes.Grayscale); gray src.Clone(); // 转成Bitmap给PictureBox显示 var bmp OpenCvSharp.Extensions.BitmapConverter.ToBitmap(gray); pictureBox1.SizeMode PictureBoxSizeMode.Zoom; pictureBox1.Image?.Dispose(); pictureBox1.Image bmp; } }逻辑说明new Mat(path, ImreadModes.Grayscale)在加载阶段就把三通道彩色图转成单通道灰度图。这个转换在OpenCV内部完成比读彩色图再手动转要省一次内存拷贝。BitmapConverter.ToBitmap是OpenCvSharp提供的Mat到System.Drawing.Bitmap的桥接方法PictureBox只认Bitmap所以这一步必须做。参数说明ImreadModes.Grayscale读进来是CV_8UC1每个像素值是0到255的intensity。如果后面做分水岭需要原始彩色图那就在这个阶段再保留一份Mat srcColor new Mat(path, ImreadModes.Color)先用灰度图把流程跑通再管彩色。这段代码里有个容易忽略的点pictureBox1.Image?.Dispose()。PictureBox的Image属性添加新对象前不释放旧对象会引起GDI句柄泄漏。反复打开十几张图后程序内存会一直涨这是WinForms程序最常见的隐形杀手之一。2.3 处理耗时操作别让UI线程卡成“未响应”单张图片的灰度化加二值化很快但分水岭处理1200x1600的大图可能需要几百毫秒在UI线程直接跑会出现窗体拖不动、按钮点不了的假死现象。最简单的解法是用async/await加上Task.Run把图像处理丢到线程池。private async void btnCount_Click(object sender, EventArgs e) { if (src null) return; btnCount.Enabled false; lblResult.Text 处理中...; int count await Task.Run(() { var resultMat PreprocessAndCount(src, out int c); return c; }); lblResult.Text $计数结果{count} 粒; btnCount.Enabled true; }逻辑说明Task.Run里的代码在线程池运行await把结果带回UI线程这样界面不会卡住。注意btnCount.Enabled false先禁用按钮防止用户在处理过程中重复点击这是一种很基础的竞态控制。参数说明C#的async/await在WinForms里使用要求调用方方法标记async。而Task.Run里不要访问任何UI控件因为工作线程访问控件是跨线程非法操作——踩这个坑的表现是抛出InvalidOperationException所以要先把图片Mat传进去把结果int传出来。计数结果用lblResult.Text直接显示。这里用到C#字符串插值在WinForms里每台机器都要安装相应的.NET运行时发布选项里选择自包含可以免装但体积会变大几十MB自己权衡。2.4 PictureBox显示大小的三个细节图片大、控件小的时候默认Normal会只显示左上角一块用户以为程序坏了。直接把pictureBox1拖大点并设置SizeMode Zoom图像按比例缩放可以看到全貌。另一种做法是设置SizeMode StretchImage图会变形玉米粒被拉扁后面画的标注框也会被拉伸不适合做计数核对。还有一点不管Zoom还是StretchPictureBox显示的只是缩放后的结果你在Mat上画的矩形是基于原始像素坐标的图片缩放了多少倍坐标也跟着缩放。如果后面要做鼠标框选放大之类的交互就要做坐标换算。这个坑我在第5章当避坑案例写先记住这个结论。3. 连通域计数全流程灰度化、二值化、面积过滤的每个参数这一章是玉米粒计数的核心算法链路。只要玉米粒是散落开的、互相不接触这一套流程就能直接出结果。如果颗粒粘连严重到第4章再处理。先把手感练出来。3.1 为什么是“二值化加连通域”而不是目标检测模型玉米粒计数的本质是数“图像里有多少个闭合区域”。每一粒玉米在合适的二值图里都是一个白色块白色块的个数就是玉米粒的个数。这个任务用OpenCV的连通域分析就能解决不需要YOLO、不需要训练样本。目标检测模型当然也能做但它解决的是“目标在哪、是什么”的问题对玉米粒这种外形简单、类别单一的刚性目标来说是拿大炮打蚊子。而且深度学习方案要准备几百张标注图、训练迭代、依赖GPU对一个桌面小工具来说成本和复杂度都太大了。连通域分析基于像素连通关系速度快、可解释性强玉米粒边缘有缝就能分开。3.2 第一步灰度化和高斯滤波灰度化在加载图片时已经做了。接下来先高斯模糊目的是去掉玉米粒表面的纹理噪声。如果直接二值化玉米粒表面的纹路和细小裂纹会在颗粒内部留下黑色孔洞这些孔洞会把本来一整颗玉米粒分裂成好几个独立区域。private Mat PreprocessGray(Mat gray) { // 高斯滤波核大小15x15σ由OpenCV自动计算 Mat blurred new Mat(); Cv2.GaussianBlur(gray, blurred, new Size(15, 15), 0); return blurred; }逻辑说明GaussianBlur的第三个参数是高斯核大小必须是正奇数。核越大平滑效果越强细节丢失越多。玉米粒直径在500像素左右时15x15的核能把表面细微纹理抹平同时保留颗粒轮廓。第四个参数sigmaX传0表示让OpenCV根据核宽自动计算标准差这是最省心的写法。参数说明如果原图是小图比如手机拍的400x300缩略图玉米粒直径可能只有50像素这时候核大小要降到5或7否则会把整颗玉米粒糊成一团阈值后颗粒边缘严重内缩。经验法则核尺寸取玉米粒直径像素数的3%到5%比较安全。这个参数是所有参数里第一个要调的东西。3.3 第二步阈值分割选出白色目标区域二值化的目标是把玉米粒变成255白色背景变成0黑色。用Cv2.Threshold做全局固定阈值或者用Otsu自动算阈值。private Mat BinarySegement(Mat blurred) { // Otsu自动阈值0和255二值化输出 Mat binary new Mat(); Cv2.Threshold(blurred, binary, 0, 255, ThresholdTypes.Otsu); return binary; }逻辑说明传入阈值参数0在Otsu模式下会被忽略算法自己遍历所有灰度值找到一个让前景和背景类间方差最大的分割点。为什么不用固定阈值160因为实际照片光照不绝对均匀玉米粒和背景的灰度差在不同区域会变化Otsu自动适应整张图的灰度分布对这张图是160换一张图可能自动变成140省得你手动调。参数说明ThresholdTypes.Otsu返回的是二值图输出为CV_8UC1像素值只有0和255。需要注意Otsu的前提是图像灰度直方图是双峰的前景一大峰背景一大峰。如果光照不均导致直方图只有一个峰Otsu会切出一个奇怪的阈值这时换第5章讲的自适应阈值。玉米粒计数这种场景背景通常是黑色底板、玉米粒是黄色或白色直方图双峰很明显Otsu非常稳。如果拍摄时玉米粒是深色的、背景是浅色的阈值方向要反过来。二值化后让前景为白色用ThresholdTypes.Binary还是BinaryInv取决于前景和背景谁亮。玉米粒比背景亮用Binary比背景暗用BinaryInv。写代码时抓住一个原则二值图里前景总是白色背景总是黑色后面所有形态学操作和连通域分析都不用想着“要不要反转”。3.4 第三步形态学开运算消除孤立噪点二值化之后图上除了玉米粒还可能有粉尘、反光斑这些小块白色噪点。它们面积小但同样会被算成连通域。先做一次开运算腐蚀再膨胀把噪点抹掉。private Mat MorphOpen(Mat binary) { // 椭圆核大小5x5先腐蚀再膨胀 Mat kernel Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(5, 5)); Mat opened new Mat(); Cv2.MorphologyEx(binary, opened, MorphTypes.Open, kernel); return opened; }逻辑说明开运算能去掉所有比核小的白色物体。腐蚀让白色区域变瘦细小噪点直接消失然后膨胀让剩余白色区域恢复原来的粗细。核形状选了Ellipse椭圆而不是矩形因为玉米粒的截面是椭圆形的用椭圆核对颗粒边缘的收缩更均匀矩形核会把玉米粒边缘修出棱角。参数说明核Size(5,5)是经验值——噪点的直径通常在3像素以内把核压在5像素左右已经足够清除。核如果太大比如15x15会把小玉米粒整体抹掉如果太小比如3x3又清不掉直径接近4像素的噪点。演示源码里写死5可以先跑通实际项目中建议做成TrackBar让用户实时拖。3.5 第四步连通域统计一次拿到个数和面积这里用Cv2.ConnectedComponentsWithStats它比Cv2.FindContours更直接——一次调用同时返回标签图、每个连通域的统计信息坐标、外接矩形、面积和质心。FindContours也能数但还要自己算面积和过滤代码更啰嗦。private int CountConnected(Mat binary, out Mat statsOut, out Mat labelsOut) { Mat labels new Mat(); Mat stats new Mat(); Mat centroids new Mat(); int labelCount Cv2.ConnectedComponentsWithStats(binary, labels, stats, centroids); // label 0是背景从1开始遍历 int validCount 0; for (int i 1; i labelCount; i) { // stats第4列是面积 int area stats.Atint(i, (int)ConnectedComponentsStats.Area); if (area 100) // 面积过滤排除噪点 { validCount; } } statsOut stats; labelsOut labels; return validCount; }逻辑说明labelCount返回的是包含背景在内的连通域数量。stats是个Mat每行对应一个连通域行索引从0开始第0行永远是背景。四列分别对应Left、Top、Width、Height、Area。这些枚举在OpenCvSharp里叫ConnectedComponentsStats索引值Area4这里用枚举比写数字4更不容易搞错。参数说明面积阈值100是按“玉米粒在图像里至少占几百个像素”定的。这个值必须根据你的图像分辨率缩放。比如原图是3000x2000一颗玉米粒能占1500像素阈值可以提到500原图是400x300一颗玉米粒只有150像素阈值还写100可能把所有小颗粒都留住了但背景噪声也可能混进来。我一般先跑一次不过滤的结果看所有连通域的面积直方图最小的那一堆是噪声玉米粒的面积分布在另一个区间然后在两者之间取阈值。这个习惯能帮你快速判断每个具体项目该设多少。3.6 第五步把结果画到图上用户才相信你没数错数完之后必须把每颗玉米粒标出来。用外接矩形和编号文字叠加到结果图上然后显示到PictureBox。private Mat DrawCornResults(Mat colorImage, Mat stats, int labelCount, int minArea) { Mat result colorImage.Clone(); for (int i 1; i labelCount; i) { int area stats.Atint(i, (int)ConnectedComponentsStats.Area); if (area minArea) continue; int x stats.Atint(i, (int)ConnectedComponentsStats.Left); int y stats.Atint(i, (int)ConnectedComponentsStats.Top); int width stats.Atint(i, (int)ConnectedComponentsStats.Width); int height stats.Atint(i, (int)ConnectedComponentsStats.Height); Cv2.Rectangle(result, new Rect(x, y, width, height), new Scalar(0, 255, 0), 2); Cv2.PutText(result, i.ToString(), new Point(x, y - 5), HersheyFonts.HersheySimplex, 0.5, new Scalar(0, 0, 255), 1, LineTypes.AntiAlias); } return result; }逻辑说明Cv2.Rectangle的第五个参数是线宽ScalarColornew Scalar(0, 255, 0)在OpenCV里是BGR顺序的绿色。PutText的第三个参数是文字左下角坐标x不变、y往上偏移5像素文字就浮在矩形框上方而不是压着边框看得清爽。LineTypes.AntiAlias让文字边缘平滑避免缩放时出现锯齿。参数说明HersheyFonts.HersheySimplex是OpenCV自带的基础字体库不需要WinForms再加载中文字体。但注意它的字体只支持ASCII字符如果画中文“玉米粒”会显示成一堆问号。演示源码通常用编号就行真要写中文就用BitmapConverter转成Bitmap后用System.Drawing画不要用PutText。到这里散落玉米粒的计数功能已经完整了。这段流程你以后处理任何“数药片”“数螺丝垫片”都能复用核心只有“阈值连通域”四个字。但现场的图像不会都像演示图那么干净玉米粒一旦互相接触简单连通域会把几颗并成一坨下一章解决这个问题。4. 粘连颗粒的破解方案距离变换与分水岭的完整落地把玉米粒倒进黑色盘子里总有几颗挨在一起。连通域统计会把两颗接触的玉米粒当成一个区域计数结果偏少。分水岭算法的价值就在这把粘连在一起的区域按颗粒中心的几何特征切回独立个体。4.1 粘连到什么程度必须用分水岭先明确一个判断标准如果你二值化后发现最大的连通域面积超过单颗玉米粒面积的1.3倍就有粘连了。1.3倍以内可能是单颗粒边缘不规则超过这个比例基本可以断定是多粒接触。这种情况直接用连通域面积除平均单粒面积来估个数不靠谱因为单粒面积本身有大小差异并且接触的排列方式不同面积分布不可预测。分水岭的思路是每个玉米粒的内部都有一个离边缘尽量远的中心点这些中心点在距离变换图上表现为局部极值。从这些中心点“涨水”遇到颗粒之间的边界就会停下来最终边界线把粘连区域切开。调好参数后粘连两三粒的图也能数对。4.2 距离变换把二值图变成灰度地形图分水岭算法不能直接在二值图上跑因为二值图的像素值只有0和255分水岭找不到区分不同颗粒的灰度起伏。距离变换先把每个前景像素替换成“它离最近背景像素的距离”这样颗粒中心的值最大、边缘的值为小形成山峰和山谷的地形。private Mat DistanceTransform(Mat binary) { // L2欧氏距离输出浮点Mat Mat dist new Mat(); Cv2.DistanceTransform(binary, dist, DistanceTypes.L2, DistanceMaskSize.Mask5); // 归一化到0-255显示和阈值都方便 Mat distNorm new Mat(); Cv2.Normalize(dist, distNorm, 0, 255, NormTypes.MinMax, MatType.CV_8U); return distNorm; }参数说明DistanceTypes.L2是最常用的欧式距离计算方式DistanceMaskSize.Mask5指用5x5的结构元素计算距离精度比Mask3更高性能在桌面平台上没有明显差别。dstType参数MatType.CV_8U表示归一化到8位图。如果省略这个参数Cv2.Normalize默认输出与输入同类型这里输入是CV_32F的话输出还是浮点图后续阈值操作就要重新指定阈值类型。逻辑说明DistanceTransform的输入是CV_8UC1二值图前景必须为白色255背景为黑色0。输出归一化后玉米粒中心最亮边缘是暗的。这个时候再做一次阈值比如取0.4倍最大距离就能把每个玉米粒的“核心区域”提取出来这个区域是分水岭的种子。种子不能太大——太大了相邻颗粒的核心区域又会连起来也不能太小——太小了一颗玉米粒可能没有或者出现两个种子。4.3 实现分水岭确定前景、确定背景、标记图三步走OpenCvSharp的Cv2.Watershed调用方式比较特殊它直接修改你传入的标记图marker而不是返回一个新图。所以调用前要把标记图准备好背景标0每个种子标一个从1开始的正整数。private int WatershedCount(Mat gray, Mat binary) { // 1. 距离变换 Mat dist new Mat(); Cv2.DistanceTransform(binary, dist, DistanceTypes.L2, DistanceMaskSize.Mask5); // 2. 取0.4倍最大距离作为阈值提取每粒玉米的中心种子 double minVal, maxVal; Cv2.MinMaxLoc(dist, out minVal, out maxVal); double thresh 0.4 * maxVal; Mat sureFg new Mat(); Cv2.Threshold(dist, sureFg, thresh, 255, ThresholdTypes.Binary); // 3. 统计种子的连通域每个连通域是一个标记 Mat labels new Mat(); Mat stats new Mat(); Mat centroids new Mat(); int seeds Cv2.ConnectedComponentsWithStats(sureFg, labels, stats, centroids); // 4. 膨胀原始二值图作为确定背景 Mat kernel Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(11, 11)); Mat sureBg new Mat(); Cv2.Dilate(binary, sureBg, kernel); // 5. 未知区域 确定背景 - 确定前景 Mat unknown new Mat(); Cv2.Subtract(sureBg, sureFg, unknown); // 6. 创建marker种子区域标为对应标签未知区域标0 Mat marker new Mat(); labels.ConvertTo(marker, MatType.CV_32S); for (int y 0; y marker.Rows; y) { for (int x 0; x marker.Cols; x) { if (unknown.Atbyte(y, x) 0) { marker.Set(y, x, 0); } } } Cv2.Watershed(gray, marker); // 7. watershed后marker为-1的是分界线统计正标签数量 int count 0; for (int i 1; i seeds; i) { Mat mask new Mat(); Cv2.Compare(marker, i, mask, CmpTypes.EQ); int area Cv2.CountNonZero(mask); if (area 100) { count; } } return count; }逻辑说明整个过程分七步。第一步距离变换后用阈值mark的轮廓提取sureFg。第二步中thresh取0.4倍最大距离。玉米粒越大这个距离值越大玉米粒越小thresh自适应兼容不同分辨率。第三步中seed的连通域数量就是分水岭的种子数。注意这里种子不是一个一个手动放的而是从距离图自动挖出来的天然对应每个玉米粒的中心区域。第四步的膨胀操作用来生成“确定背景”膨胀一圈后所有前景边缘外的像素都算背景。未知区域是sureBg和sureFg之间的灰色地带。分水岭的真正目的就是在这些地带里找最优分割线。第六步中labels.ConvertTo(marker, MatType.CV_32S)是关键类型转换labels是连通域标记图类型是CV_32S但Watershed要求marker必须是最小值为0、种子为正整数的CV_32S图。把unknown区域置为0后分水岭算法从这些0位置开始漫水遇到种子区域的边界就把边界线画在-1上。第七步的统计中Cv2.Compare(marker, i, mask, CmpTypes.EQ)把marker里等于i的像素选出来CountNonZero统计这一粒分割后的面积。如果面积太小说明这个种子可能是噪声。参数说明膨胀核Size(11,11)椭圆这个大小通常大于两颗玉米粒之间的缝隙能确保connect后背景完全覆盖玉米粒的轮廓周边。核写太大背景过度扩张会吞掉粘连边缘让分水岭找不到正确的边界写太小背景不完整分水岭可能从边界漏出去。经验值就是比单颗玉米粒直径稍大一点。4.4 分水岭后标记图的边界可视化分水岭算法在marker里把边界像素置为-1。要把这些边界线画到彩色结果图上可以遍历marker把所有值为-1的像素在原始图上标成红色。private Mat MarkWatershedLines(Mat color, Mat marker) { Mat result color.Clone(); for (int y 0; y marker.Rows; y) { for (int x 0; x marker.Cols; x) { if (marker.Atint(y, x) -1) { result.Set(y, x, new Vec3b(0, 0, 255)); // BGR红色 } } } return result; }逻辑说明遍历整个标记图画红线很直观但性能很差——1280x960的图要循环一百多万次。演示源码里无所谓但真实处理大批量图片时建议改用Mat按位操作Cv2.Compare(marker, -1, lineMask, CmpTypes.EQ); Cv2.Set(lineMask, new Scalar(0,0,255), result); 这样直接向量化速度快十倍以上。参数说明Vec3b表示三通道8位的向量(0,0,255)在BGR顺序里是红色。OpenCvSharp里Mat.Set(y, x, Vec3b)可以直接修改像素但同样不推荐在大图里逐像素用你会感受到明显的卡顿。4.5 分水岭的局限性严重堆叠还是不行分水岭也不是万能解药。如果玉米粒堆叠成三层中间那粒完全被周围遮住算法本身就没有逻辑去恢复被遮挡的信息。这种情况下唯一可行的方案是深度相机测高度或者物理上把玉米粒摇散。所以做项目前先和需求方确认他们对“粘连”的定义是三两颗接触还是整堆倒扣在盘子里。前者分水岭能解决后者建议先做个震荡摊平机构再拍照。这一点不算算法坑是需求预期没对齐但踩过的都知道有多痛。5. 玉米粒计数6个常见问题现象、原因、绕开它们的办法OpenCvSharp项目做多了你会发现大部分翻车点不在算法本身而在图像采集环境和WinForms的工程细节。这一章把最常见的6个问题列全每条都按现象、原因、解决三步写。5.1 光照不均导致Otsu阈值失效半边图全是噪点现象同一张图里左侧玉米粒数得准右侧多出几十个碎片状连通域。原因右侧光照被东西挡住背景灰度值整体下降Otsu依据全图灰度分布算出的阈值对右侧来说太低了右侧背景像素落到前景区域变成白色噪点。解决换成自适应阈值Cv2.AdaptiveThreshold。它不再用全局阈值而是为每个像素在其邻域内单独计算阈值。Mat adaptive new Mat(); Cv2.AdaptiveThreshold(gray, adaptive, 255, AdaptiveThresholdTypes.MeanC, ThresholdTypes.Binary, blockSize: 51, param: 15);参数说明blockSize是局部邻域的尺寸必须大于单颗玉米粒的直径像素数。玉米粒直径50像素时blockSize取51合适如果取15每个局部窗口都在玉米粒内部会把颗粒中心判定为背景导致玉米粒断裂。param15是一个偏移量正值会把局部阈值抬高抑制背景噪声。出现碎片就加大param出现断裂就减小param这是最常调的旋钮。5.2 PictureBox上框的位置和原图对不上现象在Mat上画好矩形PictureBox显示时框的位置看起来偏了或者你在PictureBox上点击某颗玉米粒换算到Mat坐标后画出来的点不在那颗玉米粒上。原因PictureBox的SizeModeZoom会等比缩放图像如果图像长宽比和PictureBox客户区不一致四周会留白边。鼠标坐标到原图像素坐标的换算必须要扣除这个偏移量。解决写一个坐标换算函数取显示图像在客户区中的实际位置。private Point DisplayToOriginal(Point displayPt) { if (src null) return displayPt; float scaleX (float)src.Width / (float)pictureBox1.ClientSize.Width; float scaleY (float)src.Height / (float)pictureBox1.ClientSize.Height; float scale Math.Min(scaleX, scaleY); int offsetX (pictureBox1.ClientSize.Width - (int)(src.Width * scale)) / 2; int offsetY (pictureBox1.ClientSize.Height - (int)(src.Height * scale)) / 2; int origX (int)((displayPt.X - offsetX) / scale); int origY (int)((displayPt.Y - offsetY) / scale); return new Point(origX, origY); }逻辑说明先算出图像缩放到PictureBox后的统一缩放比scale然后算图像居中后左右上的偏移量。鼠标点击坐标减去偏移量再除以scale就得到原始Mat上的像素坐标。这个映射在缩放模式下是唯一的图像没有形变。如果用了StretchImage模式scaleX和scaleY不相等图像被拉伸坐标换算还得加一步按宽高比分离演示源码不推荐用那种模式。5.3 玉米粒表面纹理把一颗算成两三颗现象单个玉米粒被分成多个小连通域每个面积都比正常单粒小总数明显虚高。原因玉米粒表面有裂纹、色斑阈值后颗粒内部出现黑色孔洞。孔洞把连通域分割成了多个不相连的白色区域每个都小但都被数了。解决先做形态学闭运算把内部孔洞填上再做开运算清外部噪点。在常规流程中把开运算换成“先闭后开”。Mat clean new Mat(); Cv2.MorphologyEx(binary, clean, MorphTypes.Close, ellipseKernel, iterations: 2); Cv2.MorphologyEx(clean, clean, MorphTypes.Open, ellipseKernel, iterations: 1);逻辑说明闭运算先膨胀后腐蚀膨胀能把内部细小孔洞堵住腐蚀恢复轮廓。加iterations2加强填充能力。之后再开运算清除外部小噪点。这一步对玉米粒这种表面纹理丰富的对象特别有效。5.4 开发机跑得好好的客户机器上无法加载OpenCvSharpExtern现象换一台没有装Visual Studio的电脑运行程序启动就抛OpenCVException提示无法加载DLL。原因OpenCvSharp4.Windows包里带原生DLL开发机因为装了完整VS的VC运行库所以不报客户机是干净系统缺少VC Redistributable。解决发布时在输出目录检查x64文件夹里的OpenCvSharpExtern.dll存在。同时项目属性里把平台目标设为x64不要选AnyCPU避免JIT按x86路径加载。客户机预装“微软VC 2015-2022 Redistributable x64”这是最简单稳妥的免罪符。5.5 分水岭把一颗玉米粒切成两半现象粘连问题解决了出现了很多只占正常颗粒一半面积的小块总数反而比人工数多几个。原因距离图的种子出现了“双峰”——同一颗玉米粒内部有两个局部最大距离点比如玉米粒一端有个凸起距离图上形成两个峰分水岭就把一粒切成了两粒。解决先把距离图高斯平滑让凸起形成的局部峰值被抹平再提取种子。也可以把sureFg的阈值从0.4调高到0.5让种子区域只保留最大峰附近的一小块两个峰的距离如果够远0.5阈值能过滤掉较小的那个峰。Mat smoothDist new Mat(); Cv2.GaussianBlur(dist, smoothDist, new Size(7, 7), 0);逻辑说明高斯平滑直接作用在距离图上把凸起的小峰压低大的中心峰保留。做这一步之后分水岭生成的边界线数量明显减少。这个技巧在分割玉米、大豆、药片时通用但平滑程度要控制好核太大会把两个相邻玉米粒挖成一个大种子那就啥也切不开了。5.6 反复打开多张图片程序内存稳步上升现象程序开了一小时内存从150MB涨到800MB最后界面都变卡。原因加载图片时new Mat、转Bitmap时拷贝、每次处理时又生成多个临时Mat没有及时释放。GC会回收托管对象但Mat相关的原生内存由OpenCvSharp的NativeMethods管理回收不可预测。反复打开图片几百次累积的内存就很可观。解决用using或try-finally确保每个临时Mat都调用Dispose。更简洁的做法是把整个处理流程封装成一个方法方法内部用using包住所有局部Mat。private int SafeCount(Mat src) { using (Mat gray src.Clone()) using (Mat binary new Mat()) using (Mat cleaned new Mat()) { // 处理流程 return result; } }逻辑说明C#的using声明的对象在作用域结束时自动调用DisposeOpenCvSharp的Mat实现了IDisposable。这个写法比手动在finally里调Dispose更干净。注意字段级的Mat比如我在第2章定义的src/gray不要盲放在using里因为Form关闭前你可能还要用。原则是局部变量全部using字段变量在FormClosing事件里Dispose一遍。6. 让演示源码真正可用误差率校验、批量测试与模板匹配计数程序写出来以后最需要的是“它到底数得准不准”的客观证据。我做一个方案时通常会顺手搭一个校验面板左边原图右边标注结果图下面放一个DataGridView记录每次的人工数、程序数、误差率。界面逻辑是在计数结果出来之后把程序数填进表格对应列人工数由用户在用NumericUpDown输入后手填误差率自动计算private void UpdateValidationTable(int manualCount, int programCount) { double errorRate Math.Abs(manualCount - programCount) / (double)manualCount * 100.0; dataGridView1.Rows.Add(Path.GetFileName(currentFile), manualCount, programCount, Math.Abs(manualCount - programCount), errorRate.ToString(0.00) %); }这个表格积累几十条记录后平均误差率是否小于1%、哪些图误差大于3%一眼就能看出来。调参数不再靠感觉而是盯着这一列数字改。这个习惯帮我避了很多“感觉差不多了实际放量就翻车”的情况。还有一个进阶技巧如果玉米粒是放在固定凹槽检验盘里的位置规律你可以第一次跑通后把每颗玉米粒的质心和面积存成模板。第二批图片进来后不需要分水岭直接在二值图上找离模板质心最近的连通域做匹配数量一减就知道缺不缺粒。速度比逐张分水岭高一个数量级。但玉米粒如果是随机散落的这个技巧不适用别硬套。最后一个不算技巧的习惯调参时不要只盯着总数。把连通域数量、种子数量、最终计数三个数字一起打印出来。如果连通域数量是120、种子数量是125、最终计数是122你就能判断哪里出了偏差——是连通域合并了、还是种子分裂了。只盯着最终总数很多中间错误会被“碰巧”掩盖换一批图就现原形。我用这个思路在WinForms里做过好几个颗粒计数的小工具每次被问到“你这准不准”我都会先把误差率表格翻出来再说话。演示源码能跑通只是第一步能验证、敢交付才是C#上位机项目真正的分水岭。希望帮到你。本文还有配套的精品资源点击获取
返回列表