OpenCV物体计数实战:从二值化、轮廓检测到分水岭算法

📅 发布时间:2026/10/17 13:37:54
OpenCV物体计数实战:从二值化、轮廓检测到分水岭算法
“物体计数”这个需求我前前后后接手过不少次。数螺丝、数胶囊、数细胞、数农产品颗粒甚至还有人拿它数红外影像里的热斑。大多数场景最后都落成了同一个问题基于OpenCV实现对图片中的物体计数要求速度快、不必训练模型、结果稳定可复现。这套东西说难不难但新手照着网上的教程跑通简单案例很容易一遇到真实图片就翻车。这篇文章我就把这个流程完整拆开讲一遍从二值化、形态学处理到轮廓检测再到物体粘连时的分水岭方案把原理、代码、参数和坑一次性说清楚。无论你是刚接触OpenCV的初学者还是想快速做原型验证的开发者都能直接对着操作。1. 需求拆解与方案选型1.1 物体计数到底在解决什么问题我习惯把任何计数需求拆成三个子问题分离、确认、稳定性。分离说的是把每个物体从背景里抠出来让它们成为独立的像素区域。这看起来是图像处理问题实际上也是光照、镜头角度、物体材质共同作用的结果。确认说的是怎么知道一块区域是“一个物体”而不是噪声、阴影或者碎渣。稳定性则要求同一批图片重复跑结果不能忽多忽少。这三个子问题对应到技术栈上分离靠预处理和二值化确认靠轮廓筛选规则稳定性靠参数调优和形态学操作。如果物体之间没有粘连这个流程可以解决90%以上的问题如果有粘连就得再上分水岭这类分割算法。常见的应用场景大概分成四类工业场景零件、药片、胶囊、螺丝、电子元件摆放整齐规整可控。农业与食品玉米粒、黄豆、米粒、水果按大小或成熟度分级。医疗与科研细胞、菌落、颗粒在显微图像中数量统计。交通与安防画面中的车辆、人员、摊位数量粗统计。这些场景的共同点是物体与背景有可分离的灰度或颜色差异个体形态相对固定。一旦满足这个前提经典OpenCV路线就是性价比最高的解法。1.2 经典图像处理路线和深度学习方法怎么选每次有人问我要不要直接上目标检测模型我都先反问一句你的图片背景能不能控制物体形态是不是有规律如果答案是肯定的那先用经典图像处理路线做半小时就能出结果。对比维度经典OpenCV流程深度学习目标检测数据需求不需要标注拿图就能调试需要数百到数千张标注图硬件要求普通CPU就能跑训练最好有GPU推理也要算力可解释性每一处判断都是规则出了问题好查黑盒出错只能靠调数据稳定性光照和背景一变需要重新调参泛化能力强场景容忍度高开发速度几小时到一天数据准备就要好几天经典流程最大的好处不是“简单”而是可解释。计数多了还是少了你能直接定位是哪一步出了问题——阈值切得不好、形态学没把噪声滤干净、面积阈值定太高都能一行一行查。深度学习模型跑错了只能整体重新训练排查成本高得多。反过来如果物体形态差异巨大、互相严重遮挡、背景杂乱经典方案确实撑不住。这种情况我把深度学习当作下一步的备选方案第6章会专门聊。2. 核心识别管线预处理、二值化与形态学2.1 灰度化与滤波先把图片“洗干净”OpenCV处理彩色图像不是不行但物体计数这种任务里颜色信息很少是必要特征。转换成灰度图可以大幅减少计算量也让后续的阈值处理更稳定。灰度化用一行代码gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)转完灰度之后一定要做滤波降噪。工业相机拍的图还好普通手机拍的图噪声很重不滤波会在二值化后产生一堆小白点最后全被当成“物体”数进去。我常用高斯滤波blurred cv2.GaussianBlur(gray, (5, 5), 0)核大小是一个关键参数。(3, 3) 去噪能力弱但细节保留好适合小物体(5, 5) 是万金油(7, 7) 适合大物体或噪声严重的图。物体越小核越大越危险——核一大小目标就可能被抹掉。所以先看清你数的是什么东西再决定核大小。注意滤波不是万能的。如果图片本身模糊换再大的核也只是把噪声和细节一起抹掉不如重新拍图或者换硬件。2.2 阈值二值化把物体从背景里抠出来二值化的本质是把灰度图变成黑白的“掩膜图”认为属于物体的像素设255背景设0或者反过来。最省事的是OTSU自动阈值法它会根据图像灰度分布自动算出一个分割阈值不需要你手动指定ret, binary cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU)这里THRESH_BINARY_INV表示反相二值化适合物体比背景暗的情况——物体区域变成白色255背景变黑0。如果物体比背景亮就去掉_INV直接用THRESH_BINARY。怎么判断不用反相我一般是先跑一次把binary显示出来看黑白的比例。如果物体的像素面积明显大于背景说明反相反了。简单粗暴但很有效。OTSU的局限在于它假设整张图光照均匀。实际图片经常半边亮半边暗一个固定阈值根本分不开。这种情况我用自适应阈值binary cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 21, 5)自适应阈值会为每个像素计算局部阈值能处理光照渐变的问题。但要注意它比OTSU容易产生噪声后面形态学处理得多做几轮。2.3 形态学处理把噪点和不完整缺口修掉二值化出来的图像远远谈不上干净。常见问题有两个背景上有零星噪点像是“芝麻粒”物体内部有小洞像是“被虫子啃过”。这两个问题的解法分别是开运算和闭运算。先记住两个基础操作腐蚀把前景区域往内收缩一圈小的白色噪点直接消失但物体边缘也会变细。膨胀把前景区域往外扩张一圈物体里的洞会补上但边缘会变粗。开运算 先腐蚀后膨胀结果是去掉小噪点、保持物体大小基本不变。闭运算 先膨胀后腐蚀结果是填补内部小洞、保持物体大小基本不变。kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) opened cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations2)kernel的形状建议用椭圆MORPH_RECT矩形核适合方形或不规则物体但容易出现直角边缘。MORPH_ELLIPSE椭圆核边缘过渡更平滑我最常用。MORPH_CROSS十字核适合处理特定方向的连接。iterations参数表示执行几次开运算。噪点严重的图从2次开始试不行就加到3、4。但开运算会把细长的物体误伤——有些细碎的物体本来就比噪点大不了多少多迭代几次就消失了。这是一对矛盾只能靠看得见的结果来权衡我把每步处理后的中间结果都显示出来肉眼看哪个迭代次数能同时保留物体和去掉噪声。3. 轮廓检测与计数实现3.1 用findContours找出每个独立物体预处理完成之后图像上剩下的白色区域就是候选物体。现在用findContours把每个白色区域的边界提取出来contours, hierarchy cv2.findContours(opened, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)这里RETR_EXTERNAL表示只提取最外层轮廓。两个物体即使靠得很近但像素没有连在一起它们会被当成两个独立的轮廓。如果物体内部还有图案或者孔洞而你只想数外轮廓RETR_EXTERNAL就是最合理的选择。CHAIN_APPROX_SIMPLE会压缩轮廓点保留端点而丢掉直线上的中间点。用CHAIN_APPROX_NONE会保存所有轮廓点轮廓更精细但占内存、处理慢。计数任务用SIMPLE完全够用。3.2 轮廓筛选面积和形状说了算findContours返回的轮廓数量不直接等于物体数量。预处理永远做不到完美总有些“看起来像物体”的噪声区域混进来。这时候需要筛选。最常用的是面积筛选area cv2.contourArea(cnt) if area area_threshold: count 1面积阈值的设定有讲究。直接拍脑袋设一个比如500换张图可能就不对了。我的做法是先把所有轮廓画出来加上面积标签然后观察最大噪声区域大概多大再把阈值设到噪声面积的1.5到2倍以上。另外几个常用筛选维度周长cv2.arcLength(cnt, True)滤掉周长太短或太长的细碎轮廓。圆形度4 * pi * area / (perimeter^2)圆形物体接近1细长物体远小于1。外接矩形宽高比cv2.boundingRect(cnt)长宽比可以用来滤掉拉长形的杂质。最小外接圆半径cv2.minEnclosingCircle(cnt)适合近似圆形的颗粒类物体。如果物体形状本身是规则的多一个筛选维度就多一分稳定但同时也要小心筛选条件太严会把真实物体误杀。3.3 可直接运行的完整代码我整理了一个通用的模板可以直接复制改参数用import cv2 import numpy as np def count_objects(img_path, area_threshold500): # 1. 读图并灰度化 img cv2.imread(img_path) if img is None: raise ValueError(图片读取失败检查路径) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 2. 高斯滤波降噪 blurred cv2.GaussianBlur(gray, (5, 5), 0) # 3. OTSU自动阈值物体比背景暗所以用INV ret, binary cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 4. 开运算去噪 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) opened cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations2) # 5. 提取外轮廓 contours, hierarchy cv2.findContours(opened, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 6. 按面积筛选有效轮廓 objects [] for cnt in contours: area cv2.contourArea(cnt) if area area_threshold: objects.append(cnt) # 7. 可视化结果 result img.copy() cv2.drawContours(result, objects, -1, (0, 255, 0), 2) cv2.imwrite(result.jpg, result) return len(objects)这个脚本跑出来的result.jpg会把识别到的物体轮廓画成绿色。我强烈建议第一次运行时一定把中间结果图输出或显示出来亲眼看到每一步发生了什么。很多人直接调最后一个数字半天找不到问题其实问题往往出在二值化这一步。3.4 参数调优的核心经验参数调优是我花时间最多的地方。下面几条是我反复踩坑后总结出来的经验先统一图片尺寸。把图片宽度缩放到800像素左右再处理。原因很简单同一套面积阈值在不同分辨率下含义不同不统一尺寸的话同一批图片忽大忽小阈值根本没法定。缩放用cv2.resize(img, (width, int(height * width / img.shape[1])))就行。面积阈值要比最大噪声大一个量级但又比最小物体小一个量级。如果两者重叠说明预处理还不够干净优先回去调形态学而不是硬调阈值。核的大小和物体的最小尺寸挂钩。3x3的椭圆核能去掉像素级的毛刺但去不掉稍大一点的团块。如果噪声是一小团、一小团的我的经验是把开运算的核改成5x5、迭代2到3次。每次只改一个参数。我见过太多人同时调整核大小、迭代次数、面积阈值改完不知道是哪个参数起的作用。一次只动一个变量结果可控性高很多。4. 进阶物体粘连时的分水岭解法4.1 什么时候轮廓检测会失效经典的“二值化轮廓检测”流程有一个致命前提物体之间不能有像素级的连接。只要两个物体挨得够近或者有一点粘连它们在二值图上就是同一个连通区域findContours自然把它们当作一个物体。真实场景里这种情况太常见了。细胞在培养皿里挤在一起药片在传送带上首尾相接玉米粒装进容器里互相重叠。这时候再数轮廓结果一定偏少而且偏少的数量还不固定——因为粘连的物体数不固定误差根本没法修正。要解决粘连问题就得把“连在一起”的区域重新切开。这就是距离变换和分水岭算法登场的时机。4.2 距离变换加分水岭的原理分水岭算法可以这样理解把二值图像看成一幅地形图每个前景像素的高度就是它到最近背景像素的距离。物体内部的像素离背景远高度高物体边缘的像素离背景近高度低。于是每个物体看起来就像一座“山丘”山脊就是物体之间的边界。接着往这片地形里“注水”。水从山脚往上漫低处先被淹没高处慢慢变成孤立的“小岛”。当两个山丘的水面碰到一起时就在交汇处筑一道“坝”——这道坝就是物体之间的分界线。距离变换就是用来计算“高度”的dist cv2.distanceTransform(opened, cv2.DIST_L2, 5)DIST_L2表示欧氏距离就是真实的两点间直线距离。返回值是一张灰度图越亮的像素离背景越远代表越可能是物体中心。然后对这个距离图做一次阈值把“足够亮”的区域选出来作为每个物体的“种子”——也就是确定无疑的物体中心ret, sure_fg cv2.threshold(dist, 0.5 * dist.max(), 255, 0)这里阈值一般取距离最大值的比例。取0.5意味着只有中心区域能成为种子边缘区域不参与。这个比例是需要调的——比例太高一个物体可能被拆成好几个种子比例太低两个靠得极近的物体又会变成同一个种子。4.3 分水岭实战步骤与代码分水岭算法的完整流程如下背景区域sure_bg通过对二值图做膨胀得到。膨胀后物体边缘向外扩这些扩出来的区域就是“确定的背景”。前景区域sure_fg通过对距离图做阈值得到是每个物体的中心种子。未知区域unknownsure_bg减去sure_fg剩下的区域也就是物体边缘模糊地带让分水岭算法去分配。标记初始化用connectedComponents给每个前景种子一个编号。执行watershed算法会把未知区域分配给最近的种子并在两个种子交汇处标记出分界线。import cv2 import numpy as np img cv2.imread(objects.png) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) ret, binary cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) opening cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations2) # 确定的背景区域 sure_bg cv2.dilate(opening, kernel, iterations3) # 距离变换找物体中心 dist cv2.distanceTransform(opening, cv2.DIST_L2, 5) ret, sure_fg cv2.threshold(dist, 0.5 * dist.max(), 255, 0) sure_fg np.uint8(sure_fg) # 未知区域 unknown cv2.subtract(sure_bg, sure_fg) # 初始化标记背景为1每个前景种子从2开始编号 ret, markers cv2.connectedComponents(sure_fg) markers markers 1 markers[unknown 255] 0 # 执行分水岭 markers cv2.watershed(img, markers) img[markers -1] [0, 0, 255] # 分界线画成红色 # 统计物体数量connectedComponents的返回值ret包含背景所以减1 num_objects ret - 1 print(物体数量:, num_objects)跑完之后图像上每个物体的边界以及物体之间的分界线会被标记出来。分界线在markers里的值是-1我用红色标出。如果你看到的红色分界线把物体切得支离破碎说明sure_fg的阈值取低了把单个物体分成了多个种子如果分界线没能在粘连物体之间出现说明阈值取高了两个物体只有一个种子。4.4 分水岭的坑和补救办法分水岭不是银弹。我实际用过很多次最常见的翻车原因有三个过分割一个物体被切开成几块。原因几乎都是距离阈值选得太低把一个物体内部的多个高点当成了多个种子。补救办法是把0.5 * dist.max()的比例往上调比如0.6、0.7宁可少分种子也别多分。欠分割两个物体还是连在一起。原因一般是距离阈值太高物体中心区域太小或者两个物体离得太近、距离变换都来不及把它们分开。这时候可以降低阈值比例也可以先对sure_fg做一次膨胀让种子区域更完整。边界噪声分水岭把背景里的噪声区也当成物体了。这锅不能甩给分水岭问题还是出在预处理。回过头把开运算的迭代次数增加或者把sure_fg之前的二值图面积筛选做一下把小面积的噪声区域先清掉再进分水岭。我自己的习惯是分水岭代码不直接应用到生产而是先用来做“排雷”——把结果可视化出来看看粘连物体是不是被切开再看每个物体的区域是不是完整。确认算法行为稳定后再固化参数。5. 常见问题与排查技巧实录5.1 高频问题速查表把我在实际操作中遇过的问题整理成下面这张表排查时按图索骥最快。问题现象可能原因排查与解决思路计数偏多零星小白点被当成物体二值化后噪声未清理干净增大开运算迭代次数或把kernel从3x3换成5x5计数偏少两个物体连成一个物体粘连或有边界连接检查二值图是否接触接触则换分水岭方案阈值分割后背景一大片白色OTSU反相方向反了检查是否需要THRESH_BINARY_INV图片半边亮半边暗OTSU失效光照不均换adaptiveThreshold或先做光照矫正物体和背景灰度接近对比度不足用HSV颜色空间按色相提取或换成彩色通道阈值每次跑结果不稳定图片分辨率不一致或参数写死统一缩放图片阈值改为相对面积比例程序跑得慢原图太大、轮廓点太多缩放图片、使用CHAIN_APPROX_SIMPLE这张表没有覆盖所有问题但覆盖了80%的常见情况。排查的原则永远是先定位到具体某一步再针对性修改而不是盲目调参数。5.2 两个反复出现的隐形坑第一个坑是反光导致物体内部出现“黑洞”。很多物体表面光滑比如药片、塑料件打光不均匀时会在物体内部形成高光区域。二值化后高光区域变成背景色物体被“挖”出一块接着findContours就可能把一个物体识别成两三个。解决办法有两个。一是用闭运算把洞补掉先膨胀后腐蚀小洞基本都能填上。二是从硬件角度解决拍照时加柔光或用多角度光源。软件能救的有限反光极其严重时物体轮廓已经完全断掉闭运算也无能为力。第二个坑是面积阈值定成绝对值换图片尺寸就失效。很多教程喜欢直接写area 500这种固定值在教程图片上跑没问题换一张不同分辨率图片就翻车。正确做法是先统一图片尺寸再用相对参考来定义阈值比如先画出所有轮廓的面积直方图看噪声和真实物体的分界在哪里再动手定值。6. 下一步从原型到真实场景落地6.1 数据量更大时怎么办原型跑通了接下来要考虑批处理。几十张图还好几百上千张图一张一张地打开看是不现实的。我的做法是把流程整理成函数然后遍历文件夹批量处理结果统一写到表格里import os import csv results [] for name in os.listdir(images): path os.path.join(images, name) count count_objects(path) results.append((name, count)) with open(result.csv, w, newline) as f: writer csv.writer(f) writer.writerow([filename, count]) writer.writerows(results)如果对速度有要求就把图片读取和预处理放到多进程里。OpenCV的imread和图像处理是CPU密集型任务多线程受GIL限制效果不好多进程才是正解。我通常在四核机器上开4个进程批量处理速度能提升近三倍。批处理时一定要在表格里记录每张图的轮廓数、总面积、平均面积这些中间指标。它们看着冗余一旦某张图的计数结果异常这些指标能帮你快速定位是图片本身的问题还是算法的问题。6.2 深度学习方案什么时候更合适回到第1章的问题经典OpenCV方案搞不定的时候深度学习目标检测就是下一步。我见过不少场景最后确实得上深度学习典型特征包括物体形态千奇百怪、互相遮挡严重、背景杂乱无章、光照条件多变。这些情况下基于规则的方法参数多到难以维护。深度学习方案的核心是做目标检测把每一个物体用一个框框出来再数框的个数。常用的模型有YOLO系列、更轻量的SSD以及带实例分割能力的模型。用检测模型的好处是它天然处理遮挡和复杂背景坏处是需要数据、标注和训练时间。我的建议是两条路结合着用先用检测模型把物体大致区域框出来再用OpenCV在框内做精细分割和计数。这种“检测分割”的组合在工业质检里很常见——检测负责抗干扰分割负责精确计数。深度学习负责“这大概是物体”OpenCV负责“这到底是几个”。我最后想分享的一点经验这套流程我用过太多次最近几年已经固定成一个习惯无论需求多简单我先把中间结果可视化完整跑一遍确认二值化和形态学环节没有埋雷再谈计数精度。OTSU加开运算加RETR_EXTERNAL这个组合能解决一大半需求遇到粘连再上距离变换和分水岭。如果连分水岭都搞不定才考虑上模型。这个顺序我建议你也试一试。多备几组实际图片作为调试素材把参数调对的依据建立在真实数据上而不是教程里的那张示例图——这是很多初学者最容易忽略的一步。