做了几年机器视觉相关的项目我经常被问到同一个问题用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区域周长结合面积算圆形度Circularity4π×面积/周长²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张标注好的图片先用代码跑一遍数量再找人眼数一遍算一个准确率。如果每张都全对说明这套流程在这个场景下基本可靠。如果某些场景准确率不稳定就要记录下这些失败案例的图像特征回到前面四个环节里去调。这个步骤看起来笨但能省掉后面大量扯皮。我从早期项目得到的教训是不要拿一张图调通就觉得万事大吉同一批数据里换上另一张图光照差一点结果可能就崩了。做一个小样本的批量验证才算真正把方案交付出去。最后说一句我个人感触比较深的地方。在图像计数的项目里最不值钱的部分反而是“计数”本身真正花时间的是把图像预处理和分割参数调到一个“稳”的状态。我做项目的时候通常会把整个处理流程封装成一个函数输入是图像路径输出是数量和统计表这样同一套代码可以反复用在不同批次的图片上。后续如果你的图像拍摄环境变了光照变了或者目标物体换了一批优先改的不是代码而是预处理环节里的那几个参数——搞清楚每个参数到底在控制什么图像计数这趟浑水你就能趟过去了。