第 22 章

影像分割使用分水岭演算法

本章共 6 个小节 · 分水岭演算法 · distanceTransform() · connectedComponents() · watershed()
前面的章节介绍了阈值、形态学、影像检测、边缘侦测、轮廓侦测等技术。本章说明当目标彼此紧邻或重叠时,如何使用分水岭演算法将影像切分成多个独立区域。
22-1

前言

前面的章节笔者介绍了 阈值形态学影像检测边缘侦测轮廓侦测 等技术,我们可以很容易取得单一影像的轮廓。假设有两个影像内容如下:

coin1.jpg 与 coin2.jpg 硬币范例
左边是 5 个硬币紧邻在一起的图片,右边除了硬币紧邻,还有硬币重叠的状况。

如果使用前面所学的知识想要取得上述影像,在轮廓取得过程会得到单一轮廓,而不是我们期待的多个硬币个别的轮廓。

程式实例 ch22_1.py:使用 coin1.jpg 影像,获得紧邻硬币的轮廓影像。

# ch22_1.py import cv2 import numpy as np src = cv2.imread("coin1.jpg", cv2.IMREAD_GRAYSCALE) cv2.imshow("Src", src) ret, dst = cv2.threshold(src, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) cv2.imshow("Dst", dst) cv2.waitKey(0) cv2.destroyAllWindows()
执行结果
ch22_1.py 二值化执行结果
紧邻硬币在二值化后形成单一连通轮廓。
22-2

分水岭演算法与 OpenCV 官方推荐网页

上述执行结果验证了紧邻硬币会产生一个轮廓,这一章将介绍分割紧邻硬币的影像为独立轮廓的方法,所使用的是 Image Segmentation with Watershed Algorithm,中文可以翻译为分水岭演算法执行影像分割。

22-2-1 认识分水岭演算法

分水岭演算法的基础观念是将影像视为地形表面,其中高强度像素值代表山峰或是丘陵,低强度像素值代表山谷。

当开始用不同颜色的水(标签)填充每个孤立的山谷(局部最小值),随著水位上升,来自不同山谷的不同颜色的水会开始融合。为了避免不同颜色的水融合,我们可以在水汇集的位置建立障碍,然后继续灌水和建立障碍,直到所有山峰都在水下,最后建立的障碍就是分水岭线,因此可以得到整个分割的结果。

22-2-2 OpenCV 官方推荐网页

有关分水岭演算法的动态影像细节,OpenCV 官网推荐了法国巴黎高等矿业科技大学的 CMM 实验室网页。本小节的影像内容主要取材自该网页。

分水岭初始影像与地形表面示意
左图是一幅影像,右图是将影像视作地形表面。影像内容取材自 https://people.cmm.minesparis.psl.eu/users/beucher/wtshed.html
分水岭演算法灌水与完成结果示意
将水灌入山谷并逐步建立障碍,最后得到分水岭完成后的结果。
均匀灰阶影像的分水岭步骤
编号意义:1 原始影像、2 梯度影像、3 梯度影像的分水岭、4 最后的影像轮廓。

有时在执行时,因为梯度影像的杂讯或局部不规则性,可能会产生过度分割。为了避免这个现象,可以标记部分淹没的影像区域,这些被标记的区域就会被分割在同一区。

使用 marker 区域改善分水岭结果
左图红色区域是标记(marker)区域;中图呈现从标记、灌水到执行结果;右图是分水岭演算法的执行结果。
22-3

分水岭演算法步骤 1 - 认识 distanceTransform()

标记区域分水岭较佳结果
使用标记区域的分水岭方法后,可以得到比较好的分水岭结果。

如果影像内的物件是独立的,例如所有硬币彼此独立没有相邻,可以使用第 12-1 节所述的腐蚀(Erosion)操作获得所有硬币的轮廓。可是如果硬币紧邻在一起或部分重叠,例如 coin1.jpgcoin2.jpg,则无法使用腐蚀操作获得所有硬币轮廓。

