基于Matlab的谷物颗粒数量计数识别系统
做谷物颗粒计数这件事最早是帮一个做种子质量检测的朋友救急。他们实验室每天要数几千粒玉米用于千粒重测算和发芽率实验的样本统计靠人工在数粒板上一个个点费眼睛不说还容易数错。后来我琢磨着能不能直接用Matlab图像处理把这活儿给自动化了于是就有了这套“基于Matlab的谷物颗粒数量计数识别系统”。这套系统说白了就是用摄像头或者手机拍一张玉米粒的俯视图经过预处理、分割、粘连处理最后自动输出玉米粒的数量并且把每个被识别出的颗粒在图上圈出来方便人工核对。这套东西适合谁用农业科研单位、粮库质检人员、种子公司的实验员以及做机器视觉课设或毕设的同学。它不依赖昂贵的工业相机和专用光源一台普通电脑加一个几百块的USB摄像头甚至手机拍的照片都能跑起来。我先把整个实现思路、关键算法、踩坑记录都整理在下面照着做你也能搭出一套实用的计数工具。1. 系统整体思路与设计取舍1.1 为什么把方案锁定在Matlab上市面上做图像识别计数PythonOpenCV是很常见的路子但在这类谷物计数场景里我更推荐Matlab原因很实际一是Matlab自带的Image Processing Toolbox封装非常完善像灰度化、二值化、形态学处理、分水岭分割这些函数都是现成的不需要自己从头写算法二是图像处理在Matlab里可以直接以脚本方式交互调试图窗里看到什么变量是什么值一目了然这对反复调整参数太重要了三是GUI开发用App Designer或GUIDE能快速搭出界面实验人员不会写代码也能操作。这套系统的整体流程分为五大环节图像采集、图像预处理、图像分割、颗粒计数与结果标注、交互界面封装。采集环节解决“拍得清楚”预处理解决“把玉米粒和背景分清”分割环节解决“粘连玉米粒怎么拆开”计数环节解决“怎么数得准”最后封装成界面让非技术用户也能用。1.2 方案选型背后的几个关键决策我做这套系统时遇到过几个需要决策的点在这里说清楚选择的原因。第一是图像采集方案。工业面阵相机效果最好但成本高、驱动麻烦。我最终选择的是普通USB摄像头配合背光板或者直接用A4白纸当背景把玉米粒倒在浅色托盘里拍摄顶部俯视图。为什么用浅色背景因为玉米粒通常是黄色到深黄色和浅色背景的灰度差异明显肉眼能分辨的对比度在算法里就是像素灰度值差距差距越大后面的阈值分割越简单。第二是静态图像计数还是动态视频流计数。动态视频流听起来更“智能”但实际效果未必好——玉米粒在传送带上移动时会产生运动模糊、遮挡和重复帧等问题算法复杂度会翻倍。对于实验室场景一次拍一张静态图数完再换下一批这完全够用。所以我选择静态图像处理路线先把核心计数算法跑通后续如果真要上产线再叠加上目标跟踪模块也不迟。第三是颜色信息用还是不用。玉米粒有黄色、白色、红色等不同品种彩色图像里RGB三通道信息其实可以用于区分不同颜色的玉米但同时也增加了光照影响的复杂度。当前系统面向的是一般黄玉米计数所以选择把RGB图像转成灰度图来处理这样算法更稳定、速度更快。如果遇到多颜色混杂的样本可以在灰度化之前先做颜色空间转换比如转到HSV空间提取特定色相范围这部分我在后面的扩展思路里会说。1.3 系统能力边界与预期效果在正式动手前先得明确这套系统的能力边界。它适合处理散粒状态的玉米样本也就是人为把玉米粒均匀撒在托盘里的情况单张图像能处理的颗粒数量在50到500粒之间再多就会出现大面积堆叠分割难度剧增。计数准确率方面在光照均匀、粘连不严重的条件下准确率可以做到95%以上也就是300粒的样本大概数错几粒到十几粒的水平如果粘连特别严重准确率会下降到90%左右。这里要提前给读者打个预防针不要期望一个开箱即用的算法对任意乱七八糟的图像都能达到100%准确。图像质量决定了算法效果的上限算法只是去逼近这个上限。所以实际操作中采样规范化比调参更重要。2. 图像预处理——把“看得清”变成“算得准”2.1 灰度化与光照校正经过摄像头或手机拍摄的原始图像是RGB三通道彩色图直接处理的话数据量是灰度图的三倍而且容易受环境光色温影响。所以第一步就是把RGB图像转成灰度图。Matlab里一句rgb2gray就搞定它按亮度公式计算每个像素的灰度值。但拍摄时候最恼人的一个问题是光照不均匀。比如左侧有窗户光、顶上日光灯直射托盘里就会出现半边亮半边暗的情况。这种亮度差异会直接影响后续二值化——如果按全局阈值分割暗区域的玉米粒可能会被当成背景亮区域的背景又可能会被当成玉米粒。对付这个问题我用的是“顶帽变换”Top-hat变换它可以扣除不均匀的背景亮度只保留比周围更亮或更暗的颗粒信息。对于浅色背景上的深色玉米粒改成底帽变换也可以达到类似效果。% 读取图像并转为灰度 img imread(corn_sample.jpg); gray rgb2gray(img); % 使用形态学顶帽变换校正光照不均 se strel(disk, 30); % 结构元素半径要大于单颗玉米粒半径 tophatImg imtophat(gray, se); % 对校正后的图像做增强 enhanced imadjust(tophatImg);注意结构元素strel(disk, 30)的半径参数不是随便填的。形态学开运算会把比结构元素小的亮区域抹掉如果结构元素太小会把玉米粒本身也抹掉一部分如果太大背景亮度变化又无法被有效拟合。根据我拍摄样本的经验当玉米粒直径占图像宽度的1/20时结构元素半径取20~40之间比较合适。实际操作时可以用imdistline工具手动量一下图中玉米粒的像素直径再反推结构元素的大小这是最稳妥的做法。2.2 去噪——不要让椒盐噪声干扰计数玉米粒表面并不是光滑均匀的它顶端有凹陷、胚部颜色深浅不一这些天然纹理在灰度图上都会形成局部噪声。再加上拍摄环境中的灰尘图像上经常出现一些细小的暗点或亮点。这些噪声如果不处理会在二值化后形成假目标导致计数结果虚高。中值滤波是对付椒盐噪声最好用的手段它的原理是把每个像素的值替换为其邻域内所有像素值的中位数。为什么不用均值滤波因为均值滤波在降噪的同时会把边缘模糊掉而玉米粒计数最后恰恰要靠边缘把颗粒分出来边缘越清晰越好。中值滤波能较好地保留边缘信息计算量也小。% 中值滤波去噪窗口大小3x3即可 denoised medfilt2(enhanced, [3 3]);窗口大小的选择同样有门道。3x3的窗口可以滤掉孤立的椒盐噪声同时不会把玉米粒表面的纹理细节全部抹掉。如果玉米粒本身较小直径只有十几个像素窗口就不能再大了否则会把玉米粒整体抹模糊导致后续分割粘连更严重。如果图像分辨率较高、玉米粒直径在100像素以上可以尝试5x5窗口进一步平滑。2.3 二值化——灰度图到黑白分明的分割预处理完成后需要把灰度图像变成二值图像也就是每个像素只取0背景或255前景两种值这一步是做分割的关键。二值化最核心的就是阈值怎么定。我强烈推荐用Otsu方法graythresh函数它的核心思想是最大化前景与背景之间的类间方差自动算出最优阈值完全不需要人工干预。为什么不用固定阈值因为不同批次的玉米颜色不同、不同环境光照不同固定阈值换个场景就不适用了而Otsu是自适应算法每一张图都会算出一个适合当前亮度分布的阈值鲁棒性好得多。% Otsu自适应阈值 level graythresh(denoised); bw imbinarize(denoised, level);二值化之后的图像通常会有两类问题一类是背景里的孤立噪点被误判为前景形成白色小点另一类是玉米粒内部的凹陷区域被误判为背景形成颗粒内部的黑洞。这两类问题可以分别用形态学开运算和闭运算来修正。开运算是先腐蚀后膨胀可以消除小的孤立噪点闭运算是先膨胀后腐蚀可以填充颗粒内部的小空洞。% 形态学清理先去噪点再填充内部空洞 bw imopen(bw, strel(disk, 2)); bw imclose(bw, strel(disk, 5)); bw imfill(bw, holes);这里有个细节imfill(bw, holes)会把所有闭合区域内部的空洞都填满但是如果有两个玉米粒粘连并围出一个封闭的背景区域这个函数会把那个背景区域也错误地填成前景。所以这个操作必须放在粘连分割之前而且后续还需要通过面积筛选来排除这种人为造成的假区域。3. 核心难点——粘连玉米粒的分割与计数3.1 为什么简单连通域计数会失败预处理完成后最直观的计数方法就是统计二值图像里的连通域数量。Matlab里用bwlabel可以给每个连通区域编号再用numel(unique(L))-1得到连通域数量。但这种方式只适用于玉米粒彼此完全分离、没有接触的情况。现实中很少有这种理想状态玉米粒从袋子里倒出来往往会挤在一起、叠在一起形成一片连通的白色区域。这时候硬按连通域数明明里面有5个玉米粒可能只数出1个。具体来说粘连情况大致分三种轻度粘连两粒玉米挨在一起但轮廓还能看出边界中度粘连两三粒玉米挤成一团轮廓局部融合重度粘连多粒叠加大部分边界都看不清。轻度粘连用形态学处理加距离变换就能解决中度和重度粘连需要分水岭算法介入。3.2 距离变换 分水岭——拆开抱团的玉米粒分水岭算法是图像分割里的经典方法思路可以类比成地理上的流域划分。把灰度图想象成一张地形图像素灰度值越高代表地势越高。往地形图里注水水会从低处开始汇聚先形成一个个小水坑局部极小值区域随着水位上升不同水坑的水面会相遇在水面相接处建立“堤坝”这些堤坝就是分水岭线对应图像中不同目标的边界。但在实际二值图像上直接跑分水岭是不行的因为一条裂缝、一个噪声点都可能产生一个局部极小值导致过分割——本来一个完整的玉米粒被切成了几块。因此需要先对二值图像做距离变换。距离变换的原理是计算每个前景像素到最近背景像素的距离也就是说距离值越大的像素越处于目标中心距离值越小的越靠近边缘。这样就把一个形状不规则的二值连通域变成了一个中心高、边缘低的“山包”每个玉米粒对应一个山包顶部。在距离变换图上跑分水岭会把山包按峰值区域切开刚好对应到每个玉米粒的中心位置从而把粘连的目标分离开。% 距离变换 D bwdist(~bw); % 对距离图做高斯平滑避免过分割 D imgaussfilt(D, 1.5); % 强制让背景区域作为极小值防止背景被错误分割 D imimposemin(D, ~bw); % 分水岭分割 L watershed(D); % 只保留原前景区域内的分水岭结果 bw_split bw; bw_split(L 0) 0;这里imimposemin是个容易被忽略但很关键的操作。它的作用是强制指定某些区域必须是极小值点。如果不做这一步背景区域可能也会被分水岭算法切出乱七八糟的线导致结果混乱。而imgaussfilt高斯平滑的作用是抹掉距离变换图上的细小起伏避免每个微小的突起都形成一个分割边界。3.3 分水岭参数对分割效果的影响分水岭对参数比较敏感不同批次的玉米粒图像可能需要微调。主要参数有两个高斯平滑的σ值标准差和距离变换前的形态学开操作结构元素大小。如果σ偏小比如0.5距离变换图保留的细节多分割边界会更贴合颗粒边缘但过分割概率增加如果σ偏大比如3分割结果会更平滑过分割减少但也可能把真正属于两个颗粒的边界也抹掉导致欠分割。我测试下来玉米粒这种尺寸相对均匀的目标σ取1到2之间是甜点区。此外在距离变换之前如果某些颗粒表面的凹陷特别深也可能被误判为分开的区域。这时候可以先对二值图像做一次开运算把颗粒表面的小凹陷修平再进行距离变换能明显缓解这一类过分割。示例代码如下% 对二值图先做小型开运算平滑颗粒表面 bw_smooth imopen(bw, strel(disk, 3)); D bwdist(~bw_smooth); D imgaussfilt(D, 1.5); D imimposemin(D, ~bw_smooth); L watershed(D); bw_split bw_smooth; bw_split(L 0) 0;强调一点开运算的结构元素不能过大否则会把本来就靠得很近的两个颗粒之间的细微“颈部”连接直接腐蚀断导致粘连关系被错误改变。直径3到5像素是一个安全范围。3.4 分割结果的可视化验证分割算法的效果不能只看最终计数结果必须用可视化方式验证每颗玉米粒是否被正确切开。我的习惯是把分水岭边界线叠加在原始灰度图上用红色线条标出分割结果然后肉眼过一遍每个区域是不是恰好对应一颗完整玉米。% 在原图上绘制分割边界 boundary (L 0); boundaryOverlay imoverlay(img, boundary, [1 0 0]); imshow(boundaryOverlay);这一步看起来很土但非常有用。计数错了第一件事应该看分割边界的叠加图判断是过分割一个颗粒被红色边界切断还是欠分割多个颗粒之间没有红色边界还是预处理阶段就出了问题玉米粒和背景没分开。有了这张图调参就有方向而不是瞎试数字。4. 计数实现与结果精度控制4.1 连通域标记与面积筛选分水岭分割完成后图像里相邻的玉米粒已经通过“黑色边界线”隔开了接下来就可以用bwlabel给每个连通域编号并计数了。但这里还有一道重要的关卡——面积筛选。分割之后的连通域里可能混入三种假目标预处理不彻底残留下来的噪点、玉米粒表面碎屑、图像边缘被切掉一部分的玉米粒残片。它们的面积通常远小于或远大于正常玉米粒的面积。统计所有连通域的面积画出直方图会发现正常玉米粒的面积分布在某个较窄的区间内而假目标的面积分布在两端。用regionprops提取面积属性后设定一个合理的最小面积和最大面积阈值把范围外的区域全部剔除就能把假目标滤掉。% 连通域标记 L bwlabel(bw_split); % 提取面积属性 stats regionprops(L, Area, Centroid, BoundingBox); % 计算面积分布确定筛选阈值 allAreas [stats.Area]; minArea 0.5 * median(allAreas); maxArea 1.5 * median(allAreas); % 面积筛选保留有效颗粒 validIdx find([stats.Area] minArea [stats.Area] maxArea); count numel(validIdx);为什么用中位数median而不是平均值来定阈值因为平均值容易受极端值影响比如一个大残片面积是正常颗粒的5倍会把平均值拉高很多导致阈值整体偏移而中位数对离群值不敏感更能代表“正常颗粒面积”的典型水平。以中位数为基准上下浮动50%作为筛选范围在我的测试中效果最稳定。4.2 标记显示与结果输出计数完成后还需要在原始图像上把识别结果标注出来直观呈现给用户。在每颗玉米粒的形心位置画一个圆点并标上序号。% 在原始图上绘制结果 imshow(img); hold on; for k 1:numel(validIdx) idx validIdx(k); centroid stats(idx).Centroid; plot(centroid(1), centroid(2), ro, MarkerSize, 10, LineWidth, 2); text(centroid(1) 5, centroid(2), num2str(k), Color, yellow, ... FontSize, 10, FontWeight, bold); end hold off;这副标注图一方面可用于人工复核另一方面也是做实验记录时的凭证可以导出保存。完整的计数结果还可以写到一个Excel表格里把样本编号、计数数量、图像文件名、处理时间都记录进去方便后期追溯。Matlab里用writetable就能轻松实现。4.3 准确率评测方法与误差分析做这类系统光看“感觉差不多”不行必须建立一套量化评测方法。我的做法是取一批玉米粒先人工数出准确数量或者用电子数粒仪结果作为基准再让系统跑一遍对比误差。误差通常来自三个方向。一是过分割一颗玉米粒被切成两块计数偏大通常是分水岭参数过小或表面凹陷太深导致。二是欠分割多颗玉米粒粘在一起没被切开计数偏小通常是粘连太紧密、边界不明显或高斯平滑σ过大把边界抹平了。三是漏检和误检玉米粒太靠近图像边缘被切掉一半或者背景里的杂物被当成颗粒这类问题主要靠面积筛选和边缘剔除来解决。我自己的一个习惯是对边缘处的连通域做额外判断如果某个区域的边界与图像边缘重叠超过20%就认为它是不完整颗粒直接排除。这个虽然可能漏掉边缘上的完整颗粒但整体上避免了半粒玉米被错误计入而导致严重偏差。4.4 批量处理与自动保存实际工作中不可能一次只处理一张图。种子实验里一次来50个样本、100个样本很常见一个个手动处理太累了。所以我在系统里加了批量处理功能把文件夹里的所有图片路径读取出来循环执行预处理、分割、计数最后生成一张汇总表。% 批量处理文件夹内所有jpg图片 fileList dir(fullfile(samples, *.jpg)); results table(); for i 1:numel(fileList) imgPath fullfile(fileList(i).folder, fileList(i).name); count processCornImage(imgPath); % 封装好的计数函数 results [results; table({fileList(i).name}, count, VariableNames, ... {FileName, Count})]; end writetable(results, count_results.xlsx);封装成函数的过程我建议务必做一次把整个流程从读图到计数提取成一个processCornImage.m函数输入图片路径输出数量和标注图。这样做的好处有三个第一批量处理时只需循环调用第二后续要接GUI函数接口已经现成第三想换算法比如从普通分水岭换成改进算法时只需要改函数内部不用动外层逻辑。5. 实操中遇到的坑与排查方法5.1 常见问题速查表这部分是我自己调试时踩过的坑列成表格方便你对照排查。现象可能原因排查与解决方法计数结果明显偏大玉米粒表面凹陷造成过分割增大高斯平滑σ值先做形态学开运算平滑颗粒表面计数结果明显偏小多个玉米粒粘连成团未分开检查是否缺少分水岭步骤降低σ值增强图像对比度个别颗粒完全没识别出来玉米粒颜色与背景太接近更换背景颜色加强光照使前景背景对分明背景噪声被当成玉米粒二值化后孤立噪点未清理增大形态学开运算结构元素检查中值滤波是否生效图片边缘颗粒被切碎样本超出拍摄范围摆放时留出边界余量做边缘完整性剔除处理速度很慢图像分辨率过高先按比例缩小图像处理完再映射坐标不同批次的玉米颜色不同导致效果差异大灰度化后颜色信息丢失改用HSV空间的颜色阈值分割来区分玉米粒5.2 图像采集时的环境规范很多同学跑来问我“为什么我拍的图算法效果那么差”我让他们发原图一看问题八成都出在图像采集环节。这里分享几条采集规范真的能帮你少走很多弯路。拍摄时把玉米粒均匀摊开尽量不要叠放颗粒与颗粒之间的间隙至少保持2到3像素以上这样能给后续分割留出操作空间。背景选哑光白色或蓝色纸张避免反光。瓷砖、塑料板表面容易产生镜面反射会在图里形成高光区域直接影响二值化效果。光线尽量选用高角度的漫射光避免直射强光在颗粒表面形成强烈反光和阴影。如果发现阴影重可以用一张白纸做成简易柔光罩效果立竿见影。另一个容易忽略的问题是镜头畸变。普通摄像头边缘会有桶形畸变玉米粒在画面边缘会被拉变形面积和形状特征都会受影响。如果精度要求高可以用相机标定工具箱做一次畸变校正效果会好很多。5.3 性能优化建议当图像分辨率较高比如手机拍的1200万像素照片时算法处理速度会很慢尤其在分水岭这一步。实际上对于玉米粒计数任务并不需要那么高的分辨率。我的建议是先把图像缩放到宽度800到1000像素左右再处理处理完计数后把坐标位置按缩放比例映射回原图再做可视化标注。这一招能把处理时间缩短到原来的十分之一左右。Matlab里用imresize就够了。需要提醒的是缩放后玉米粒像素面积可能变小分水岭分割的σ参数也要相应调整否则分割效果会变化。5.4 拓展思路从玉米到其他谷物的迁移这套系统的核心是图像分割与计数并不局限于玉米。换成大米、小麦、大豆、油菜籽只需要调整几个关键参数结构元素大小、面积筛选阈值、分水岭σ值。原因在于不同谷物的尺寸、颜色、形状不同但分割与计数的算法框架是通用的。我在迁移到大米计数时主要改了三个地方一是结构元素的半径大米颗粒更细长disk半径适当调小二是距离变换后的高斯平滑σ值大米表面更平滑过分割问题不严重σ可以调小三是面积筛选的上下限大米比玉米小很多中位数自动适应不需要手动指定绝对面积值。整体代码改动不超过十行迁移成本很低。6. 交互界面封装让非技术用户也能操作6.1 App Designer快速搭建界面算法稳定之后最好再封装一个简单的图形界面否则每次都要在命令行运行脚本、改参数对非技术用户太不友好。Matlab的App Designer是目前官方主推的GUI设计方案拖拽控件的方式和做PPT差不多很容易上手。我的界面布局思路很简单顶部一个“选择图片”按钮中间显示原始图像和结果图像底部显示计数结果和“导出报告”按钮。控件不用多够用就行。关键是要把回调函数写好让点击按钮时能调用我们之前封装好的processCornImage函数。% 按钮回调示例 function selectImageButtonPushed(app, event) [file, path] uigetfile({*.jpg;*.png;*.bmp, 图像文件}); if file 0 return; end fullPath fullfile(path, file); img imread(fullPath); imshow(img, Parent, app.OriginalImageAxes); % 调用计数函数 [count, markedImg] processCornImage(fullPath); imshow(markedImg, Parent, app.ResultImageAxes); app.CountLabel.Text [玉米数量: num2str(count)]; end6.2 编译发布脱离Matlab环境运行如果做出来只是自己用界面版已经足够了。但如果要给实验室其他同事用或者作为课设、项目成果提交建议用Matlab Compiler把程序编译成独立可执行文件。这样目标电脑上不需要安装完整Matlab只需要安装免费的Matlab Runtime即可运行。编译的过程很简单在App Designer或脚本界面选择“打包应用程序”然后按向导选择主文件和依赖文件就可以了。有一点要注意编译之前一定要先跑通所有功能因为编译后的调试比在Matlab环境里麻烦得多。6.3 实际使用体验反馈我把这套系统给种子实验室的朋友用了两周收集到几条很有意思的反馈。第一他们最认可的不是计数准确率而是“标注图”功能因为每个标记的玉米粒都在图上显示出来了数错没数错一眼就能看出来不用盲目信任算法。第二他们希望批量处理时能把每张图的标注图也一并保存而不是只保存数量结果后来我加了图片导出功能实测反馈很好。第三有用户提出能否把计数结果直接接入二维码扫描枪读了样本编号自动记录这个想法很好目前还在开发中。这些反馈也说明一个道理做工具类系统用户体验和结果可视化的重要性不亚于算法本身的精度。你可以不接受所有建议但至少要保证用户能看懂结果、能验证结果。7. 最终成果与个人心得这套基于Matlab的谷物颗粒数量计数识别系统主干流程从图像采集到计数输出全打通了黄玉米的单张图像计数准确率在常规光照和散粒状态下实测可以达到95%以上批量处理一张500万像素图片耗时约2到3秒。如果只是随便拍一张验证速度更快。做课设、毕设或者实验室日常辅助计数这个精度和速度都够用。我在实际开发中踩过最大的一个坑就是一开始死磕分水岭参数结果怎么调都调不好。后来退一步审视整个流程发现问题其实出在预处理阶段——光照不均导致二值化结果本身就存在大量误差分割算法再先进也救不回来。所以现在我的调试顺序永远是先确认二值化结果干净清晰再调分割参数最后才看计数结果。你如果也遇到类似的问题建议按照这个顺序排查会省下不少时间。最后再分享一个小技巧做这类图像计数系统不用一上来就用深度学习。传统图像处理虽然“老”但它的每一步都是可解释、可控制、可调的对于玉米这种形状相对规则的目标算法的稳定性和计算效率其实比深度学习更好。先用经典方法跑通全流程如果有更复杂的场景需求再考虑引入深度学习模型做分割这样既能快速落地又能保证系统的可控性。