我最早接触图像计数,是帮某实验室数细菌菌落。那批培养皿照片拍得歪歪扭扭,光照还不均匀,人工数了一个多小时眼睛都快看瞎了,结果两个人数的数量还不一致。后来我用MATLAB写了十几行代码,几分钟跑完全部照片,误差控制在几个百分点以内,从那以后我就养成了习惯:凡是遇到“数一数图里有多少个XX”的需求,第一反应就是拿MATLAB快速验证一把。
图像计数这件事,听起来不起眼,但应用场景比很多人想象的要广得多。质检线上数零件、工业料仓里统计颗粒数量、生物实验里数细胞和菌落、农业遥感里数树木和作物,本质上都是同一个问题:在一张二维图像里,把“感兴趣的目标”和“背景”分开,然后统计目标的个数。MATLAB在这条路上的优势很明显,图像处理工具箱(Image Processing Toolbox)把大量算法封装得比较完善,不需要自己从零去写滤波、形态学、连通域这些底层实现,可以快速搭建一套能跑的流程。
这篇文章我会把整个流程拆开揉碎,从最朴素的连通域计数讲起,再到粘连目标的分水岭处理,最后把边界截断、噪点干扰、光照不均这些实际会遇到的问题整理成排查思路。不管你之前有没有contact过MATLAB,只要想解决“图像里数个数”这种问题,都可以直接照着操作,再结合你手上实际图片微调参数。
1. 先盘思路:图像计数到底在数什么
1.1 一个朴素的流程:灰度化 → 二值化 → 区分数
图像计数不是一个孤立操作,它背后是一条明确的处理链。我用最简单的话来梳理:任何计数的前提,是让“目标”和“背景”在计算机眼里变得泾渭分明。假如原始图像是彩色的、背景比较杂乱,第一步就要灰度化,丢掉无关的颜色信息;然后通过某种规则(最常用的是阈值)将像素分成前景和背景两类,这就是二值化;二值化之后,图像变成黑白的,目标区域连成一个个“块”,这时候再使用连通域分析,数出这些块的数量。
为什么要强调这个流程?因为很多人在一开始就犯方向性错误——直接拿彩色图像的边缘检测结果来数数,结果边缘断裂、重叠,数量完全没法看。正确的做法是先降维,把信息浓缩成“前景/背景”两个状态,再考虑分割和计数。整个过程就是不断给图像做减法,过滤掉与“数量”无关的信息。
1.2 为什么MATLAB适合干这件事
我始终觉得,工具的选型要结合任务目的。如果是在工业环境里部署到生产线上实时计数,我肯定会优先考虑嵌入式方案、OpenCV配合C++,或者直接用集成好的视觉系统。但如果目标是快速验证、算法调研、离线数据分析,MATLAB的性价比就很高。
具体来说有三个原因。第一,函数接口封装度高。imread读图、rgb2gray灰度化、imbinarize二值化、bwlabel标记连通域、regionprops计算区域属性,这一套下来,新手也能在一个小时内跑通基础流程。第二,可视化调试方便。MATLAB的图片变量可以直接用imshow查看中间结果,每一步都能直观看到“这步处理之后图像变成什么样了”,排查问题时非常高效。第三,矩阵思维和图像天然匹配。图像在MATLAB的底层就是数值矩阵,做像素级计算、加减阈值、逻辑掩码操作都非常直接。
1.3 需要先做减法:想清楚哪些物体要数
这一条算是我反复踩坑后的真实体会。在写第一行代码之前,先问自己三个问题:图像里的目标是什么颜色、什么形状,大概多大?背景是什么样子,是均匀的还是渐变的、有没有纹理?目标之间会不会粘连,粘连严重到什么程度?
这些问题的答案直接决定后续阈值怎么选、形态学结构元素用多大、要不要上分水岭。比如数红色圆形药片,可以用RGB通道分离加圆形度过滤;数灰色零件,在均匀光照下可能用固定阈值就够了;数细胞这类半透明、互相紧贴的物体,则几乎必然要面对粘连分割的问题。先把这些问题想清楚,比闷头调参重要得多。
2. 核心细节解析:每一步在做什么、为什么这么做
2.1 灰度化与滤波:第一步不处理好,后面全部白费
计数流程的起点通常是灰度化。MATLAB里一句grayImg = rgb2gray(originalImg)就能完成,原理是提取R、G、B三个通道的加权和,得到单通道亮度图。不过要注意,如果目标颜色在灰度图上与背景亮度接近,直接灰度化会导致前景背景难以区分。
举个实际例子:数背景中的深蓝色零件,如果直接用rgb2gray,灰度图上零件和背景的亮度差异可能不大,二值化后根本无法完整分割。这时候如果目标颜色鲜明(红、绿、蓝等),可以考虑直接提取对应颜色通道,或者做通道差分(比如红色目标拿R通道减去G通道,目标一下就亮了)。这一步的处理完全取决于图像内容,没有万能公式。
接下来是滤波。图像传感器拍出来的照片往往含有噪点,而噪点会在二值化后变成细微的白色碎点,严重影响计数精度。因此我在预处理阶段通常先做一次平滑滤波,最常见的是高斯滤波imgaussfilt(grayImg, sigma)。sigma的值一般取1到2,取太小去噪效果不明显,取太大会让目标边界模糊、粘连加剧。我常用的做法是先调sigma=2看一眼效果,如果目标边缘细节很重要就降到1.5左右。滤波不是越强越好,它的目的是在保留目标结构的前提下,去掉尽可能多的噪声。
2.2 二值化阈值的三条路线:固定、Otsu、自适应
二值化是决定计数成败的关键一环。它的本质是给每个像素一个判定规则:亮度大于阈值的设为前景,否则设为背景。阈值怎么选,通常有三个思路。
固定阈值最简单,代码就是bw = grayImg > 120。这个数需要人为判断,适合光照非常稳定、目标和背景亮度对比强烈的场景,比如强光下拍摄的深色零件。
Otsu(大津法)是自动阈值的主流方案,MATLAB里用level = graythresh(grayImg); bw = imbinalize(grayImg, level);一句话搞定。它的原理是遍历所有可能的阈值,找到一个让前景和背景两类像素“类间方差最大”的分界点,本质上是自动寻找一个最优分割点,使得两类之间差异最大、每类内部差异最小。对于绝大多数图像,Otsu比手动阈值靠谱,我日常90%的场景都先跑一遍Otsu看效果。
自适应阈值则解决了光照不均的问题。当图像一侧亮一侧暗、同一背景在不同位置的灰度差异超过目标与背景的差异时,全局阈值必然失效。核心思路是使用imbothat或imtophat,做形态学顶帽/底帽变换来消除背景光照变化的影响。顶帽变换用大尺寸结构元素估计背景,然后用原图减掉这个背景估计值,得到“去除背景趋势”的图片,之后再跑Otsu就很干净了。
| 方法 | 代码示例 | 适用场景 | 注意事项 |
|---|---|---|---|
| 固定阈值 | bw = grayscale > T | 光照恒定、对比度高 | T依赖人工经验 |
| Otsu自动阈值 | level = graythresh(im); bw = imbinarize(im, level); | 光照均匀、双峰明显 | 光照不均时失效 |
| 顶帽/底帽+Otsu | im = imtophat(im, strel("disk", 30)); bw = imbinarize(im, graythresh(im)); | 光照不均、背景渐变 | 结构元素尺寸需大于目标直径 |
2.3 形态学操作:让计数偏差从“乱七八糟”变成“基本可控”
二值化之后,图像通常是黑底白目标,但常常带着毛刺、空洞、小碎块。如果直接拿这个二值图做连通域计数,会得到大量虚假目标。这时候就该形态学上场了。
膨胀(imdilate)让白色区域向外扩张,用来连接断裂的区域;腐蚀(imerode)让白色区域向内收缩,用来去除细小突起和碎点。开运算(先腐蚀再膨胀)能有效去除微小噪点,同时保持大目标的整体形状和面积不变;闭运算(先膨胀再腐蚀)能填补目标内部的小空洞,让目标块更完整、更连贯。
具体到计数场景,我通常会组合使用:先bw = imopen(bw, strel("disk", r))扫掉小噪点,再bw = imclose(bw, strel("disk", r))填补空洞。这里结构元素用圆盘形(disk)而不是矩形(rectangle),原因是圆盘更接近大多数自然目标的形状,不容易让边界产生棱角。r的选择取决于噪点尺寸与目标直径的比例。如果目标最小直径是20像素,噪点直径是2像素,那我用r=3或者r=5的开运算,既不会损伤真实目标,又能清掉绝大部分噪点。
形态学操作还有个额外的好处:对轻微粘连的目标,可以通过一次腐蚀让它们分开,计数后再通过膨胀把面积还原回来。不过在腐蚀的时候要小心,过度腐蚀会把小目标直接消掉,反而少计数。
2.4 连通域分析:数数量、量尺寸、做筛选
形态学做完之后的二值图已经比较干净了,此时可以进入真正的“数数”环节:连通域分析。MATLAB里用cc = bwconncomp(bw, 8)来获得连通域信息,它返回一个结构体,里面包含每个连通域的总像素数和像素索引列表。如果想进一步拿到每个区域的面积、质心、外接矩形等属性,就交给stats = regionprops("table", bw, "Area", "Centroid", "BoundingBox")。
这里有个细节必须强调:bwconncomp的第二个参数选择4连通还是8连通,会直接影响计数结果。4连通表示只有上下左右四个方向相邻的像素才算同一个区域;8连通则把对角线上的像素也算作相邻。对于颗粒、菌落这类目标,我通常用8连通,因为实际物体边缘往往是对角接触的,8连通能更接近人的视觉判断。但如果目标和目标之间挨得非常近、甚至中间只有对角线相接,8连通容易把它们连成一块,这时换成4连通反而更准确。
area属性特别有用,可以用来做面积筛选。比如噪点清除后仍然剩了一些比真实目标小很多的碎块,我可以直接写一条规则:validIdx = stats.Area > 500,把面积小于500像素的统统不算数。类似地,如果目标都近似圆形,我可以用stats.Eccentricity(离心率)过滤掉异常细长的假目标;如果目标是特定颜色,我还可以结合原始彩色图,提取连通域对应位置的RGB均值来判断颜色是否符合预期。这些属性组合起来,相当于给计数逻辑加了一层或多层过滤器,让结果稳定可靠得多。
3. 从零到一:MATLAB计数实操全流程
3.1 准备测试图像与实验环境
实际操作之前,先把实验环境备好。我建议使用较新版本的MATLAB,内置的图像处理工具箱一般都比较完整。如果你的环境里没有Image Processing Toolbox,用ver命令可以直接查出来;缺工具箱的话,重新安装或者补装对应的许可证就行,不然imread、bwlabel这些函数都会报错。
测试图像怎么准备也有讲究。第一张测试图我的建议是:目标数量适中(比如20到100个之间)、目标明显区别于背景、目标之间尽量分离、光照较均匀。先用这样一张“理想图像”把流程跑通,再去处理那些“真实世界”里的复杂图像。整个过程会省心很多。
3.2 基础版连通域计数:适用于目标分开、背景干净的情况
下面是基础版计数代码,对多数分离目标图像有效。这段代码我按照“读图 → 灰度 → 二值化 → 形态学清理 → 连通域分析 → 可视化标注”的顺序组织,每一步都有注释,调试时可以插入imshow查看中间结果。
% 读取图像 origImg = imread("test_image.png"); figure; imshow(origImg); title("原始图像"); % 灰度化 if size(origImg, 3) == 3 grayImg = rgb2gray(origImg); else grayImg = origImg; end % 高斯平滑去噪 grayImg = imgaussfilt(grayImg, 1.5); % Otsu自动阈值二值化 level = graythresh(grayImg); bw = imbinarize(grayImg, level); figure; imshow(bw); title("Otsu二值化结果"); % 形态学清理:开运算去噪声,闭运算填空洞 bw = imopen(bw, strel("disk", 3)); bw = imclose(bw, strel("disk", 3)); % 面积筛选:去掉太小的碎片 cc = bwconncomp(bw, 8); stats = regionprops("table", bw, "Area", "Centroid"); minArea = 20; filteredIdx = find(stats.Area >= minArea); % 计数 count = length(filteredIdx); fprintf("检测到目标数量: %d\n", count); % 标注结果 imshow(origImg); hold on; centroids = stats.Centroid(filteredIdx, :); plot(centroids(:, 1), centroids(:, 2), "ro", "MarkerSize", 8); hold off;这个基本流程对应80%以上“目标清晰、互不粘连”的场景。写完之后重点验证两件事:一是面积阈值minArea合不合理,二是标注点是否准确落在每个目标中心。如果图上明显有目标没被标记,多半是二值化把目标和背景融合了;如果有多个红点落在一个目标上,说明可能存在过分割。
3.3 处理粘连目标:分水岭分割怎么用
现实场景里目标往往紧贴着:细胞成群、零件堆叠、硬币挨着硬币。直接做连通域分析,这些目标会被合并成一个连通区域,计数自然就不准。这时候需要先把“粘在一起的一堆”分成“一个个单独的个体”,最常用的算法是分水岭分割(watershed)。
分水岭的直觉理解是:把梯度图想象成地形图,目标中心是盆地,目标边界是山脊,然后从盆地底部慢慢注水,水漫到山脊时不同盆地的水就会相遇,相遇的地方形成分水岭线,这条线就是目标之间的分割边界。
具体到MATLAB实现,代码逻辑是这样:
% 输入:经过预处理的二值图 bw,目标是分出一个一个的独立物体 % 第一步:计算距离变换,每个前景点到最近背景点的距离 D = bwdist(~bw); % 第二步:对距离变换图取负,让物体中心成为“盆地” D = imcomplement(D); % 第三步:通过强制最小值来避免过分割 D = imhmin(D, 2); % 第四步:执行分水岭 L = watershed(D); % 第五步:把分水岭线之外的区域(标记为0)从二值图中去掉 bwSeg = bw; bwSeg(L == 0) = 0; % 第六步:对分割后的二值图重新做连通域计数 ccSeg = bwconncomp(bwSeg, 8); statsSeg = regionprops("table", bwSeg, "Area", "Centroid"); countSeg = height(statsSeg);这里面要特别注意imhmin这个函数。分水岭算法最著名的坑是“过分割”:因为图像里有大量局部极小值,算法会在很多无关紧要的小凹陷处也建立分水岭,导致一个目标被切成好几块。imhmin通过压低那些深度不足的极小值,把它们“抹平”,从而控制分割粒度。参数2表示只有深度超过2的局部极小值才会被保留成分水岭起点。这个值越大,分割出来的区域越少,反之越多。实际操作中,我会从1开始试着调到3左右,盯住粘连最紧的那一簇目标看效果。
分水岭是这一整条任务链里最需要调试的部分,没有一个参数是万能解。你面对的图像的粘连程度、目标大小差异都不同,每次都要结合可视化结果换参数。
3.4 参数调节与可视化验证:边调边看结果
图像处理很少一次性成功,中间产物可视化几乎是必需操作。我在调试时习惯建一个“图像管线”视图,也就是在一个figure里用subplot把各阶段结果都摆出来:原始图、灰度图、二值图、形态学结果、连通域标注图、分水岭分割图。好处是能快速判断问题出在哪个环节。
举一个实际案例:之前处理一批药片计数,二值化之后总有几个药片被吞掉边缘。从subplot里看到二值图发现,药片外圈是白色糖衣、中间是深色药物,灰度化后中间部分和背景亮度相近,Otsu把中间区域判成了背景,导致药片变成“空心的圆环”,面积属性异常。解决办法是改用闭运算补洞,或者采用imfill填充连通域内部空洞。没有可视化,这类问题很难靠直觉定位。
给一个新手的提示:每次修改参数后,都要同时看“数量”和“标注图”,不要只看一个数字。数量对不代表标注位置对,过分割和漏分割有可能恰好把数量补到一致,但这种“巧合”换一张图就崩了。以标注图为准,结合具体目标形状来判断参数是否合适,这才是可靠的验证方式。
4. 常见问题与排查技巧实录
4.1 边界截断的物体要不要数
如果目标紧贴图像边缘,连通域分析时这部分目标会被生硬截断,面积比真实目标小,很容易被“面积过低”规则过滤掉,或者被算成一个异常目标。
我的处理习惯是:如果是统计“总量”,比如在视野内的菌落总数,那么边缘截断的菌落也应该算进去,因为它们是真实的个体;如果是识别“完整目标”——比如质检中只关心完好的药片——那么边缘截断的目标就不参与计数。
实现上可以通过比较连通域的边界位置和图像边界来判断。stats.BoundingBox返回每个连通域的左上角坐标和宽高,如果左上角x坐标接近1或者x+宽度接近图像宽度,说明它接触了左右边界;y方向同理。在筛选阶段把这些边界接触目标单独分类就行。如果要保留它们,就放宽面积阈值的判断——对接触边界的连通域不应用面积下限,或使用一个更低的面积下限;如果不想保留,直接过滤掉。
4.2 粘连成簇的目标分割过度或分割不足
分水岭的实际表现经常会走两个极端。一是过分割:目标内部存在纹理、凹坑,距离变换图上有很多局部极小值,分水岭就把一个真实目标切成了几块。二是欠分割:粘连太紧密、目标之间几乎没有灰度梯度,分水岭找不到山脊线,一簇目标仍然被当成一个整体。
对付过分割,优先调整imhmin的参数,把抑制深度调大一些,减少无效分割。同时检查预处理:如果图像本身噪声偏高,建议先增强高斯滤波强度。对付欠分割,可以试试给二值图做一次距离变换前的小幅度腐蚀(把目标收缩,让目标之间的“缝隙”变得更明显),然后分水岭,再膨胀回来。另外,如果目标之间黏连处有色彩差异,可以考虑在RGB某一个通道上做分水岭,有时能比灰度图分割得更准。
4.3 噪点被误判成目标
图像里的微小亮点、传感器噪声、JPEG压缩块,经二值化后经常变成独立的小连通域。面积过滤是个有效办法,但需要先知道目标的大概尺寸范围,否则容易误伤小目标。
更稳妥的思路是先用形态学开运算统一清理一次。开运算有一个特性:对于结构元素无法容纳的细小目标,会被直接消除,而大目标基本不受影响。如果经过开运算后仍然有碎碎的小区域,再用bwareaopen(bw, minSize)精确过滤掉像素数少于minSize的连通域。这个函数不需要先bwconncomp,直接传入二值图和最小面积就返回清理后的结果,代码上更省事。minSize怎么定?先在图上框出一个真实目标,用regionprops看它的面积,然后取该面积的1/2到1/3作为阈值。
4.4 光照不均导致阈值失效
光照不均是工业落地场景里最普遍的干扰因素。如果图像左上角亮、右下角暗,Otsu出来的全局阈值往往在亮区把背景也带成了前景(过分割),在暗区把目标淹没在背景里(漏分割)。此时最有效的工具是形态学顶帽变换。
顶帽变换的实现思路是先用一个较大的结构元素做开运算,估计出“背景亮度面”,再用原图减去这个背景面,把光照变化带来的缓慢灰度漂移消除掉。注意结构元素尺寸必须大于目标的最大直径,否则会把目标本身也纳入背景估计,导致目标消失。例如目标直径约20像素,背景光照在大约200像素尺度上变化,就可以用半径50~80像素的圆盘做顶帽,效果通常不错。跑完顶帽再跑Otsu,整体分割稳定性会好很多。
5. 扩展应用与个人经验
5.1 用颜色、形状属性做多层筛选
连通域的面积和数量只是基础,真实业务往往需要更精细的筛选。例如在同一幅图里数出“红色零件有几个,蓝色零件有几个”,基础二值化把两种颜色都当成前景,连通域数得到的是总数。这时候可以在二值化的基础上,进一步利用每个连通域所对应的原始彩色像素均值来判断颜色类别。代码思路是:先用labelmatrix得到一个标签矩阵,然后遍历每个标签号,提取坐标对应的RGB值,计算平均色相,再根据预设的颜色范围分类。
形状属性也能帮助筛选。regionprops提供的Eccentricity(离心率)接近0表示近似圆形,接近1表示细长条;Solidity(凸度比)反映目标轮廓是否规整;MinorAxisLength和MajorAxisLength能给出目标长短轴。比如筛选“完整圆形药片”时,可以要求面积在一定范围内、离心率低于0.3、凸度比大于0.9。这些规则相互组合,即便省去复杂的机器学习分类,也能在工程场景下实现较高准确率。
5.2 批量处理多张图像的技巧
如果一次要处理几十张图片,手动一张一张点开看会累死人。我建议先单独调好一张图的参数,然后写一个循环,把所有文件读进来统一跑。一个大坑是:不同图片的光照、目标大小可能有差异,直接套用同一套阈值到所有图上很容易翻车。如果图像间差异很大,就不能用固定阈值,最好每张图都动态计算Otsu阈值或自适应阈值。还可以用dir遍历文件夹、fullfile拼接路径,统一读取。处理完成后把每张图的计数结果保存在表格里,最后用writetable导出Excel或CSV,方便后续统计和做记录。
还有一个从实际项目里得到的体会:批量处理时,最好在每张图处理完之后自动保存一份“标注过”的结果图,文件名沿用原图名加后缀_result。这样如果批次处理完毕发现哪张图数量异常,可以马上打开标注图定位问题原因,而不是重新跑一遍又只看到一个孤立数字。
5.3 几个值得记住的调参经验
第一,结构元素尺寸优先参考目标直径。无论是开运算、闭运算还是顶帽变换,结构元素的尺寸都应该根据“目标多大”来定。如果目标是20像素的圆,开运算用半径30的圆盘就会出问题,目标会被当成噪声清掉。设计一套管线时,先从图像里量一下目标像素尺寸,所有参数都围绕这个基础量来估计。
第二,二值化结果差,不要急着调二值化参数,先回头检查预处理。很多分割问题其实是光照不均和噪声引起的,预处理多做一步,二值化就轻松很多。我一直把预处理当成两个基本任务:打光(顶帽/底帽矫正背景)和除尘(高斯滤波+开运算)。
第三,计数结果要设置人工复核机制。可以抽取10%的图片人工核对,如果误差超过预期,立刻停下来检查流程,不要继续跑批量任务。拿我自己来说,通常设置5%以内的误差才算可用,超过这个范围肯定有某个环节的参数不适合这批数据。
写在最后:一次实践换来的几点体会
说到底,MATLAB图像计数的核心不是在“MATLAB”这三个字上,而是对图像处理链路每一步的掌握和参数的因地制宜。灰度化、二值化、形态学、连通域分析、分水岭分割,这套组合拳掌握之后,你会发现迁移到OpenCV或者Python的scikit-image也只是一层语法差异,底层逻辑是一样的。
从个人经验来说,有几个小习惯确实能提升效率:一是每次调试都保留中间图像的可视化输出,随手截图存下来对比;二是参数尽量用变量名集中管理,而不是散落在代码各处;三是计数之后一定要做标注可视化,看到标记位置与目标实际的对应关系才敢信任那串数字。图像处理这东西,一眼看结果是很快的,但真正让自己放心,还得靠对每一步含义的理解。希望这篇实操整理能帮你少走一点弯路。