这时需要使用 distanceTransform(),中文可译为距离变换函数。此函数功能是计算二值影像的前景图案内任一点(非零像素值点)到最近背景图案点(零像素值点)的距离。如果输出位置是 0,代表这是背景点;如果用影像显示输出,越亮的点代表距离越远。分水岭演算法的第一步就是取得影像的距离变换函数资讯。

dst = distanceTransform(src, distanceType, maskSize, dstType)
参数说明
dst目标影像,长宽和 src 相同,是 8 位元或 32 位元浮点数。
src输入影像,此影像格式是 8 位元的二值影像。
distanceType计算距离的整数资料类型参数,可以参考下表。
maskSize遮罩大小,可以参考下表。
dstType可选参数,预设是 CV_32F
具名参数说明
DIST_USER使用者自订距离。
DIST_L1distance = |x1-x2| + |y1-y2|
DIST_L2欧几里得距离。
DIST_Cdistance = max(|x1-x2|, |y1-y2|)
DIST_L12distance = 2(sqrt(1+x*x/2)-1)
DIST_FAIRdistance = c*c(|x|/c-log(1+|x|/c)), c = 1.3998
DIST_WELSCHdistance = c*c/2(1-exp(-(x/c)*(x/c))), c = 2.9846
DIST_HUBERdistance = |x| < c ? x*x/2 : c(|x|-c/2), c = 1.345
具名参数说明
DIST_MASK_3mask = 3
DIST_MASK_5mask = 5
DIST_MASK_PRECISE目前尚未支援

如果 distanceTypeDIST_L1DIST_C 时,此参数一定是 3

程式实例 ch22_2.py:获得距离变换函数资讯,同时显示结果和阈值的结果。

# ch22_2.py import cv2 import numpy as np import matplotlib.pyplot as plt plt.rcParams["font.family"] = ["Microsoft JhengHei"] src = cv2.imread("opencv_coin.jpg", cv2.IMREAD_COLOR) gray = cv2.cvtColor(src, cv2.COLOR_BGR2GRAY) # 因为在 matplotlib 显示,所以必须转成 RGB 色彩 rgb_src = cv2.cvtColor(src, cv2.COLOR_BGR2RGB) # 二值化 ret, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 执行开启 Opening kernel = np.ones((3, 3), np.uint8) opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2) # 获得距离变换函数结果 dst = cv2.distanceTransform(opening, cv2.DIST_L2, 5) # 读者也可以更改下列 0.7 为其他值,会影响前景大小 ret, sure_fg = cv2.threshold(dst, 0.7 * dst.max(), 255, 0) # 前景图案 plt.subplot(131) plt.title("原始影像") plt.imshow(rgb_src) plt.axis("off") plt.subplot(132) plt.title("距离变换影像") plt.imshow(dst) plt.axis("off") plt.subplot(133) plt.title("阈值化影像") plt.imshow(sure_fg) plt.axis("off") plt.show()
执行结果
ch22_2.py distanceTransform 阈值 0.7 执行结果
阈值化影像图是第 19 列设定 0.7*dst.max() 的结果。

上述 0.7 是参考 OpenCV 官方网站的建议,如果将此值更改为 0.5,可以获得较大的前景图案区块。下列是使用 coin1.jpg 的执行结果。

distanceTransform 阈值 0.5 执行结果
将阈值改为 0.5*dst.max() 时,前景区块较大。
22-4

分水岭演算法步骤 2 - 找出未知区域

前景影像是确定了前景区域,在程式 ch22_2.py 第 19 列的 sure_fg 就是确定的前景。下一步是找出确定的背景图案,可以使用形态学的膨胀(Dilate)观念让前景放大,这时前景以外的区域就是背景,而且所获得的背景一定小于实际背景。这个背景就是确定背景,假设使用 sure_bg 表示。

sure_bg = cv2.dilate(opening, kernel, iterations=2)

最后找出未知区域的方法如下:

sure_fg = np.uint8(sure_fg) unknown = cv2.subtract(sure_bg, sure_fg)

程式实例 ch22_3.py:扩充前一个程式,绘制未知区域。

