用OpenCV数清楚图片里的物体:阈值、轮廓与分水岭实战

发布时间:2026/10/17 22:18:09
用OpenCV数清楚图片里的物体:阈值、轮廓与分水岭实战 如果有人让你数一张图片里的物体数量你大概率会觉得这件事没什么技术含量。但真正做过程序计数的人都知道这个“数一下”的背后是要把一整条图像处理链路串起来要在复杂背景里分离目标要把噪声误识别的干扰去掉要把粘连在一起的两个目标切开还要保证同一个物体没有被重复统计。OpenCV作为做图像任务最常用的工具库把阈值分割、轮廓检测、连通域分析、分水岭等经典算法都打包好了在目标特征固定、拍摄条件可控的场景下效果完全不输给深度学习模型。这篇文章不涉及模型训练就讲如何用OpenCV把图片中的物体数清楚。从预处理、分割再到计数与排错都会给出能直接跑的思路和代码。适合刚开始接触视觉项目、正在为颗粒统计、零件清点、细胞样图之类需求找方案的开发者参考。1. 为什么“物体计数”远比想象中复杂从需求到技术选型1.1 计数任务的核心难点在哪里“数清楚”这个任务难点其实不在最后的 count而在前面的整条图像链路上。我拆过很多次的计数需求发现反复出现的问题无非这几类目标与背景灰度太接近。比如在白色纸板上数浅黄色纽扣用全局阈值基本没法把目标和背景分开因为两者的灰度范围大量重叠。光照不均匀。同一张图里左边亮右边暗或者目标表面有反光会导致同一个物体在图像里呈现完全不同的亮度阈值无论怎么调都会漏掉一部分。背景纹理干扰。布料、泡沫板、颗粒面板这类材料本身就有细密纹路预处理不足时纹路会变成大量的小面积“假目标”计数结果直接爆掉。目标粘连或相互遮挡。物体堆放得太近时轮廓连成一个闭合区域程序会把它当成一个物体来处理。目标被图像边缘截断。如果拍照时物体没有完全放进视野边缘露出的半个目标会干扰面积筛选。这些难点往往不会同时出现但只要出现其中一个计数偏差就会非常明显。所以做这类项目我的习惯是拿到图像后先别急着写代码而是先花五分钟看图哪些区域是目标哪些是背景干扰目标之间有没有重叠光照是不是均匀。把这些问题列出来再决定后面用哪一组合适的预处理加分割方案。1.2 OpenCV在物体计数中的定位先给一个结论不是所有场景都适合用OpenCV的传统方法但对目标形态固定、背景相对可控的计数需求传统视觉往往比深度学习更合适。原因很直接传统方法不需要标注数据、不需要训练参数调好之后在普通CPU上也能跑得很快部署成本很低。我之前在某工厂的零件清点Demo里用的就是OpenCV如果换成目标检测模型光是收集和标注一批近似图像就得花费大量时间而且还要考虑推理机器的性能。版本方面推荐直接用 opencv-python 4.x。4.x 里 findContours 的返回值从三个变成两个很多老博客的写法在新版本上会直接报错。所有示例代码我统一按 4.x 的 API 来写。另外图像处理通常还会搭配 numpy 使用OpenCV 读进来的图片本质上就是 numpy 数组做掩膜、切片、统计都非常方便。装好这两个库环境就算齐了。我自己画过的处理流程通常是这样输入图像 → 灰度化 → 去噪 → 增强对比度 → 二值化 → 形态学修正 → 轮廓或连通域提取 → 特征筛选 → 计数输出。顺序可以根据实际图像微调但整体链路就是这个样子。2. 图像预处理决定计数准确率的第一道关卡2.1 灰度化、去噪与直方图均衡预处理的第一个动作通常是灰度化。把彩色图转成灰度图不是为了省那点内存而是大部分计数算法只关心亮度轮廓颜色信息反而会引入干扰。比如红色物体和蓝色物体在彩色空间差异很大但在灰度空间可能亮度接近计数时不需要区分颜色直接合并处理即可。用 cvtColor 一行就能完成。去噪这一步要看图像本身的噪声类型。高斯滤波适合处理随机分布的高斯噪声平滑效果均匀中值滤波对椒盐噪声特别有效而且相对能保留边缘轮廓双边滤波可以在去噪的同时保边但计算量大实时场景要谨慎用。我做颗粒物计数时用得最多的是中值滤波因为颗粒边缘需要保留直接用高斯容易把边缘模糊掉导致后面轮廓面积偏小。如果图像比较干净甚至可以跳过滤波。有时候图像整体偏暗或偏亮灰度直方图挤在很小的区间里这会严重影响二值化效果。可以用直方图均衡化把灰度分布拉开让前景和背景的差异更明显。它的副作用是会放大背景中的细小噪声所以建议在均衡化之后再配合滤波使用。一段很基础的预处理代码如下这段代码在大多数场景里可以直接复用import cv2 import numpy as np img cv2.imread(sample.jpg) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 中值滤波去除椒盐噪声保留边缘 blur cv2.medianBlur(gray, 5) # 直方图均衡化增强前景背景对比 enhanced cv2.equalizeHist(blur) # 大津阈值自动计算二值分割阈值 _, binary cv2.threshold(enhanced, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) cv2.imwrite(binary.png, binary)这里用了 THRESH_BINARY_INV 是因为很多拍摄场景下目标比背景暗取反之后目标变白、背景变黑后续轮廓处理更直观。如果目标是亮色、背景暗色就需要去掉 INV。2.2 背景分离与形态学操作图像二值化只是把像素分成前景和背景两类真正的问题是怎么分得准。大津法是最好用的全局阈值它能自动选择类间方差最大的分割阈值。但遇到光照不均或背景复杂的图像时全局阈值就不够用了这时可以换自适应阈值它是基于每个像素邻域计算局部阈值对光照渐变的图像效果明显更好。代价是更容易把背景纹理也分割出来产生很多小块噪声。自适应阈值的写法通常是这样的adaptive cv2.adaptiveThreshold( enhanced, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, # 邻域大小一般取奇数 10 # 常数从邻域均值中减去 )邻域大小和常数都需要根据目标尺寸调整。邻域太小会把细节过度分割邻域太大又失去局部意义常数越大分割结果越保守。另一种常见策略是根据颜色特征分割。如果目标有特定颜色用 HSV 色彩空间往往比灰度阈值更稳定。比如在一堆杂色背景中数红色胶囊可以把图像转到 HSV 空间通过色相和饱和度范围生成掩膜。这样做的好处是对光照的敏感度低因为 HSV 的 V 通道单独负责亮度分割时只取 H 和 S 即可。得到二值图之后通常还要做形态学操作。开运算先腐蚀后膨胀可以去掉小噪声点闭运算先膨胀后腐蚀可以闭合目标内部的细小空洞或断裂区域。如果目标图像里有微小的孔洞比如颗粒内部有白斑闭运算能把这些孔洞填掉让后续轮廓更完整。3. 核心计数算法从阈值分割到连通域统计3.1 大津法与自适应阈值的适用边界大津法是我默认会先试的方法因为它不需要人工指定阈值。它的原理简单说就是遍历所有可能阈值找到能让前景和背景两类像素方差之和最小的那个值也就是两类差异最大的阈值。在目标和背景灰度分布是双峰形态时大津法表现很好但光照不均、灰度直方图不是双峰时它就容易找偏。自适应阈值更适合处理扫描文档、光照梯度明显、背景亮度缓慢变化的场景。它等于把一个全局问题拆成一个个局部小问题逐个区域找阈值所以对大范围阴影有天然的抵抗能力。代价是计算量变大并且会对背景纹理更敏感需要额外滤波或形态学清理。二者没有绝对的谁好谁坏。我一般的方法是先看灰度直方图如果直方图有两个明显波峰直接用大津法如果直方图单峰且拖尾先试自适应阈值。下面这个表可以作为选择参考方法适用场景优点主要缺点大津法光照均匀、目标与背景灰度差异大自动阈值、实现简单、速度快对光照不均和灰度重叠敏感自适应阈值光照渐变、背景亮度不均局部适应性强抗阴影效果好容易放大背景纹理参数需要调HSV颜色分割目标有明确且稳定的颜色抗光照干扰分割颜色目标精准对相似色背景无效需要调色相范围很多刚接触这个领域的同学会花大量时间寻找“最好的阈值方法”其实没有这种万能方法。正确思路是先分析图像属于哪种情况再做选择。3.2 轮廓检测与筛选面积、周长和圆度二值图准备好之后最直观的计数方式就是找轮廓。一张二值图中每个白色连通区域都可以用 findContours 提取出对应的轮廓点集。遍历所有轮廓每个轮廓代表一个候选目标计数就是统计满足筛选条件的轮廓数量。用 OpenCV 4.x 找到轮廓并计数的核心代码如下contours, hierarchy cv2.findContours( binary, cv2.RETR_EXTERNAL, # 只取最外层轮廓 cv2.CHAIN_APPROX_SIMPLE ) min_area 200 # 最小面积阈值需要根据目标大小标定 max_area 5000 # 最大面积阈值 count 0 for cnt in contours: area cv2.contourArea(cnt) if area min_area or area max_area: continue count 1 print(检测到目标个数:, count)这段代码跑通很容易但真正决定准确率的其实是 min_area 和 max_area 的取值。这两个值不能拍脑袋定要结合真实目标在图片中的像素大小来标定。我的做法是先在一张图上把检测到的所有轮廓画出来通过面积直方图观察目标和噪声的分布区间再据此设定阈值范围。除了面积轮廓还有很多其他特征可以用。周长通过 arcLength 获取圆度可以用公式 4πA/P² 计算越接近 1 说明目标越接近圆形。如果图中的圆形颗粒需要计数增加一个圆度筛选就能滤掉不少杂形噪声。此外还可以用外接矩形的宽高比做筛选比如计数长条形物体时可以排除掉接近方形的干扰。轮廓层级也要说一句。RETR_EXTERNAL 只取最外层轮廓适合目标之间没有包含关系的场景。如果图像里存在嵌套结构比如一个圆环物体包含另一个小物体就要用 RETR_TREE 并利用 hierarchy 中的父子关系否则会把内外两层重复计数。3.3 连通区域分析的正确打开方式轮廓检测不是唯一的计数方式。如果目标只是独立的连通块可以用 connectedComponentsWithStats它直接返回每个连通区域的标签图、统计信息和中心点坐标。用这种方式不用手动遍历轮廓点统计面积和外接框也便捷我在批量场景下反而更常用。示例代码如下num_labels, labels, stats, centroids cv2.connectedComponentsWithStats(binary) count 0 for i in range(1, num_labels): # 从1开始跳过背景 area stats[i, cv2.CC_STAT_AREA] if area min_area or area max_area: continue count 1 print(连通域数量:, count)connectedComponentsWithStats 的结果里stats 的每一行对应一个连通区域字段包括左边界、上边界、宽度、高度、面积。centroids 是对应的中心坐标。相比 findContours它的起步门槛更低处理速度在某些场景下也更快尤其适合目标都是简单连通块的二值图。但要注意连通域分析不擅长处理目标的嵌套关系如果二值图里有物体套物体它也会分别统计。此时还是要回到轮廓层级或者分水岭那一类更高级的分割思路。4. 粘连与重叠目标分水岭算法与工程取舍4.1 分水岭分割的思路与代码如果图像里的物体相互紧挨着轮廓检测会把两个紧挨的区域当成一个大目标计数结果就会明显偏少。解决这个问题的经典思路是分水岭算法。分水岭的思想可以这样理解把图像的像素灰度值想象成海拔高度灰度值低的地方是盆地高的是山脊。从图像的不同最低点开始往盆地注水水面逐渐上升不同盆地的水快要汇合时算法会在交界处建立堤坝。最终形成的堤坝就是分割线每个被堤坝分隔的盆地就是一个独立目标。直接对二值图做分水岭并不合适通常要先做距离变换。所谓距离变换是计算每个前景像素到最近背景像素的距离距离越大说明这个像素越靠近目标中心。取距离图上的局部高点作为每个目标的“种子”分水岭从这些种子出发生长就能把粘连目标切开。相关代码逻辑如下# binary 是前景为白色、背景为黑色的二值图 dist cv2.distanceTransform(binary, cv2.DIST_L2, 5) # 把距离图阈值化得到每个目标的“确定前景”区域 _, sure_fg cv2.threshold(dist, 0.5 * dist.max(), 255, 0) sure_fg np.uint8(sure_fg) # 确定前景和原始二值图之间的区域属于不确定区域 unknown cv2.subtract(binary, sure_fg) # 对确定前景做连通域标记并扩展标记 _, markers cv2.connectedComponents(sure_fg) markers markers 1 # 让背景标签从1开始 markers[unknown 0] 0 # 把不确定区域标记为0 # 执行分水岭 markers cv2.watershed(img, markers)执行完成后markers 中值为 1 的是背景值为 -1 的是分水岭边界大于 1 的每个编号对应一个分割出来的独立区域。统计大于 1 的唯一值数量就是分割后的目标数。分水岭算法最怕的是某个目标内部本身存在凹陷或低对比度区域导致距离变换后在同一个目标内部产生多个种子把它过度切割成好几块。这时可以适当提高 sure_fg 的阈值比例让种子区域更保守也可以先对二值图做闭运算把目标内部的细小凹陷先填平。4.2 分割不完美时的工程取舍分水岭效果好的时候非常惊艳但实际项目中它并不总能做到完美分割。过度分割和欠分割都可能发生。过度分割是一个目标被切成几个欠分割是两个目标仍然连在一起。我在实际项目里不会一开始就上分水岭而是先做一步简单的判断计算轮廓面积分布如果一个区域的面积明显是正常目标的 1.5 倍到 2 倍那就说明大概率有粘连。如果粘连目标占比很小比如不到总目标数的 5%我会选择直接接受这部分误差因为分水岭引入的过分割风险可能比漏计几个重叠目标更麻烦。反过来如果图像普遍粘连就必须上分水岭。分水岭也不是唯一的选择。如果目标形状接近标准圆形可以试试霍夫圆检测它天然对圆形物体有很强的识别能力对重叠也有一定容忍度。如果目标有一定纹理也可以先做边缘检测将检测到的边缘作为分割约束配合形态学重建。工程上重要的不是掌握所有算法而是先判断当前误差主要来自哪个环节再选择最小成本的修正手段。此外计数项目经常允许“人工复核”环节。我会把程序标记出的每个目标中心点和编号画在图上交给负责审核的人快速扫一眼。重叠区域偶尔被算法吞掉人工在图上修正一下比让程序追求完美分割要高效得多。5. 实测案例与参数调优心得5.1 三类典型图像的方案对比不同的图像特点对应完全不同的处理组合。拿三类我实际接触过相似场景的图像来做对比图像类型图像特点预处理与分割方案结果情况浅色颗粒图深色底板光照均匀灰度化 中值滤波 大津阈值 面积筛选计数准确率约98%主要误差来自边缘极少半颗颗粒金属垫片图自然光照背景有反光灰度化 双边滤波 自适应阈值 形态学闭运算 连通域统计准确率约92%反光区域偶发误检生物细胞样图目标密集且相互粘连灰度化 高斯滤波 距离变换 分水岭准确率约85%需配合人工抽检第一类图像最简单目标与背景差异明显颗粒之间没有粘连用 3.2 的轮廓筛选就可以跑出很高的准确率。第二类图像难在反光反光区域会让局部灰度接近目标此时自适应阈值能比全局阈值更好地保留目标轮廓但也会把强反光误选为目标。我通过增加圆度筛选和面积下限滤掉了大部分反光产生的细长形状噪声。第三类图像必须依赖分水岭因为目标互相紧贴不做分割直接计数会少算接近三分之一。这三类案例说明不存在“调一次参数通吃所有图”的方案每换一批图都必须重新可视化中间结果重新标定参数。5.2 从误检漏检反查问题原因当计数结果不对时我最常做的事是把错误反推回处理链路里而不是单纯增加滤波器次数。误检和漏检产生的原因不太一样排查方向也不同。如果计数结果偏多多半是噪声或背景纹理被当成了目标。先看二值图里是否存在大量小面积白点如果有就加大面积下限或者在中值滤波之外增加一次开运算。如果噪声来自背景纹理纹理一般呈现细长或网状形态可以增加圆度筛选把它们排除。如果计数结果偏少先看目标是否粘连。在同一张二值图上观察白色连通区域的面积分布如果有大面积区域明显是几个目标合体就要走分水岭处理。另一种常见漏检是目标中间存在空洞导致一个目标轮廓被分成内外两段这时候用闭运算填充空洞或改用连通域的最大面积统计都能解决问题。还有一种情况是目标与背景灰度非常接近二值化后被直接淹没。这时最有效的不是调阈值而是先做直方图均衡化或 CLAHE 局部对比度增强把灰度差异拉开。从实际经验看很多“阈值怎么调都不对”的问题根因不在阈值本身而是前面的对比度增强没做够。5.3 批量处理与速度优化计数需求很少只处理一张图更多时候是处理一个文件夹里的上百张图。批量处理时我习惯把这些步骤封装成一个函数输入路径和参数输出计数结果。一个简单的批量版本可以这样写import os import cv2 def count_objects(image_path, min_area, max_area): img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur cv2.medianBlur(gray, 5) _, binary cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) count 0 for cnt in contours: area cv2.contourArea(cnt) if min_area area max_area: count 1 return count image_dir ./images results {} for filename in os.listdir(image_dir): if filename.lower().endswith((.jpg, .png, .bmp)): path os.path.join(image_dir, filename) results[filename] count_objects(path, 200, 5000)如果图片数量很大可以考虑先把图片缩放一个等级。比如原图 4000×3000 像素可以先缩放到 1000×750分割速度会明显加快。但要注意缩放后面积阈值也要同步缩小否则最小面积参数全部失效。多进程也可以考虑OpenCV 读取图片加上简单阈值分割在 CPU 上很快瓶颈往往是磁盘 IO多进程提升没有想象中大把代码逻辑理清楚更重要。如果后续需要做更复杂的预处理再用多线程或进程池。6. 我绕过的那些坑来自实际项目的血泪经验6.1 反光与阴影最好的预处理是拍好照片很多计数不准的问题根源不在算法而在拍摄条件。反光会让目标表面出现高光区高光区域的灰度可能和背景一样导致目标内部出现“空洞”或目标边缘断裂。阴影则会让背景出现大面积暗色区被错误地当成目标轮廓的一部分。我拍了一批物料图像时发现同样的算法在室内均匀光源下准确率能到 95%换到阳光直射窗口拍的照片准确率直接跌到 70%。后来把拍摄环境改成漫反射光源并在侧面补了一个柔光灯图像质量明显改善计数准确率立刻回升。做这类项目时不要只盯着代码先花点功夫把拍照环境控制好这才算真正的“预处理”。如果图像已经拍坏了无法重拍算法上也有补救手段。反光引起的目标内部空洞可以用闭运算填充阴影引起的背景暗区可以通过背景建模或形态学顶帽操作减轻。但这些都是补救效果远不如重新拍一张合格的图。6.2 参数标定不要迷信“万能参数”我在项目里最深的体会是所有阈值参数都是图像相关的。min_area、邻域大小、滤波核尺寸、常数 C这些参数在不同相机、不同距离、不同光照下都会变。把它们硬编码在代码里短期方便换一批图准出问题。我的做法是把参数单独写成配置文件每个数据集对应一组参数。做项目时我会先用一张典型图像做交互式调参用滚动条实时查看分割效果定好一组参数后固定下来再批量运行。OpenCV 里用 createTrackbar 做交互式调参很方便核心逻辑就是让参数变化实时触发重处理并在窗口中显示结果。只要能快速看到“调参带来的后果”你就能很快找到合适的区间。调完参数后别忘了把每批图的参数记录下来。某些项目过了一个月再回看如果只留下代码不留下参数记录你根本不知道当年那批图是怎么调出效果的。这个细节比算法本身更容易被忽略。6.3 调试技巧把每一步中间结果都存下来排查计数不准的问题时最怕的就是处理链路太长看不到中间过程。我的习惯是工程化地把每一步结果都保存或显示一次灰度图、滤波后图像、二值图、形态学结果、轮廓绘制图、最终标记图。任何一个环节出了问题一看中间图就能锁定位置。特别是最终标记图。我会把每个目标的编号直接画在原始图上生成一张带数字的标注图然后人工对照原始图像快速检查哪里多检了、哪里漏检了。有一次我发现程序统计结果少了三个目标打开编号图一看原来是三个灰色物体和背景颜色几乎融为一体在二值化阶段就被抹掉了。之后我再处理类似图像都会先把对比度增强提到阈值前面问题就消失了。最后分享一个小技巧写一个简单的 debug 开关设置为 True 时保存所有中间结果设为 False 时只输出最终计数。这样既方便日常调试又能在批处理时避免生成一大堆调试文件。计数项目到最后拼的往往不是某种高级算法而是你能不能把每个环节的中间结果可视化快速定位传出问题的那一环。这个习惯比任何调参经验都管用。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询