文章目录
前言
在图像处理的过程中,经常需要从图像中将前景对象作为目标图像分割或者提取出来。例如,在视频监控中,观测到的是固定背景下的视频内容,而我们对背景本身并无兴趣,感兴趣的是背景中出现的车辆、行人或者其他对象。我们希望将这些对象从视频中提取出来,而忽略那些没有对象进入背景的视频内容。
一般来说:图像形态学变换、阈值算法、图像金字塔、图像轮廓、边缘检测等方法对图像进行分割。本章介绍使用分水岭算法及GrabCut算法对图像进行分割及提取。
分水岭算法实现图像分割与提取
图像分割是图像处理过程中一种非常重要的操作。分水岭算法将图像形象地比喻为地理学上的地形表面,实现图像分割,该算法非常有效。
算法原理
冈萨雷斯在《数字图像处理》一书中,对分水岭算法进行了细致的分析与介绍。OpenCV的官网建议学习者阅读国立巴黎高等矿业学校图像处理实验室的CMM网站上关于分水岭算法的介绍和动画演示。
下面对分水岭算法的相关内容做简单的介绍。
任何一幅灰度图像,都可以被看作是地理学上的地形表面,灰度值高的区域可以被看成是山峰,灰度值低的区域可以被看成是山谷。如图1所示,其中左图是原始图像,右图是其对应的“地形表面”。
如果我们向每一个山谷中“灌注”不同颜色的水(这里采用了OpenCV官网的表述,冈萨雷斯将灌注表述为在山谷中打洞,然后让水穿过洞以均匀的速率上升)。 那么,随着水位不断地升高,不同山谷的水就会汇集到一起。在这个过程中,为了防止不同山谷的水交汇,我们需要在水流可能汇合的地方构建堤坝。该过程将图像分成两个不同的集合:集水盆地和分水岭线。 我们构建的堤坝就是分水岭线,也即对原始图像的分割。这就是分水岭算法。
在图2中,左图是原始图像,右图是使用分水岭算法得到的图像分割结果。在CMM的网站上不仅提供了该示例图像,还提供了动画演示效果,有兴趣的读者可以去网站上看看。
由于噪声等因素的影响,采用上述基础分水岭算法经常会得到过度分割的结果。过度分割会将图像划分为一个个稠密的独立小块,让分割失去了意义。 图3展示了过度分割的图像。其中左图是电泳现象的图像,右图是过度分割的结果图像,可以看到过度分割现象非常严重。
为了改善图像分割效果,人们提出了基于掩模的改进的分水岭算法。改进的分水岭算法允许用户将他认为是同一个分割区域的部分标注出来(被标注的部分就称为掩模,见图4)。
这样,分水岭算法在处理时,就会将标注的部分处理为同一个分割区域。
在图4中,左图是原始图像,我们对其做了标注处理,其中被标注为深色的三个小色块表示:在使用掩模分水岭算法时,这些部分所包含的颜色都会被分割在同一个区域内。使用掩模分水岭算法得到的分割结果如图4中的右图所示。
采用改进的分水岭算法对图5中左侧的电泳图像进行掩模处理,得到右侧的分割结果。可以看出,分割结果得到明显的改进。
相关函数介绍
在OpenCV中,可以使用函数cv2.watershed()实现分水岭算法。
在具体的实现过程中,还需要借助于形态学函数、距离变换函数cv2.distanceTransform()、cv2.connectedComponents()来完成图像分割。
下面对分水岭算法中用到的函数进行简单的说明。
形态学函数回顾
在使用分水岭算法对图像进行分割前,需要对图像进行简单的形态学处理。先回顾一下形态学里的基本操作。
1.开运算
开运算是先腐蚀、后膨胀的操作,开运算能够去除图像内的噪声。例如,在图6中,先对左图进行腐蚀操作,会得到中间的图像,再对中间的图像进行膨胀操作,会得到右侧的图像。对左图进行开运算(先腐蚀、后膨胀)后,我们得到了右图。通过观察可知,左图在经过开运算后变成右图以后,上面的毛刺(噪声信息)已经被去除了。
对图像进行开运算,能够去除图像内的噪声。在用分水岭算法处理图像前,要先使用开运算去除图像内的噪声,以避免噪声对图像分割可能造成的干扰。
2.获取图像边界
通过形态学操作和减法运算能够获取图像的边界。例如,在图7中,左图是原始图像,中间的图是对其进行腐蚀而得到的图像,对二者进行减法运算,就会得到右侧的图像。通过观察可知,右图是左图的边界。
代码
使用形态学变换,获取一幅图像的边界信息,并观察效果。
import cv2
import numpy as np
import matplotlib.pyplot as plt
o=cv2.imread("my.bmp", cv2.IMREAD_UNCHANGED)
k=np.ones((5,5), np.uint8)
e=cv2.erode(o, k)
b=cv2.subtract(o, e)
plt.subplot(131)
plt.imshow(o)
plt.axis('off')
plt.subplot(132)
plt.imshow(e)
plt.axis('off')
plt.subplot(133)
plt.imshow(b)
plt.axis('off')
plt.show()
运行上述程序,得到结果如图8所示,其中左图是原始图像,中间的图是对其进行腐蚀而得到的图像,右图是原始图像减去腐蚀图像后得到的边界图像。可以看到,右图比较准确地显示出了左图内前景对象的边界信息。
通过以上分析可知,使用形态学操作和减法运算能够获取图像的边界信息。但是,形态学操作仅适用于比较简单的图像。如果图像内的前景对象存在连接的情况,使用形态学操作就无法准确获取各个子图像的边界了。
cv2.distanceTransform函数
当图像内的各个子图没有连接时,可以直接使用形态学的腐蚀操作确定前景对象,但是如果图像内的子图连接在一起时,就很难确定前景对象了
。此时,借助于距离变换函数cv2.distanceTransform()可以方便地将前景对象提取出来。
距离变换函数cv2.distanceTransform()计算二值图像内任意点到最近背景点的距离。一般情况下,该函数计算的是图像内非零值像素点到最近的零值像素点的距离
,即计算二值图像中所有像素点距离其最近的值为0的像素点的距离。当然,如果像素点本身的值为0,则这个距离也为0。
距离变换函数cv2.distanceTransform()的计算结果反映了各个像素与背景(值为0的像素点)的距离关系。通常情况下:
- 如果前景对象的中心(质心)距离值为0的像素点距离较远,会得到一个较大的值。
- 如果前景对象的边缘距离值为0的像素点较近,会得到一个较小的值。
如果对上述计算结果进行阈值化,就可以得到图像内子图的中心、骨架等信息。距离变换函数cv2.distanceTransform()可以用于计算对象的中心,还能细化轮廓、获取图像前景等,有多种功能。
距离变换函数cv2.distanceTransform()的语法格式为:
dst=cv2.distanceTransform(src, distanceType, maskSize[, dstType]])
式中:
-
src是8位单通道的二值图像。
-
distanceType为距离类型参数,其具体值和含义如表1所示。
表1 -
maskSize为掩模的尺寸,其可能的值如表2所示。需要注意,当表1中的distanceType=cv2.DIST_L1或cv2.DIST_C时,maskSize强制为3(因为设置为3和设置为5及更大值没有什么区别)。
- dstType为目标图像的类型,默认值为CV_32F。
- dst表示计算得到的目标图像,可以是8位或32位浮点数,尺寸和src相同。
代码
使用距离变换函数cv2.distanceTransform(),计算一幅图像的确定前景,并观察效果。
import numpy as np
import cv2
import matplotlib.pyplot as plt
img = cv2.imread('C:\\Users\\Administrator\\Desktop\\my.png')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
ishow = img.copy()
ret, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
kernel = np.ones((3, 3), np.uint8)
opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2)
dist_transform = cv2.distanceTransform(opening, cv2.DIST_L2, 5)
ret, fore = cv2.threshold(dist_transform, 0.7 * dist_transform.max(), 255, 0)
plt.subplot(131)
plt.imshow(ishow)
plt.axis('off')
plt.subplot(132)
plt.imshow(dist_transform)
plt.axis('off')
plt.subplot(133)
plt.imshow(fore)
plt.axis('off')
plt.show()
结果
- 左图是原始图像。
- 中间的是距离变换函数cv2.distanceTransform()计算得到的距离图像。
- 右图是对距离图像进行阈值化处理后的结果图像。
从图9可以看到,右图比较准确地显示出左图内的“确定前景”。这里的确定前景,通常是指前景对象的中心。之所以认为这些点是确定前景,是因为它们距离背景点的距离足够远,都是距离大于足够大的固定阈值(0.7*dist_transform.max())的点。
确定未知区域
使用形态学的膨胀操作能够将图像内的前景“膨胀放大”。当图像内的前景被放大后,背景就会被“压缩”,所以此时得到的背景信息一定小于实际背景的,不包含前景的“确定背景”。以下为了方便说明将确定背景称为B。
距离变换函数cv2.distanceTransform()能够获取图像的“中心”,得到“确定前景”。为了方便说明,将确定前景称为F。
图像中有了确定前景F和确定背景B,剩下区域的就是未知区域UN了。这部分区域正是分水岭算法要进一步明确的区域。
针对一幅图像O,通过以下关系能够得到未知区域UN:
未知区域 U N = 图像 O − 确定背景 B − 确定前景 F \begin{equation} 未知区域UN=图像O-确定背景B-确定前景F \end{equation} 未知区域UN=图像O−确定背景B−确定前景F
对上述表达式进行整理,可以得到:
未知区域 U N = (图像 O − 确定背景 B ) − 确定前景 F \begin{equation} 未知区域UN=(图像O-确定背景B)- 确定前景F \end{equation} 未知区域UN=(图像O−确定背景B)−确定前景F
上式中的“图像O-确定背景B”,可以通过对图像进行形态学的膨胀操作得到。
代码
标注一幅图像的确定前景、确定背景及未知区域。
import numpy as np
import cv2
import matplotlib.pyplot as plt
img = cv2.imread('C:\\Users\\Administrator\\Desktop\\my.png')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
ishow = img.copy()
ret, thresh = cv2.threshold(gray, 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
kernel = np.ones((3, 3), np.uint8)
opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2)
bg = cv2.dilate(opening, kernel, iterations=3)
dist = cv2.distanceTransform(opening, cv2.DIST_L2, 5)
ret, fore = cv2.threshold(dist, 0.7 * dist.max(), 255, 0)
fore = np.uint8(fore)
un = cv2.subtract(bg, fore)
plt.subplot(221)
plt.imshow(ishow)
plt.axis('off')
plt.subplot(222)
plt.imshow(bg)
plt.axis('off')
plt.subplot(223)
plt.imshow(fore)
plt.axis('off')
plt.subplot(224)
plt.imshow(un)
plt.axis('off')
plt.show()
- 左上角是原始图像ishow。
- 右上角是对图像ishow进行膨胀后得到的图像bg,其背景图像是确定背景,前景图像是“原始图像-确定背景”。
- 左下角是确定前景图像fore。
- 右下角图像中的小圆环就是未知区域图像un,是由图像bg和图像fore相减得到的。也就是说,未知区域图像un来源于“原始图像-确定背景-确定前景”。值得注意的是,在图10右上角的图像bg中:
- 前景的一个个小圆是“原始图像-确定背景”部分,而不是“确定背景”。
- 其背景图像才是“确定背景”。
cv2.connectedComponents函数
明确了确定前景后,就可以对确定前景图像进行标注了。在OpenCV中,可以使用函数cv2.connectedComponents()进行标注。该函数会将背景标注为0,将其他的对象使用从1开始的正整数标注。
函数cv2.connectedComponents()的语法格式为:
retval, labels = cv2.connectedComponents( image )
式中:
- image为8位单通道的待标注图像。
- retval为返回的标注的数量。
- labels为标注的结果图像。
代码
使用函数cv2.connectedComponents()标注一幅图像,并观察标注的效果。
import numpy as np
import cv2
import matplotlib.pyplot as plt
img = cv2.imread('C:\\Users\\Administrator\\Desktop\\my.png')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
ishow = img.copy()
ret, thresh = cv2.threshold(gray, 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
kernel = np.ones((3, 3), np.uint8)
opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2)
sure_bg = cv2.dilate(opening, kernel, iterations=3)
dist_transform = cv2.distanceTransform(opening, cv2.DIST_L2, 5)
ret, fore = cv2.threshold(dist_transform, 0.7 * dist_transform.max(), 255, 0)
fore = np.uint8(fore)
ret, markers = cv2.connectedComponents(fore)
plt.subplot(131)
plt.imshow(ishow)
plt.axis('off')
plt.subplot(132)
plt.imshow(fore)
plt.axis('off')
plt.subplot(133)
plt.imshow(markers)
plt.axis('off')
print(ret)
plt.show()
- ● 左图是原始图像ishow。
- ● 中间的是经过距离变换后得到的前景图像的中心点图像fore。
- ● 右图是对前景图像的中心点图像进行标注后的结果图像markers。
可以看到,前景图像的中心点被做了不同的标注(用不同颜色区分,在纸质书中显示为不同的灰度)。
函数cv2.connectedComponents()在标注图像时,会将背景标注为0,将其他的对象用从1开始的正整数标注。具体的对应关系为:
- ● 数值0代表背景区域。
- ● 从数值1开始的值,代表不同的前景区域。
在分水岭算法中,标注值0代表未知区域。所以,我们要对函数cv2.connectedComponents()标注的结果进行调整:将标注的结果都加上数值1。经过上述处理后,在标注结果中:
- 数值1代表背景区域。
- 从数值2开始的值,代表不同的前景区域。
为了能够使用分水岭算法,还需要对原始图像内的未知区域进行标注,将已经计算出来的未知区域标注为0即可。
这里的关键代码为:
ret, markers = cv2.connectedComponents(fore)
markers = markers+1
markers[未知区域] = 0
代码
使用函数cv2.connectedComponents()标注一幅图像,并对其进行修正,使未知区域被标注为0,并观察标注的效果。
import numpy as np
import cv2
import matplotlib.pyplot as plt
img = cv2.imread('C:\\Users\\Administrator\\Desktop\\my.png')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
ishow = img.copy()
ret, thresh = cv2.threshold(gray, 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
kernel = np.ones((3, 3), np.uint8)
opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2)
sure_bg = cv2.dilate(opening, kernel, iterations=3)
dist_transform = cv2.distanceTransform(opening, cv2.DIST_L2, 5)
ret, fore = cv2.threshold(dist_transform, 0.7 * dist_transform.max(), 255, 0)
fore = np.uint8(fore)
ret, markers1 = cv2.connectedComponents(fore)
foreAdv = fore.copy()
unknown = cv2.subtract(sure_bg, foreAdv)
ret, markers2 = cv2.connectedComponents(foreAdv)
markers2 = markers2 + 1
markers2[unknown == 255] = 0
plt.subplot(121)
plt.imshow(markers1)
plt.axis('off')
plt.subplot(122)
plt.imshow(markers2)
plt.axis('off')
plt.show()
运行上述程序,得到的结果如图12所示。其中:
- ● 左图是对一幅图像使用函数cv2.connectedComponents()直接标注后的结果。
- ● 右图是修正后的标注结果。
对比左右图可以看出,右图在前景图像的边缘(未知区域)进行了标注,使得每一个确定前景都有一个黑色的边缘,这个边缘是被标注的未知区域。由于图像对比度的原因,在纸质书上观察到的效果可能并不明显,大家可以运行这段代码,在计算机屏幕上观察结果。
cv2.watershed()函数
完成上述处理后,就可以使用分水岭算法对预处理结果图像进行分割了。在OpenCV中,实现分水岭算法的函数是cv2.watershed(),其语法格式为:
markers = cv2.watershed( image, markers )
式中:
- ● image是输入图像,必须是8位三通道的图像。在对图像使用cv2.watershed()函数处理之前,必须先用正数大致勾画出图像中的期望分割区域。每一个分割的区域会被标注为1、2、3等。对于尚未确定的区域,需要将它们标注为0。我们可以将标注区域理解为进行分水岭算法分割的“种子”区域。
- ● markers是32位单通道的标注结果,它应该和image具有相等大小。在markers中,每一个像素要么被设置为初期的“种子值”,要么被设置为“-1”表示边界。markers可以省略。
分水岭算法详细步骤
主要步骤:
-
读取和预处理图像:
- 读取图像并转换为灰度图。
- 应用高斯模糊去除噪声。
-
二值化图像:
- 使用Otsu’s方法进行二值化,并将图像反转,使前景为白色,背景为黑色。
-
形态学操作:
- 通过开运算消除小噪声点。
- 通过膨胀操作扩展背景区域。
-
确定前景和背景区域:
- 进行距离变换,得到前景区域。
- 膨胀得到背景区域。
-
标记未知区域:
- 通过减法运算得到图像中的未知区域(即边界)。
-
应用连通分量分析:
- 对前景区域进行连通分量分析并标记。
- 所有背景标记为1,前景标记为不同的整数,未知区域标记为0。
-
应用分水岭算法:
- 对标记的图像应用分水岭算法。
- 分割的边界会被标记为-1。
-
显示结果:
- 用颜色标记边界并显示图像。
分水岭算法图像分割实例
本节结合前面介绍的知识,讲解一个图像分割实例。使用分水岭算法进行图像分割时,基本的步骤为:
1.通过形态学开运算对原始图像O去噪。
2.通过腐蚀操作获取“确定背景B”。需要注意,这里得到“原始图像-确定背景”即可。
3.利用距离变换函数cv2.distanceTransform()对原始图像进行运算,并对其进行阈值处理,得到“确定前景F”。
4.计算未知区域UN(UN=O -B - F)。
5.利用函数cv2.connectedComponents()对原始图像O进行标注。6.对函数cv2.connectedComponents()的标注结果进行修正。
7.使用分水岭函数完成对图像的分割。
代码
使用分水岭算法对一幅图像进行分割,并观察分割的效果。根据题目的要求,编写代码如下:
import numpy as np
import cv2
import matplotlib.pyplot as plt
# 读取图像并转换颜色空间
img = cv2.imread('C:\\Users\\Administrator\\Desktop\\my.png')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
ishow = img.copy()
# 图像二值化
ret, thresh = cv2.threshold(gray, 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
# 形态学操作
kernel = np.ones((3, 3), np.uint8)
opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2)
sure_bg = cv2.dilate(opening, kernel, iterations=3)
# 距离变换和前景确定
dist_transform = cv2.distanceTransform(opening, cv2.DIST_L2, 5)
ret, sure_fg = cv2.threshold(dist_transform, 0.7 * dist_transform.max(), 255, 0)
sure_fg = np.uint8(sure_fg)
# 确定未知区域
unknown = cv2.subtract(sure_bg, sure_fg)
# 标记和分水岭变换
ret, markers = cv2.connectedComponents(sure_fg)
markers = markers + 1
markers[unknown == 255] = 0
markers = cv2.watershed(img, markers)
img[markers == -1] = [0, 255, 0]
plt.subplot(121)
plt.imshow(ishow)
plt.axis('off')
plt.subplot(122)
plt.imshow(img)
plt.axis('off')
plt.show()