# ch22_3.py import cv2 import numpy as np import matplotlib.pyplot as plt plt.rcParams["font.family"] = ["Microsoft JhengHei"] src = cv2.imread("opencv_coin.jpg", cv2.IMREAD_COLOR) gray = cv2.cvtColor(src, cv2.COLOR_BGR2GRAY) # 因为在 matplotlib 显示,所以必须转成 RGB 色彩 rgb_src = cv2.cvtColor(src, cv2.COLOR_BGR2RGB) # 二值化 ret, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 执行开启 Opening kernel = np.ones((3, 3), np.uint8) opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2) # 获得距离变换函数结果 dst = cv2.distanceTransform(opening, cv2.DIST_L2, 5) # 读者也可以更改下列 0.7 为其他值,会影响前景大小 ret, sure_fg = cv2.threshold(dst, 0.7 * dst.max(), 255, 0) # 前景图案 # 计算未知区域 sure_fg = np.uint8(sure_fg) sure_bg = cv2.dilate(opening, kernel, iterations=2) unknown = cv2.subtract(sure_bg, sure_fg) plt.subplot(141) plt.title("原始影像") plt.imshow(rgb_src) plt.axis("off") plt.subplot(142) plt.title("距离变换影像") plt.imshow(dst) plt.axis("off") plt.subplot(143) plt.title("阈值化影像") plt.imshow(sure_fg) plt.axis("off") plt.subplot(144) plt.title("未知区域") plt.imshow(unknown) plt.axis("off") plt.show()
执行结果
ch22_3.py 未知区域执行结果
最右图的黄色区块是未知区域,黄色区内的小圆圈是确定前景。
22-5

分水岭演算法步骤 3 - 建立标记

现在我们知道硬币区、背景区和整个影像,下一步是建立标记。这时需要使用 connectedComponents() 函数。此函数会用 0 标记背景,其他物件则从 1、2、... 开始标记,不同的数字代表不同的连通区域。

ret, labels = cv2.connectedComponents(image)
参数 / 回传值说明
ret函数回传的标记数量。
labels影像上每一个像素的标记,不同数字代表不同的连通区域。
image输入影像,这是 8 位元需要标记的影像。

程式实例 ch22_4.py:扩充设计 ch22_3.py,绘制标记区域。

# ch22_4.py import cv2 import numpy as np import matplotlib.pyplot as plt plt.rcParams["font.family"] = ["Microsoft JhengHei"] src = cv2.imread("opencv_coin.jpg", cv2.IMREAD_COLOR) gray = cv2.cvtColor(src, cv2.COLOR_BGR2GRAY) # 因为在 matplotlib 显示,所以必须转成 RGB 色彩 rgb_src = cv2.cvtColor(src, cv2.COLOR_BGR2RGB) # 二值化 ret, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 执行开启 Opening kernel = np.ones((3, 3), np.uint8) opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2) # 获得距离变换函数结果 dst = cv2.distanceTransform(opening, cv2.DIST_L2, 5) # 读者也可以更改下列 0.7 为其他值,会影响前景大小 ret, sure_fg = cv2.threshold(dst, 0.7 * dst.max(), 255, 0) # 前景图案 # 计算未知区域 sure_fg = np.uint8(sure_fg) sure_bg = cv2.dilate(opening, kernel, iterations=2) unknown = cv2.subtract(sure_bg, sure_fg) # 标记区 ret, markers = cv2.connectedComponents(sure_fg) plt.subplot(131) plt.title("原始影像") plt.imshow(rgb_src) plt.axis("off") plt.subplot(132) plt.title("未知区域") plt.imshow(unknown) plt.axis("off") plt.subplot(133) plt.title("标记区") plt.imshow(markers) plt.axis("off") plt.show()
执行结果
ch22_4.py 标记区执行结果
左图是原始影像,中图是未知区域,右图是标记区。

在前述程式中,connectedComponents() 函数获得标记如下:

0:代表背景。 1, 2, ...:代表不同的前景区域。

在分水岭演算法中,对于背景 0 是代表未知区域,1 代表背景,使用 2、3、... 代表不同的前景区域,所以还需要调整,将所有的 markers 加 1。

markers = markers + 1

将未知区域设为 0:

markers[unknown == 255] = 0

