做了几年机器视觉相关的项目,我经常被问到同一个问题:用MATLAB怎么数清楚一张图里有多少个物体?这个问题听起来简单,实际上坑很深。图里可能有几十个上百个零件、细胞、菌落,人工肉眼数费时费力还容易数错,写代码自动数又快又稳,但前提是你得真正理解这套流程背后的逻辑。这篇文章我打算把整个思路从头到尾捋一遍,从“为什么能数出来”的原理,到“粘连物体怎么拆开”,再到“数不准的时候怎么排查”,全程用实际案例说话,让零基础的人能跑通,让有点基础的人能避开我踩过的坑。
1. 先把“计数”想明白:分割质量决定一切
1.1 看起来是在计数,实际上是在做分割
很多人第一次接触图像计数,以为难点在“数”这个动作上,代码里写个循环,找到几个亮点就输出几个数,很简单。真正干过就知道,难点从来不在计数本身,而在“怎么把每个物体从背景里干净地切出来”。这一步叫图像分割,分割做得好,计数就是查表的事;分割做得差,后面所有统计都是空中楼阁。
我举个很典型的例子。某工厂产线上的金属垫圈,背景是深色传送带,垫圈是亮色的。最粗暴的做法是灰度化之后直接设一个阈值,亮的地方算物体,暗的地方算背景。但实际图像里往往有反光、有油污、有传送带上的划痕,甚至还有边缘的阴影。阈值一旦选得不合适,垫圈和背景就黏成一团,数出来的数量要么偏少(两个黏一起被当成一个),要么偏多(一个反光斑被当成一个垫圈)。
所以我把整条流水线拆成五个环节:图像采集 → 预处理 → 二值化 → 形态学修正 → 连通域统计。每一步都有它的作用,也都藏着自己的坑。
1.2 为什么不直接用像素亮度找亮点
新手最容易犯的错误,是用imread读进来,然后直接找亮度极值点,或者用find把灰度值大于某个数的像素全挑出来,再数一数这些像素连成了几块。这种思路在理想图像上确实能跑通,但换一张光照不均匀的图就原形毕露:左上角亮、右下角暗,同一个物体在不同位置灰度值差很多,单一阈值根本没法把目标完整地保留下来。
所以在正式分割之前,预处理几乎是必须的。我常用的套路是:先转灰度,再做一次平滑滤波去噪,然后用全局阈值或局部自适应阈值二值化,最后用形态学操作把断开的地方接上、把多余的小点去掉。每一步看起来都简单,但组合起来效果天差地别。
2. 连通域分析与连通域标注:零基础直接能跑的计数方案
2.1 从一张最简单的水果图说起
我先给一个最常规、最容易理解的例子:统计一张图里有多少个橙子。假设背景是白色桌面,橙子颜色偏深且轮廓分明,光照也比较均匀。这种图像用连通域分析几乎一次就能成功。
核心代码非常短,我直接给出一份可以照着跑的版本:
% 读取图像 img = imread('oranges.jpg'); % 如果是RGB图像,转成灰度 if size(img, 3) == 3 gray = im2gray(img); else gray = img; end % 二值化:自动寻找阈值 bw = imbinarize(gray); % 如果目标是暗色,需要反相 % bw = ~bw; % 用形态学开运算去掉小噪声,用闭运算填补内部小洞 bw = imopen(bw, strel('disk', 3)); bw = imclose(bw, strel('disk', 3)); % 去掉面积小于30像素的零碎区域 bw = bwareaopen(bw, 30); % 连通域分析,8连通 cc = bwconncomp(bw, 8); % 统计数量 numObjects = cc.NumObjects; % 获取每个区域的面积、中心点和边框 stats = regionprops(cc, 'Area', 'Centroid', 'BoundingBox'); fprintf('检测到 %d 个物体\n', numObjects);跑完之后,可以用下面这段代码把每个物体的中心点画出来,方便肉眼核对:
figure; imshow(img); hold on; for k = 1 : numObjects plot(stats(k).Centroid(1), stats(k).Centroid(2), 'ro', 'MarkerSize', 10, 'LineWidth', 2); rectangle('Position', stats(k).BoundingBox, 'EdgeColor', 'g', 'LineWidth', 1.5); end hold off;看到图里每个橙子都被红圈和绿框标出来,数量对不对就一目了然了。
2.2 4连通和8连通的取舍
bwconncomp里的第二个参数是连通方式:4连通指上下左右四个方向相邻才算同一个区域,8连通把斜对角也算进去。我的习惯是绝大多数场景都用8连通,因为真实物体边缘不会是绝对水平的,斜向连接的像素在4连通下很容易被误判成两个区域。只有在处理有明显方向性的纹理结构时,我才会考虑4连通。
有一个很值得说的细节:二值化之后,很多区域内部会残留小白点或小黑点。白点叫噪声,黑点叫孔洞,它们都会干扰后续的计数和面积统计。噪声用imopen(先腐蚀后膨胀)去掉,孔洞用imfill(bw, 'holes')填上。这个imfill其实是个很有意思的操作,它在数学形态学里叫“空间填充变换”,逻辑是从图像边界的背景像素出发,什么是连不进去的,那里就是孔洞,然后把洞口封死。
2.3 自动阈值到底在干什么
imbinarize默认用的是大津法(Otsu),核心思想是把所有像素分成前景和背景两类,然后找一个灰度阈值,使得两类像素的类内方差最小、类间方差最大。这个算法不需要你人工调参,在大部分双峰分布图上效果都不错。
但大津法有一个致命前提:图像的灰度直方图必须大体呈现两个峰,前景一个、背景一个。如果光照严重不均,直方图可能只有一个宽峰,或者出现多个峰,这时候大津法算出来的阈值会把大量背景也划进前景。解决的办法有几条路:
- 先做背景减除,用较大半径的形态学开运算估算背景亮度,再从原图中减去;
- 用局部自适应阈值,把图像分成小块,每块单独计算阈值;
- 用
adapthisteq做对比度增强,把灰度分布拉开之后再二值化。
我在后面的实际案例里再展开讲,这里先记住一个判断原则:跑出来的结果如果物体连成一片,不要急着换算法,先看直方图是不是双峰。这能省下大量瞎试参数的时间。
3. 物体粘连怎么处理:分水岭变换的实用玩法
3.1 为什么连通域数不出来个数了
现实中的图像很少像橙子图那么干净。细胞在培养皿里会互相挤压,菌落会长成一片,螺母螺栓堆在一起时轮廓完全重叠。这时候bwconncomp一统计,粘连的一大块只算成一个对象,输出数量必然偏小。
怎么把粘连的区域拆开?我最早学图像处理的时候,老师提过一种思路:两个圆形物体靠在一起,轮廓上会形成“凹下去”的地方,只要找到这些凹陷点,沿凹陷切开就能分开。听着很合理,一写代码就很麻烦,因为每个粘连区域的形状都不一样,凹陷点也找不到通用的数学定义。实际项目中我更常用的是分水岭变换。
3.2 分水岭的核心思想
分水岭的原理可以这么理解:把二值图像看成一片地形,每个前景像素的灰度值就是它的海拔高度;灰度值高的地方是山脊,低的地方是山谷。往这片地形里注水,水会先填满最低的谷底,水位慢慢上升,不同谷底的水快要在山脊处汇合时,修建一条“大坝”把它们隔开。这些大坝围出来的每一个区域,就是一个独立的物体。
听上去有点绕,但在MATLAB里落地其实就是几行代码的事:
% bw是你的二值掩膜,1代表前景物体 % 第一步:做距离变换 D = bwdist(~bw); % 距离变换图取负,让物体中心变成“谷底” D = -D; % 修正局部极小值,避免过度分割(参数2控制敏感度) D = imhmin(D, 2); % 分水岭 L = watershed(D); % L == 0 的像素就是分水岭边界线 boundaries = (L == 0); % 把边界叠加到原图上查看效果 figure; imshow(img); hold on; [bx, by] = find(boundaries); plot(by, bx, 'r.', 'MarkerSize', 1); hold off;这里有两个关键点必须讲清楚。第一,为什么要用bwdist(~bw)做距离变换?原因是原始二值图的灰度只有0和1,分水岭需要的是“有起伏的地形”,距离变换能把每个前景像素到最近背景的距离算出来,距离越远说明越靠近物体内部中心,距离越小说明越靠近边缘。这样就把一个平平整整的连通域变成了有中心高、边缘低的地形。
第二,为什么要加imhmin这一步?imhmin是“H-minima变换”,它把所有深度小于给定值的极小值谷底填平。不加它的话,分水岭对噪声极其敏感,一个物体表面稍微有点毛刺就会多出一个谷底,导致物体被过度分割成好几块。这个参数怎么调?我一般从2开始试,效果不好就往大调,最大一般不超10。
3.3 标记控制的分水岭:更稳的进阶版本
直接用距离变换做分水岭,还有一个隐患:如果物体大小差异很大,小的物体会被周围大的物体“吃掉”,分水岭边界跑偏。更稳的做法是标记控制的分水岭,思路是先找出“一定是物体内部”的种子点,再强制分水岭从这些种子点开始生长。这样种子点标错了,结果也不会太离谱,容错率明显更高。
% 基于上面的 D % 提取局部极小值区域作为种子点 mask = imextendedmin(D, 2); % 用强制最小化技术把分水岭限制在种子点周围 D2 = imimposemin(D, mask); % 再次分水岭 L2 = watershed(D2); % 统计分割后的区域数量 cc2 = bwconncomp(L2 > 0, 8); segCount = cc2.NumObjects; % 可视化 labels = labelmatrix(cc2); rgbLabel = label2rgb(labels, 'jet', 'k', 'shuffle'); figure; imshow(rgbLabel);这个方案我推荐所有做细胞计数、菌落计数、颗粒分析的人直接采用。它的最大优点是不需要手动设置“圆不圆”“多大”之类的形状假设,只要前景分割准了,参数就两个(imextendedmin的高度阈值和imimposemin的强度),调起来比纯watershed直观得多。
3.4 距离变换为什么在分水岭里这么重要
再多说一句距离变换的原理,因为它决定了分水岭能不能拆开粘连。bwdist(~bw)对二值掩膜的反相做欧几里得距离变换,遍历每个背景像素,算出它到最近前景像素的距离;但 MATLAB 计算后会得到每个前景像素到最近背景的距离。我们直觉上说的是“物体中心远离边界”,所以距离值越大越靠内。圆形的物体距离变换下来,中心会形成一个亮斑,两个圆相交的地方会形成马鞍形的谷地——分水岭算法要切的就是这个谷地。理解了这一点,参数调起来就不会瞎猜了。
4. 特殊场景的计数实战:灰度不均、边缘出血与单一色调
4.1 光照不均怎么救:背景减除与局部增强
实际项目里十个有八个会遇到光照不均。我印象最深的一次是在某车间拍金属零件的照片,灯泡正好在上方偏左,图像左边亮右边暗,整个灰度直方图只有一个大包,阈值怎么选都顾此失彼。我第一次用全局阈值跑,结果左边零件和背景黏在一起,右边零件缺胳膊少腿,计数结果是几十个全乱套。
后来我试出来的组合拳是这套:
% 用大半径形态学开运算估计背景 background = imopen(gray, strel('disk', 50)); % 从原图减掉背景,得到光照校正后的图像 corrected = gray - background; % 对比度增强 corrected = adapthisteq(corrected); % 再二值化 bw = imbinarize(corrected);道理很简单:光照不均的本质是背景亮度在空间上缓慢变化,形态学开运算半径足够大的时候,能把物体的细节全部抹掉,只剩下大范围的背景亮度分布。用原图减掉这个背景,等于把光照的“底色”去掉了,剩下的就是纯粹的物体反射差异。这一步之后再用adapthisteq拉一下对比度,效果往往脱胎换骨。
这里有个参数经验:开运算半径要比最大物体的半径大。比如最大零件直径约30像素,半径就至少用40以上,宁可大一点也不能小,太小会把物体边缘当成背景一部分,减出来的图会有严重的“黑晕”。
4.2 图像边缘被截断的物体到底算不算
拍照的时候,总有物体卡在图像边缘。要不要计数,得先跟需求方确认清楚规则。我经历过两个完全不同的项目:一个项目要求“只统计完整的零件”,边缘截断的都不要;另一个项目要求“所有出现在视野里的都算”,哪怕只有一半也得记上。
MATLAB里处理起来也很方便。regionprops返回的BoundingBox是[x, y, w, h],只要判断这四个值和图像尺寸的关系,就能过滤边缘对象:
[rows, cols] = size(bw); validIdx = true(numObjects, 1); for k = 1:numObjects bb = stats(k).BoundingBox; % 如果边框贴边,说明物体可能被截断 if bb(1) <= 0.5 || bb(2) <= 0.5 || bb(1) + bb(3) >= cols - 0.5 || bb(2) + bb(4) >= rows - 0.5 validIdx(k) = false; end end finalCount = sum(validIdx);有一个容易被忽略的地方:物体虽然完整,但因为紧贴边缘,它的形状区域会被边缘“切掉”一部分边缘,导致区域面积偏小。如果后续还要统计面积分布,边缘对象无论算不算数,都要单独标记出来,否则你的面积直方图会在小尺寸那一段莫名多出一堆假峰。
4.3 前景和背景颜色太像,分割不出来怎么办
这是最让人头疼的一类。比如浅灰色的螺丝放在深灰色的橡胶垫上,灰度直方图几乎是单峰,阈值怎么调都分不出边界。传统的灰度方法确实没有太好的通用解法,但有一种形态学办法在某些场景下特别好用:终极腐蚀。
思路是:既然边界分不清,就别直接分割了,改用腐蚀一层一层往里“啃”。把前景区域不断腐蚀,每次腐蚀掉一圈边界像素,最后剩下的核心点就是每个物体的种子。腐蚀的次数可以从1一直试到图像全黑为止,记录下每一次保留下来的区域。两个粘连物体如果接触面积不大,腐蚀几层之后自然会断开成两个独立的小块。每一轮保留的区域就是潜在的“物体核心”,然后用类似标记控制分水岭的思路再生长回去。
这个办法对形状稍微规则的物体很管用,但遇到特别不规则的物体就会出问题——同一个物体可能腐蚀出好几个核心,导致后面一个物体被拆成好几份。所以我现在遇到这种“颜色分不开”的情况,往往会建议换思路:要么改打光,要么换彩色相机用颜色通道差分,要么直接上深度学习。形态学技巧能救急,但别指望它是万能药。
4.4 计数之后顺手做尺寸分布统计
很多人数完数量就结束了,其实regionprops里还有一大堆好用的信息。我在很多项目里发现,用户表面上要“计数”,实际上真正关心的是“这批料的质量波动情况”——尺寸均匀吗,有没有特别小的碎料混进来。
regionprops支持的属性非常多,我只列几个常用的:
| 属性 | 表示内容 | 典型用处 |
|---|---|---|
Area | 区域像素面积 | 判断大小是否达标 |
MajorAxisLength/MinorAxisLength | 椭圆长轴/短轴长度 | 估计物体的长度和宽度 |
Eccentricity | 椭圆离心率 | 判断物体接近圆的程度 |
Perimeter | 区域周长 | 结合面积算圆形度 |
Circularity | 4π×面积/周长² | 1表示完美圆,越小越不规则 |
拿到这些数据之后,画一个面积直方图,或者按尺寸范围分组统计数量,报表直接就能出。比如我的习惯是:
areas = [stats.Area]; histogram(areas, 20); xlabel('面积(像素)'); ylabel('数量'); title('物体尺寸分布');这个分布图能帮你发现很多肉眼看不到的问题:如果直方图在极小面积处有一个高峰,说明二值化带进了很多碎噪声;如果面积分布特别分散,可能是分割时把部分物体切碎了,需要回头调整形态学参数。
5. 结果验证不能省:四种常见误判的排查方法
5.1 一个物体被数成好几个的“过度分割”
我在做细胞计数练习时遇到过最典型的问题:明明视野里只有12个细胞球,分水岭跑完出来21个,多出来的全是被切开的。排查思路不是直接改参数,而是把分割结果可视化,把L2里每一个区域用不同颜色染出来,一眼就能看出哪些地方被多切了。
造成过度分割的原因主要有三个。第一,imhmin或imextendedmin的高度参数设得太小,导致物体表面很小的亮度起伏也被当成谷底。第二,图像噪声严重,一步到位的阈值处理没有做平滑滤波。第三,物体本身形状极度不规则,中心区域凹凸不平,天然就会产生多个极小值点。
解决顺序也按这三个原因来:先检查预处理有没有做平滑,再尝试把imhmin的参数从2调到5或8,最后考虑用面积阈值过滤掉过小的分割块。
5.2 多个物体黏在一起没分开的“欠分割”
这和过度分割正好相反。如果分水岭之后仍然有一大块区域没被切开,问题基本出在“种子点没提取出来”。两个物体挨得太近,距离变换形成的谷底太浅,imextendedmin认为这不算是真正的极小值,于是没有在那个位置生成种子点。
这种情况我一般做三件事:一是把imextendedmin的高度参数调小,让更多浅谷底变成种子;二是把二值化阈值往严格方向调一调,让粘连区域的“颈部”先断掉,再走分水岭;三是如果物体有凝聚性,比如细胞贴壁生长,就要考虑用椭圆的形状先拟合再切分,这是一个更大的话题,这里不展开。
5.3 噪声被当成物体,数量虚高
噪点最常见的来源是传感器热噪声、压缩伪影和光照反光点。bwareaopen(bw, n)能过滤面积小于n像素的区域,这个n怎么选很有讲究。我的经验是先统计一下所有区域的面积分布,设一个“碎屑”面积阈值,比如所有面积小于最大面积2%的区域都算噪声。
但要注意,如果物体本身就很小,噪声和真实物体的面积重叠,这招就失灵了。这种情况下我会换用形状特征过滤:真实物体通常有比较紧凑的形状,噪声点往往是单像素或长条形。用regionprops算一下Eccentricity和Circularity,把明显异常的长条形区域去掉,往往比单纯按面积过滤更有效。
5.4 背景里的干扰物也被记了一笔
有些应用场景背景里本来就有其他东西。比如在传送带上数螺丝,背景里有一条金属支架,亮度和螺丝几乎一样,即使光照均匀也会被误分割。这种问题的正解是ROI掩膜:程序只处理你画出来的特定区域,区域外面的一切都忽略。
我是这样做的:先人工在图像上框选一个感兴趣区域(Region of Interest),生成一个逻辑掩膜,然后让二值化、连通域分析都在这个掩膜内进行。这样不但能避免背景干扰,还能减少计算量。如果相机位置和角度固定,掩膜可以用一次标定保存下来,之后批量处理直接调用,一劳永逸。
% 手动框选ROI figure; imshow(img); roi = drawrectangle('Color', 'r'); mask = createMask(roi); % 把掩膜之外的部分屏蔽 bwMasked = bw & mask;这里有一个很多人不知道的细节:ROI掩膜要作用在二值化之后的前景掩膜上,而不是直接作用在原图上。如果先在原图上裁剪出ROI再做二值化,边缘的亮度会产生新的伪影,影响分割结果。
5.5 随机抽检:确认算法可靠的标准动作
任何自动计数方案,交付前我都建议做一个“人工对照测试”。拿20张标注好的图片,先用代码跑一遍数量,再找人眼数一遍,算一个准确率。如果每张都全对,说明这套流程在这个场景下基本可靠。如果某些场景准确率不稳定,就要记录下这些失败案例的图像特征,回到前面四个环节里去调。
这个步骤看起来笨,但能省掉后面大量扯皮。我从早期项目得到的教训是:不要拿一张图调通就觉得万事大吉,同一批数据里换上另一张图,光照差一点结果可能就崩了。做一个小样本的批量验证,才算真正把方案交付出去。
最后说一句我个人感触比较深的地方。在图像计数的项目里,最不值钱的部分反而是“计数”本身,真正花时间的是把图像预处理和分割参数调到一个“稳”的状态。我做项目的时候通常会把整个处理流程封装成一个函数,输入是图像路径,输出是数量和统计表,这样同一套代码可以反复用在不同批次的图片上。后续如果你的图像拍摄环境变了,光照变了,或者目标物体换了一批,优先改的不是代码,而是预处理环节里的那几个参数——搞清楚每个参数到底在控制什么,图像计数这趟浑水你就能趟过去了。