在工业视觉中,经常会遇到多个目标粘连在一起的问题,例如:
- 元器件粘连
- 药片粘连
- 细胞分割
- 水滴分割
- 螺丝统计
- 颗粒计数
普通轮廓检测通常只能找到一个整体轮廓,而 Watershed(分水岭算法) 可以将相互接触的目标重新分离,因此成为 OpenCV 中最经典的图像分割算法之一。
一、什么是分水岭算法
1.1 什么是分水岭算法
分水岭算法(Watershed Algorithm)是一种基于拓扑理论的图像分割方法,其核心思想来源于地理学中的地形概念。想象一幅灰度图像是一个三维地形图,其中像素的灰度值代表海拔高度:亮区域对应高山,暗区域对应山谷。
当我们从最低点(局部极小值)开始注水,水会逐渐淹没整个地形。当来自不同山谷的水即将汇合时,我们在汇合处修建"堤坝",这些堤坝就构成了图像的分割边界。最终形成的各个"集水盆地"就是分割后的不同区域。
在图像分割中,分水岭算法特别擅长处理粘连对象的分离问题,例如细胞计数、硬币分割等场景。
1.2 传统分水岭的问题
经典的分水岭算法存在一个严重缺陷------过分割(Over-segmentation)。这是因为:
- 噪声会产生大量虚假的局部极小值,导致形成过多的小区域
- 图像纹理和细节也会被误识别为独立的山谷
- 最终可能将单个对象分割成数十甚至数百个小块,失去实用价值
因此,实际应用中几乎都采用标记控制的分水岭算法(Marker-controlled Watershed),通过人工或自动方式指定哪些区域应该是前景、哪些是背景,从而引导算法产生合理的分割结果。
1.3 标记控制分水岭原理
标记控制分水岭的核心是将图像划分为三个部分:
- 前景标记(Foreground Markers):确定属于对象的内部区域
- 背景标记(Background Markers):确定属于背景的区域
- 未知区域(Unknown Region):前景和背景之间的过渡区域,由分水岭算法决定归属
距离变换(Distance Transform)在这一过程中起到关键作用。它计算每个前景像素到最近背景像素的距离,距离越大的像素越可能是对象的中心。通过对距离变换结果设定阈值,可以准确提取出前景标记。
分水岭(Watershed)最初来源于地形学。
可以把一幅灰度图理解成一座山:
- 灰度值高 → 山峰
- 灰度值低 → 山谷
如果向山谷不断灌水:
不同山谷的水最终会相遇。
OpenCV 就会在两股水相遇的位置建立一道"堤坝"。
这条堤坝就是:
目标之间真正的分割线。
因此称为:
Watershed(分水岭)
示意图
山峰
/\
/ \
____/____\____
↑ ↑
两个山谷开始灌水
↓
最后相遇
↓
██████████
分割线
二、为什么需要分水岭
例如两个圆:
○○
使用轮廓检测:
findContours()
↓
一个轮廓
结果:
██████
实际上我们需要:
○ ○
分水岭可以自动完成这一过程。
三、算法流程
OpenCV Watershed 的标准流程如下:
原图
│
▼
灰度化
│
▼
二值化
│
▼
形态学去噪
│
▼
距离变换
│
▼
寻找前景
│
▼
寻找背景
│
▼
未知区域
│
▼
连通域标记
│
▼
Watershed
│
▼
得到分割结果
四、OpenCV API
4.1 整体流程概述
完整的分水岭分割流程包含以下步骤:
- 图像预处理:使用高斯模糊或中值滤波去除噪声
- 阈值化:将灰度图像转换为二值图像,初步分离前景和背景
- 形态学操作:通过开运算去除小的噪声点,闭运算填充小孔洞
- 距离变换:计算每个前景像素到背景的距离
- 确定前景标记:对距离变换结果阈值化,获取对象的核心区域
- 确定背景标记:通过膨胀操作扩展背景区域
- 创建标记图像:合并前景和背景标记,标记未知区域为0
- 应用分水岭:调用cv2.watershed()执行分割
- 可视化结果:用不同颜色标注分割边界
4.2 关键函数详解
- cv2.distanceTransform() - 距离变换
计算二值图像中每个非零像素到最近零像素的距离。参数distanceType指定距离度量方式(DIST_L2为欧氏距离,DIST_L1为曼哈顿距离),maskSize指定掩模大小(3或5)。 - cv2.connectedComponents() - 连通分量标记
为二值图像中的每个连通区域分配唯一的标签ID。返回标记数量和标记矩阵,标记矩阵中每个像素的值即为其所属区域的ID。 - cv2.watershed() - 分水岭算法
执行标记控制的分水岭分割。输入为原始彩色图像和标记图像(int32类型),输出中标记为-1的像素即为分割边界。注意:该函数会直接修改输入的标记矩阵。 - cv2.morphologyEx() - 形态学操作
支持多种形态学变换:MORPH_OPEN(开运算,先腐蚀后膨胀,去除小噪声)、MORPH_CLOSE(闭运算,先膨胀后腐蚀,填充小孔洞)、MORPH_GRADIENT(形态学梯度,突出边缘)。
python
cv2.watershed(image, markers)
参数:
| 参数 | 说明 |
|---|---|
| image | 原始彩色图像 |
| markers | 标记图(int32) |
返回:
markers
其中:
-1
表示:
分割边界。
其它数字表示不同目标。
例如:
1
2
3
4
...
分别表示不同区域。
五、完整代码
5.1基础代码
python
import cv2
import numpy as np
img = cv2.imread("coin.png")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(
gray,
0,
255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
)
kernel = np.ones((3,3),np.uint8)
opening = cv2.morphologyEx(
binary,
cv2.MORPH_OPEN,
kernel,
iterations=2
)
sure_bg = cv2.dilate(
opening,
kernel,
iterations=3
)
dist = cv2.distanceTransform(
opening,
cv2.DIST_L2,
5
)
_, sure_fg = cv2.threshold(
dist,
0.5 * dist.max(),
255,
0
)
sure_fg = np.uint8(sure_fg)
unknown = cv2.subtract(
sure_bg,
sure_fg
)
num_labels, markers = cv2.connectedComponents(
sure_fg
)
markers = markers + 1
markers[unknown == 255] = 0
markers = cv2.watershed(
img,
markers
)
img[markers == -1] = [0,0,255]
cv2.imshow("Result", img)
cv2.waitKey(0)
5.2 基础示例:硬币分割
以下代码演示如何使用分水岭算法分割粘连的硬币图像:
python
import numpy as np
import cv2
from matplotlib import pyplot as plt
# 读取图像
img = cv2.imread('coins.png')
if img is None:
print("错误:无法读取图像文件")
exit()
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 步骤1: 阈值化 - 使用Otsu自动阈值
ret, thresh = cv2.threshold(gray, 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
# 步骤2: 形态学开运算去除噪声
kernel = np.ones((3,3), np.uint8)
opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2)
# 步骤3: 距离变换 - 计算每个前景像素到背景的距离
dist_transform = cv2.distanceTransform(opening, cv2.DIST_L2, 5)
# 步骤4: 确定前景标记 - 取距离较大的区域作为对象中心
# 阈值设为最大距离的70%,可根据实际情况调整
ret, sure_fg = cv2.threshold(dist_transform,
0.7*dist_transform.max(), 255, 0)
sure_fg = np.uint8(sure_fg)
# 步骤5: 确定背景标记 - 通过膨胀扩展背景区域
sure_bg = cv2.dilate(opening, kernel, iterations=3)
# 步骤6: 确定未知区域 - 背景减去前景
unknown = cv2.subtract(sure_bg, sure_fg)
# 步骤7: 创建标记图像
# connectedComponents为每个连通区域分配唯一ID
ret, markers = cv2.connectedComponents(sure_fg)
# ID从1开始,0保留给背景
markers = markers + 1
# 未知区域标记为0
markers[unknown == 255] = 0
# 步骤8: 应用分水岭算法
# 注意:此函数会直接修改markers矩阵
markers = cv2.watershed(img, markers)
# 步骤9: 可视化结果 - 将边界标记为红色
img[markers == -1] = [255, 0, 0]
# 显示结果
plt.figure(figsize=(12, 8))
plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
plt.title('Watershed Segmentation Result')
plt.axis('off')
plt.tight_layout()
plt.savefig('watershed_result.png', dpi=150, bbox_inches='tight')
plt.show()
print(f"检测到 {ret} 个硬币对象")
代码说明:
• THRESH_BINARY_INV + THRESH_OTSU:反转二值化并使用Otsu方法自动选择最佳阈值
• iterations=2:开运算迭代2次,充分去除小噪声
• 0.7*dist_transform.max():取距离最大的70%作为前景,这个比例需要根据图像调整
• markers == -1:分水岭算法将边界像素标记为-1
5.3 高级示例:细胞图像分割
细胞图像通常具有更高的粘连度和更复杂的形态,需要更精细的参数调整:
python
import numpy as np
import cv2
def segment_cells(image_path, output_path='cells_result.png'):
"""
细胞图像分割函数
参数:
image_path: 输入图像路径
output_path: 输出结果路径
"""
# 读取图像
img = cv2.imread(image_path)
if img is None:
raise FileNotFoundError(f"无法读取图像: {image_path}")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 步骤1: 自适应阈值 - 适合光照不均匀的显微图像
# 块大小11,常数C=2
thresh = cv2.adaptiveThreshold(gray, 255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV, 11, 2)
# 步骤2: 形态学操作
kernel = np.ones((3,3), np.uint8)
# 开运算去噪
opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2)
# 闭运算填充细胞内部小孔
closing = cv2.morphologyEx(opening, cv2.MORPH_CLOSE, kernel, iterations=2)
# 步骤3: 距离变换 - 使用L2距离,5x5掩模
dist_transform = cv2.distanceTransform(closing, cv2.DIST_L2, 5)
# 步骤4: 归一化距离变换结果以便可视化
dist_normalized = cv2.normalize(dist_transform, None, 0, 255,
cv2.NORM_MINMAX)
dist_normalized = np.uint8(dist_normalized)
# 步骤5: 确定前景标记 - 降低阈值以捕获更多细胞中心
# 对于粘连严重的细胞,可以降低到0.5或更低
ret, sure_fg = cv2.threshold(dist_transform,
0.5*dist_transform.max(), 255, 0)
sure_fg = np.uint8(sure_fg)
# 步骤6: 确定背景标记
sure_bg = cv2.dilate(closing, kernel, iterations=3)
# 步骤7: 未知区域
unknown = cv2.subtract(sure_bg, sure_fg)
# 步骤8: 创建标记
ret, markers = cv2.connectedComponents(sure_fg)
markers = markers + 1
markers[unknown == 255] = 0
# 步骤9: 分水岭分割
markers_copy = markers.copy() # 备份用于统计
markers = cv2.watershed(img, markers)
# 步骤10: 统计细胞数量(排除背景和边界)
unique_labels = np.unique(markers)
cell_count = len(unique_labels) - 2 # 减去背景(-1)和未知区域(0)
# 步骤11: 可视化 - 为每个细胞分配随机颜色
result_img = img.copy()
colors = np.random.randint(0, 255, (len(unique_labels), 3))
for label in unique_labels:
if label <= 1: # 跳过背景和未知区域
continue
mask = (markers == label)
color = colors[label]
result_img[mask] = color
# 绘制边界
result_img[markers == -1] = [255, 255, 255] # 白色边界
# 保存结果
cv2.imwrite(output_path, result_img)
print(f"分割完成!检测到 {cell_count} 个细胞")
print(f"结果已保存至: {output_path}")
return result_img, cell_count
# 使用示例
if __name__ == '__main__':
result, count = segment_cells('cells.png')
关键改进点:
• 自适应阈值:处理光照不均匀的显微图像效果更好
• 增加闭运算:填充细胞内部的染色不均造成的小孔洞
• 降低前景阈值至0.5:捕获更多可能的细胞中心,避免漏检
• 随机颜色可视化:便于区分相邻的细胞
5.4 交互式分水岭
当自动方法效果不佳时,可以使用鼠标交互手动指定标记点:
python
import cv2
import numpy as np
class InteractiveWatershed:
"""交互式分水岭分割器"""
def __init__(self, image_path):
self.img = cv2.imread(image_path)
if self.img is None:
raise FileNotFoundError(f"无法读取图像: {image_path}")
self.output = self.img.copy()
self.markers = np.zeros(self.img.shape[:2], dtype=np.int32)
self.drawing = False
self.current_marker_id = 1
self.marker_colors = {}
# 创建显示窗口
cv2.namedWindow('Interactive Watershed')
cv2.setMouseCallback('Interactive Watershed', self.on_mouse)
print("=" * 50)
print("交互式分水岭工具使用说明:")
print("左键拖动 - 绘制前景标记(红色)")
print("右键拖动 - 绘制背景标记(蓝色)")
print("'s' 键 - 执行分水岭分割")
print("'r' 键 - 重置所有标记")
print("'q' 键 - 退出程序")
print("=" * 50)
def on_mouse(self, event, x, y, flags, param):
"""鼠标事件回调"""
if event == cv2.EVENT_LBUTTONDOWN:
self.drawing = True
self.current_marker_id += 1
self.marker_colors[self.current_marker_id] = (0, 0, 255) # 红色
elif event == cv2.EVENT_RBUTTONDOWN:
self.drawing = True
self.current_marker_id -= 1
if self.current_marker_id < 1:
self.current_marker_id = 1
self.marker_colors[self.current_marker_id] = (255, 0, 0) # 蓝色
elif event == cv2.EVENT_MOUSEMOVE:
if self.drawing:
# 在标记矩阵上绘制
cv2.circle(self.markers, (x, y), 5,
self.current_marker_id, -1)
# 在显示图像上绘制
color = self.marker_colors.get(self.current_marker_id,
(0, 0, 255))
cv2.circle(self.output, (x, y), 5, color, -1)
elif event in [cv2.EVENT_LBUTTONUP, cv2.EVENT_RBUTTONUP]:
self.drawing = False
cv2.imshow('Interactive Watershed', self.output)
def segment(self):
"""执行分水岭分割"""
if np.max(self.markers) == 0:
print("警告:未绘制任何标记,请先绘制前景和背景标记")
return
# 确保至少有一个前景和一个背景标记
unique_markers = np.unique(self.markers)
if len(unique_markers) < 3: # 包括0(未标记)
print("警告:需要同时绘制前景和背景标记")
return
# 执行分水岭
markers_copy = self.markers.copy()
result_markers = cv2.watershed(self.img, markers_copy)
# 可视化结果
result_img = self.img.copy()
result_img[result_markers == -1] = [0, 255, 0] # 绿色边界
# 为每个区域填充半透明颜色
for marker_id in np.unique(result_markers):
if marker_id <= 0:
continue
mask = (result_markers == marker_id)
color = np.random.randint(50, 200, 3).tolist()
overlay = result_img.copy()
overlay[mask] = color
cv2.addWeighted(overlay, 0.5, result_img, 0.5, 0, result_img)
cv2.imshow('Segmentation Result', result_img)
cv2.imwrite('interactive_result.png', result_img)
print("分割完成!结果已保存")
def reset(self):
"""重置所有标记"""
self.markers = np.zeros(self.img.shape[:2], dtype=np.int32)
self.output = self.img.copy()
self.current_marker_id = 1
self.marker_colors = {}
cv2.imshow('Interactive Watershed', self.output)
print("已重置所有标记")
def run(self):
"""主循环"""
while True:
key = cv2.waitKey(1) & 0xFF
if key == ord('s'):
self.segment()
elif key == ord('r'):
self.reset()
elif key == ord('q'):
break
cv2.destroyAllWindows()
# 使用示例
if __name__ == '__main__':
segmenter = InteractiveWatershed('image.jpg')
segmenter.run()
交互功能说明:
• 左键绘制前景标记(对象内部)
• 右键绘制背景标记
• 按"s"执行分割并查看结果
• 按"r"清除所有标记重新开始
• 按"q"退出程序
六、基础代码代码详解
1、灰度化
python
gray = cv2.cvtColor(
img,
cv2.COLOR_BGR2GRAY
)
作用:
去掉颜色信息。
后续二值化更加稳定。
2、OTSU自动阈值
python
binary = cv2.threshold(
gray,
0,
255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
)[1]
作用:
自动寻找最佳阈值。
无需人工设置。
3、形态学开运算
python
opening = cv2.morphologyEx(
binary,
cv2.MORPH_OPEN,
kernel
)
作用:
去除噪点。
原图
■■■
■□■
■■■
↓
■■■
■■■
4、膨胀得到背景
python
sure_bg = cv2.dilate(
opening,
kernel,
iterations=3
)
作用:
得到:
确定背景
5、距离变换
python
dist = cv2.distanceTransform(
opening,
cv2.DIST_L2,
5
)
距离变换表示:
每个像素距离背景有多远。
中心距离最大。
边缘距离最小。
例如:
0
12321
23432
34543
越亮说明越靠近中心。
6、寻找前景
python
cv2.threshold(
dist,
0.5 * dist.max(),
255,
0
)
得到:
真正属于目标内部的区域
称为:
Sure Foreground
7、未知区域
python
unknown = sure_bg - sure_fg
得到:
██████
□□□□
██████
中间:
□□□□
就是:
未知区域。
之后交给 Watershed 判断。
8、连通域
python
cv2.connectedComponents(
sure_fg
)
得到:
111111
222222
333333
每个目标一个编号。
9、Watershed
python
markers = cv2.watershed(
img,
markers
)
最终:
111111
-1-1
222222
-1:
表示:
分割边界
七、参数说明
DistanceTransform
DIST_L1
曼哈顿距离。
DIST_L2
欧式距离。
最常用。
DIST_C
棋盘距离。
maskSize
3
5
一般:
5
效果最好。
前景阈值
0.3
0.4
0.5
0.6
0.7
越小:
前景越大。
越大:
前景越小。
工业中:
0.4~0.6
最常用。
八、实际效果
原图:
○○○
普通轮廓:
██████
Watershed:
○ ○ ○
每个目标独立编号。
九、工业视觉应用
9.1 医学图像分析
- 细胞计数:在血液涂片、组织切片中自动计数红细胞、白细胞或癌细胞。分水岭能有效分离粘连的血细胞,准确率可达90%以上。
- 组织分割:病理图像中将肿瘤区域与正常组织分离,辅助医生诊断。常结合染色信息(如H&E染色)提高分割精度。
- 神经元追踪:神经科学中分离重叠的神经元突起,重建神经网络结构。
9.2 工业检测
- 零件缺陷检测:在金属表面检测裂纹、气孔等缺陷。分水岭可将缺陷区域从背景中分离出来,便于后续分类。
- 产品计数:流水线上计数瓶装饮料、药片等物品。即使物品部分遮挡也能准确计数。
- 质量分拣:根据分割出的区域大小、形状等特征,自动分级产品质量。
9.3 农业应用
- 果实计数:果园中估算苹果、橙子等果实的数量,预测产量。需处理树叶遮挡和果实重叠问题。
- 叶片分割:植物病害检测中分离病斑与健康组织,计算病害面积占比。
- 种子分析:测量种子的大小、形状分布,评估种子质量。
9.4 文档处理
- 字符分割:手写文字识别中分离粘连的字符。比传统投影法更鲁棒。
- 表格单元格识别:扫描文档中提取表格结构,分隔相邻单元格。
- 印章提取:从复杂背景中分离公章、签名等印章区域。
十、常见问题与解决方案
10.1 过分割问题
症状:分割出过多的小区域,单个对象被分成多块。
原因分析:
- 图像噪声未充分去除,产生虚假局部极小值
- 前景标记提取阈值过高,遗漏了部分对象中心
- 形态学开运算强度不足
解决方案:
- 增强预处理:增加开运算迭代次数或使用更大的核
- 降低距离变换阈值:从0.7降至0.5或更低
- 添加高斯模糊:cv2.GaussianBlur(gray, (5,5), 0)平滑图像
- 使用后处理:合并面积过小的区域
10.2 欠分割问题
症状:应该分开的粘连对象被合并为一个区域。
原因分析:
- 前景标记不足,未能识别出所有对象中心
- 距离变换参数不当,峰值不明显
- 对象间粘连过于紧密
解决方案:
- 降低前景阈值:让更多像素成为前景标记
- 使用距离变换的局部极大值:cv2.dilate配合比较提取峰值
- 尝试不同的距离度量:DIST_L1有时能更好分离特定形状
- 结合边缘检测:先用Canny找到边界,再辅助标记
10.3 边界不精确
症状:分割边界偏离真实对象边缘,出现锯齿或偏移。
原因分析:
- 图像对比度低,边缘模糊
- 预处理过度平滑,丢失细节
- 原始图像分辨率不足
解决方案:
- 减少平滑强度:降低高斯模糊的sigma值
- 使用原图而非平滑图进行分水岭:仅用平滑图提取标记
- 后处理优化:用形态学细化边界
- 提高输入图像质量:使用更高分辨率的图像
10.4 内存溢出
症状:处理大尺寸图像(如4K以上)时程序崩溃或极慢。
解决方案:
- 图像降采样:cv2.resize(img, (width//2, height//2))
- ROI处理:只处理感兴趣区域
- 数据类型优化:使用np.float32而非float64
- 分块处理:将大图分成小块分别处理再拼接
十一、优缺点
优点
✅ 能分离粘连目标
✅ OpenCV自带
✅ 运算速度快
✅ 与距离变换结合效果优秀
✅ 工业视觉应用广泛
缺点
❌ 对噪声敏感
❌ 标记区域不准确时容易过分割
❌ 光照变化较大时效果下降
因此通常需要配合:
- 高斯滤波
- 中值滤波
- 开闭运算
- 距离变换
- 连通域分析
共同使用。
十二、总结
Watershed 是 OpenCV 中经典的图像分割算法,其核心思想是将灰度图视为地形,通过模拟"灌水"过程,在不同区域相遇的位置建立分割边界,从而实现粘连目标的自动分离。
标准流程如下:
原图
↓
灰度化
↓
二值化
↓
形态学去噪
↓
距离变换
↓
前景提取
↓
背景提取
↓
未知区域
↓
连通域标记
↓
Watershed
↓
目标分割
在工业视觉、医学图像、颗粒分析、水滴测量、目标计数等领域,Watershed 依然是一种稳定且高效的经典分割方法。合理结合形态学处理与距离变换,可以显著提升复杂场景下粘连目标的分割效果。