程式实例 ch22_5.py:扩充设计 ch22_4.py,增加修正标记。

# ch22_5.py import cv2 import numpy as np import matplotlib.pyplot as plt plt.rcParams["font.family"] = ["Microsoft JhengHei"] src = cv2.imread("opencv_coin.jpg", cv2.IMREAD_COLOR) gray = cv2.cvtColor(src, cv2.COLOR_BGR2GRAY) # 因为在 matplotlib 显示,所以必须转成 RGB 色彩 rgb_src = cv2.cvtColor(src, cv2.COLOR_BGR2RGB) # 二值化 ret, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 执行开启 Opening kernel = np.ones((3, 3), np.uint8) opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2) # 获得距离变换函数结果 dst = cv2.distanceTransform(opening, cv2.DIST_L2, 5) # 读者也可以更改下列 0.7 为其他值,会影响前景大小 ret, sure_fg = cv2.threshold(dst, 0.7 * dst.max(), 255, 0) # 前景图案 # 计算未知区域 sure_fg = np.uint8(sure_fg) sure_bg = cv2.dilate(opening, kernel, iterations=2) unknown = cv2.subtract(sure_bg, sure_fg) # 标记区 ret, markers = cv2.connectedComponents(sure_fg) # 标记修正 markers_new = markers + 1 markers_new[unknown == 255] = 0 plt.subplot(131) plt.title("未知区域") plt.imshow(unknown) plt.axis("off") plt.subplot(132) plt.title("标记区") plt.imshow(markers, cmap="jet") plt.axis("off") plt.subplot(133) plt.title("标记修订区") plt.imshow(markers_new, cmap="jet") plt.axis("off") plt.show()
执行结果
ch22_5.py 标记修订区执行结果
将色彩改为 jet 后,较容易看清标记区与标记修订区的差异。
22-6

完成分水岭演算法

完成先前的准备工作后,最后一步就是使用 OpenCV 的 watershed() 完成影像分割。函数语法如下:

markers = cv2.watershed(img, markers)

上述 img 是原始读取的彩色影像,markers 是标记的结果,边界区域将被标记为 -1

程式实例 ch22_6.py:完成分水岭分割影像。

# ch22_6.py import cv2 import numpy as np import matplotlib.pyplot as plt plt.rcParams["font.family"] = ["Microsoft JhengHei"] src = cv2.imread("opencv_coin.jpg", cv2.IMREAD_COLOR) gray = cv2.cvtColor(src, cv2.COLOR_BGR2GRAY) # 因为在 matplotlib 显示,所以必须转成 RGB 色彩 rgb_src = cv2.cvtColor(src, cv2.COLOR_BGR2RGB) # 二值化 ret, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 执行开启 Opening kernel = np.ones((3, 3), np.uint8) opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2) # 获得距离变换函数结果 dst = cv2.distanceTransform(opening, cv2.DIST_L2, 5) # 读者也可以更改下列 0.7 为其他值,会影响前景大小 ret, sure_fg = cv2.threshold(dst, 0.7 * dst.max(), 255, 0) # 前景图案 # 计算未知区域 sure_fg = np.uint8(sure_fg) sure_bg = cv2.dilate(opening, kernel, iterations=2) unknown = cv2.subtract(sure_bg, sure_fg) # 标记区 ret, markers = cv2.connectedComponents(sure_fg) markers = markers + 1 markers[unknown == 255] = 0 # 正式执行分水岭函数 dst = rgb_src.copy() markers = cv2.watershed(dst, markers) dst[markers == -1] = [255, 0, 0] # 使用红色显示 plt.subplot(121) plt.title("原始影像") plt.imshow(rgb_src) plt.axis("off") plt.subplot(122) plt.title("分割结果") plt.imshow(dst) plt.axis("off") plt.show()
执行结果
ch22_6.py 分水岭分割结果
右图以红色线标示分割后的硬币边界。
习题

1. 请自行练习堆叠硬币,然后试著用分水岭演算法做分割,下列是笔者的执行结果。

第 22 章习题硬币分水岭分割结果
左图是原始影像,右图是分割结果。