第 22 章
影像分割使用分水岭演算法
本章共 6 个小节 · 分水岭演算法 · distanceTransform() · connectedComponents() · watershed()
前面的章节介绍了阈值、形态学、影像检测、边缘侦测、轮廓侦测等技术。本章说明当目标彼此紧邻或重叠时,如何使用分水岭演算法将影像切分成多个独立区域。
22-1
前言
前面的章节笔者介绍了 阈值、形态学、影像检测、边缘侦测、轮廓侦测 等技术,我们可以很容易取得单一影像的轮廓。假设有两个影像内容如下:
左边是 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()
紧邻硬币在二值化后形成单一连通轮廓。
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)区域;中图呈现从标记、灌水到执行结果;右图是分水岭演算法的执行结果。
22-3
分水岭演算法步骤 1 - 认识 distanceTransform()
使用标记区域的分水岭方法后,可以得到比较好的分水岭结果。
如果影像内的物件是独立的,例如所有硬币彼此独立没有相邻,可以使用第 12-1 节所述的腐蚀(Erosion)操作获得所有硬币的轮廓。可是如果硬币紧邻在一起或部分重叠,例如 coin1.jpg 或 coin2.jpg,则无法使用腐蚀操作获得所有硬币轮廓。
这时需要使用 distanceTransform(),中文可译为距离变换函数。此函数功能是计算二值影像的前景图案内任一点(非零像素值点)到最近背景图案点(零像素值点)的距离。如果输出位置是 0,代表这是背景点;如果用影像显示输出,越亮的点代表距离越远。分水岭演算法的第一步就是取得影像的距离变换函数资讯。
dst = distanceTransform(src, distanceType, maskSize, dstType)
| 参数 | 说明 |
dst | 目标影像,长宽和 src 相同,是 8 位元或 32 位元浮点数。 |
src | 输入影像,此影像格式是 8 位元的二值影像。 |
distanceType | 计算距离的整数资料类型参数,可以参考下表。 |
maskSize | 遮罩大小,可以参考下表。 |
dstType | 可选参数,预设是 CV_32F。 |
| 具名参数 | 说明 |
DIST_USER | 使用者自订距离。 |
DIST_L1 | distance = |x1-x2| + |y1-y2| |
DIST_L2 | 欧几里得距离。 |
DIST_C | distance = max(|x1-x2|, |y1-y2|) |
DIST_L12 | distance = 2(sqrt(1+x*x/2)-1) |
DIST_FAIR | distance = c*c(|x|/c-log(1+|x|/c)), c = 1.3998 |
DIST_WELSCH | distance = c*c/2(1-exp(-(x/c)*(x/c))), c = 2.9846 |
DIST_HUBER | distance = |x| < c ? x*x/2 : c(|x|-c/2), c = 1.345 |
| 具名参数 | 说明 |
DIST_MASK_3 | mask = 3 |
DIST_MASK_5 | mask = 5 |
DIST_MASK_PRECISE | 目前尚未支援 |
如果 distanceType 是 DIST_L1 或 DIST_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()
阈值化影像图是第 19 列设定 0.7*dst.max() 的结果。
上述 0.7 是参考 OpenCV 官方网站的建议,如果将此值更改为 0.5,可以获得较大的前景图案区块。下列是使用 coin1.jpg 的执行结果。
将阈值改为 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()
最右图的黄色区块是未知区域,黄色区内的小圆圈是确定前景。
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()
左图是原始影像,中图是未知区域,右图是标记区。
在前述程式中,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()
将色彩改为 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()
右图以红色线标示分割后的硬币边界。
1. 请自行练习堆叠硬币,然后试著用分水岭演算法做分割,下列是笔者的执行结果。
左图是原始影像,右图是分割结果。