在图像处理的过程中,经常需要从图像中将前景对象作为目标图像分割或者提取出来。例如,在视频监控中,观测到的是固定背景下的视频内容,而我们对背景本身并无兴趣,感兴趣的是背景中出现的车辆、行人或者其他对象。我们希望将这些对象从视频中提取出来,而忽略那些没有对象进入背景的视频内容。
图像分割是图像处理过程中一种非常重要的操作。分水岭算法将图像形象地比喻为地理学上的地形表面,实现图像分割,该算法非常有效。
任何一幅灰度图像,都可以被看作是地理学上的地形表面,灰度值高的区域可以被看成是山峰,灰度值低的区域可以被看成是山谷。
如果我们向每一个山谷中“灌注”不同颜色的水.那么,随着水位不断地升高,不同山谷的水就会汇集到一起。在这个过程中,为了防止不同山谷的水交汇,我们需要在水流可能汇合的地方构建堤坝。该过程将图像分成两个不同的集合:集水盆地和分水岭线。我们构建的堤坝就是分水岭线,也即对原始图像的分割。这就是分水岭算法。
左图是原始图像,右图是使用分水岭算法得到的图像分割结果。
由于噪声等因素的影响,采用上述基础分水岭算法经常会得到过度分割的结果。过度分割会将图像划分为一个个稠密的独立小块,让分割失去了意义。下图展示了过度分割的图像。其中左图是电泳现象的图像,右图是过度分割的结果图像,可以看到过度分割现象非常严重。
为了改善图像分割效果,人们提出了基于掩模的改进的分水岭算法。改进的分水岭算法允许用户将他认为是同一个分割区域的部分标注出来(被标注的部分就称为掩模)。这样,分水岭算法在处理时,就会将标注的部分处理为同一个分割区域。
在 OpenCV 中,可以使用函数cv2.watershed()实现分水岭算法。在具体的实现过程中,还需要借助于形态学函数、距离变换函数cv2.distanceTransform()、cv2.connectedComponents()来完成图像分割。下面对分水岭算法中用到的函数进行简单的说明。
在使用分水岭算法对图像进行分割前,需要对图像进行简单的形态学处理。先回顾一下形态学里的基本操作。
开运算是先腐蚀、后膨胀的操作,开运算能够去除图像内的噪声。
对图像进行开运算,能够去除图像内的噪声。在用分水岭算法处理图像前,要先使用开运算去除图像内的噪声,以避免噪声对图像分割可能造成的干扰。
通过形态学操作和减法运算能够获取图像的边界。例如,在图中,左图是原始图像,中间的图是对其进行腐蚀而得到的图像,对二者进行减法运算,就会得到右侧的图像。通过观察可知,右图是左图的边界。
通过以上分析可知,使用形态学操作和减法运算能够获取图像的边界信息。但是,形态学操作仅适用于比较简单的图像。如果图像内的前景对象存在连接的情况,使用形态学操作就无法准确获取各个子图像的边界了。
距离变换函数cv2.distanceTransform()计算二值图像内任意点到最近背景点的距离。一般情况下,该函数计算的是图像内非零值像素点到最近的零值像素点的距离,即计算二值图像中所有像素点距离其最近的值为0 的像素点的距离。当然,如果像素点本身的值为0,则这个距离也为0。
距离变换函数cv2.distanceTransform()的计算结果反映了各个像素与背景(值为0 的像素点)的距离关系。通常情况下:
如果对上述计算结果进行阈值化,就可以得到图像内子图的中心、骨架等信息。距离变换函数cv2.distanceTransform()可以用于计算对象的中心,还能细化轮廓、获取图像前景等,有多种功能。
dst=cv2.distanceTransform(src, distanceType, maskSize[, dstType]])
import numpy as np
import cv2
import matplotlib.pyplot as plt
img = cv2.imread('water_coins.jpg')
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')
使用形态学的膨胀操作能够将图像内的前景“膨胀放大”。当图像内的前景被放大后,背景就会被“压缩”,所以此时得到的背景信息一定小于实际背景的,不包含前景的“确定背景”。以下为了方便说明将确定背景称为B。
距离变换函数cv2.distanceTransform()能够获取图像的“中心”,得到“确定前景”。为了方便说明,将确定前景称为F。
图像中有了确定前景F和确定背景B,剩下区域的就是未知区域UN了。这部分区域正是分水岭算法要进一步明确的区域。
针对一幅图像O,通过以下关系能够得到未知区域UN:
未知区域UN =(图像O - 确定背景B)- 确定前景F
import numpy as np
import cv2
import matplotlib.pyplot as plt
img = cv2.imread('water_coins.jpg')
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')
明确了确定前景后,就可以对确定前景图像进行标注了。在 OpenCV 中,可以使用函数cv2.connectedComponents()进行标注。该函数会将背景标注为0,将其他的对象使用从1 开始的正整数标注。
retval, labels = cv2.connectedComponents( image )
import numpy as np
import cv2
import matplotlib.pyplot as plt
img = cv2.imread('water_coins.jpg')
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)
函数 cv2.connectedComponents()在标注图像时,会将背景标注为0,将其他的对象用从1开始的正整数标注。具体的对应关系为:
在分水岭算法中,标注值0 代表未知区域。所以,我们要对函数cv2.connectedComponents()标注的结果进行调整:将标注的结果都加上数值1。经过上述处理后,在标注结果中:
为了能够使用分水岭算法,还需要对原始图像内的未知区域进行标注,将已经计算出来的未知区域标注为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('water_coins.jpg')
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')
对比左右图可以看出,右图在前景图像的边缘(未知区域)进行了标注,使得每一个确定前景都有一个黑色的边缘,这个边缘是被标注的未知区域。
完成上述处理后,就可以使用分水岭算法对预处理结果图像进行分割了。
markers = cv2.watershed( image, markers )
本节结合前面介绍的知识,讲解一个图像分割实例。使用分水岭算法进行图像分割时,基本的步骤为:
import numpy as np
import cv2
import matplotlib.pyplot as plt
img = cv2.imread('water_coins.jpg')
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)
#1. 通过形态学开运算对原始图像O去噪。
opening = cv2.morphologyEx(thresh,cv2.MORPH_OPEN,kernel, iterations = 2)
#2. 通过膨胀操作获取“确定背景B”。
sure_bg = cv2.dilate(opening,kernel,iterations=3)
#3. 利用距离变换函数cv2.distanceTransform()对原始图像进行运算,并对其进行阈值处理,得到“确定前景F”。
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)
#4. 计算未知区域UN
unknown = cv2.subtract(sure_bg,sure_fg)
#5. 利用函数cv2.connectedComponents()对原始图像O进行标注。
ret, markers = cv2.connectedComponents(sure_fg)
#6. 对函数cv2.connectedComponents()的标注结果进行修正。
markers = markers+1
markers[unknown==255] = 0
#7. 使用分水岭函数完成对图像的分割。
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')
在开始提取前景时,先用一个矩形框指定前景区域所在的大致位置范围,然后不断迭代地分割,直到达到最好的效果。经过上述处理后,提取前景的效果可能并不理想,存在前景没有提取出来,或者将背景提取为前景的情况,此时需要用户干预提取过程。用户在原始图像的副本中(也可以是与原始图像大小相等的任意一幅图像),用白色标注要提取为前景的区域,用黑色标注要作为背景的区域。然后,将标注后的图像作为掩模,让算法继续迭代提取前景从而得到最终结果。
例如,对于下图中的的左图,先用矩形框将要提取的前景Lena 框出来,再分别用白色和黑色对前景图像、背景图像进行标注。完成标注后,使用交互式前景提取算法,就会得到右图所示的结果图像。
在 OpenCV 中,实现交互式前景提取的函数是cv2.grabCut(),其语法格式为:
mask, bgdModel, fgdModel =cv2.grabCut(img, mask, rect, bgdModel, fgdModel, iterCount[, mode])
import numpy as np
import cv2
import matplotlib.pyplot as plt
o = cv2.imread('lenacolor.png')
orgb=cv2.cvtColor(o,cv2.COLOR_BGR2RGB)
mask = np.zeros(o.shape[:2],np.uint8)
bgdModel = np.zeros((1,65),np.float64)
fgdModel = np.zeros((1,65),np.float64)
rect = (50,50,400,500)
cv2.grabCut(o,mask,rect,bgdModel,fgdModel,5,cv2.GC_INIT_WITH_RECT)
mask2 = np.where((mask==2)|(mask==0),0,1).astype('uint8')
ogc = o*mask2[:,:,np.newaxis]
ogc=cv2.cvtColor(ogc,cv2.COLOR_BGR2RGB)
plt.subplot(121)
plt.imshow(orgb)
plt.axis('off')
plt.subplot(122)
plt.imshow(ogc)
plt.axis('off')
可以看到,在不使用掩模(掩模值都设置为默认值0 时),函数cv2.grabCut()的处理效果并不太好:提取图中左图的前景时,人物的帽子没有提取完整。对于有些图像,也有可能将背景错误地提取出来。
为了得到完整的前景对象,需要做一些改进。这里对原始图像进行标注,将需要保留的部分设置为白色,将需要删除的背景设置为黑色。以标记好的图像作为模板,使用函数cv2.grabCut()完成前景的提取。
这个过程主要包含以下步骤:
需要注意,在上述步骤中,使用画笔标记的模板图像m0 不能直接作为模板(即参数mask)使用。函数cv2.grabCut()要求,参数mask 的值必须是cv2.GC_BGD(确定背景)、cv2.GC_FGD(确定前景)、cv2.GC_PR_BGD(可能的背景)、cv2.GC_PR_FGD(可能的前景),或者是0、1、2、3 之中的值。此时的模板图像m0 中,存在着[0, 255]内的值,所以它的值不满足函数cv2.grabCut()的要求,无法作为参数mask 直接使用。必须先将模板图像m0 中的白色值和黑色值映射到模板m 上,再将模板图像m作为函数cv2.grabCut()的模板参数。
import numpy as np
import cv2
import matplotlib.pyplot as plt
o= cv2.imread('lenacolor.png')
orgb=cv2.cvtColor(o,cv2.COLOR_BGR2RGB)
mask = np.zeros(o.shape[:2],np.uint8)
bgd = np.zeros((1,65),np.float64)
fgd = np.zeros((1,65),np.float64)
rect = (50,50,400,500)
cv2.grabCut(o,mask,rect,bgd,fgd,5,cv2.GC_INIT_WITH_RECT)
mask2 = cv2.imread('mask.png',0)
mask2Show = cv2.imread('mask.png',-1)
m2rgb=cv2.cvtColor(mask2Show,cv2.COLOR_BGR2RGB)
mask[mask2 == 0] = 0
mask[mask2 == 255] = 1
mask, bgd, fgd = cv2.grabCut(o,mask,None,bgd,fgd,5,cv2.GC_INIT_WITH_MASK)
mask = np.where((mask==2)|(mask==0),0,1).astype('uint8')
ogc = o*mask[:,:,np.newaxis]
ogc=cv2.cvtColor(ogc,cv2.COLOR_BGR2RGB)
plt.subplot(121)
plt.imshow(m2rgb)
plt.axis('off')
plt.subplot(122)
plt.imshow(ogc)
plt.axis('off')
在函数 cv2.grabCut()的实际使用中,也可以不使用矩形初始化,直接使用模板模式。构造一个模板图像,其中:
构造完模板后,直接将该模板用于函数cv2.grabCut()处理原始图像,即可完成前景的提取。一般情况下,自定义模板的步骤为:
import numpy as np
import cv2
import matplotlib.pyplot as plt
o= cv2.imread('lenacolor.png')
orgb=cv2.cvtColor(o,cv2.COLOR_BGR2RGB)
bgd = np.zeros((1,65),np.float64)
fgd = np.zeros((1,65),np.float64)
mask2 = np.zeros(o.shape[:2],np.uint8)
#先将掩模的值全部构造为0(确定背景),在后续步骤中,再根据需要修改其中的部分值
mask2[30:512,50:400]=3 #lena 头像的可能区域
mask2[50:300,150:200]=1 #lena 头像的确定区域,如果不设置这个区域,头像的提取不完整
cv2.grabCut(o,mask2,None,bgd,fgd,5,cv2.GC_INIT_WITH_MASK)
mask2 = np.where((mask2==2)|(mask2==0),0,1).astype('uint8')
ogc = o*mask2[:,:,np.newaxis]
ogc=cv2.cvtColor(ogc,cv2.COLOR_BGR2RGB)
plt.subplot(121)
plt.imshow(orgb)
plt.axis('off')
plt.subplot(122)
plt.imshow(ogc)
plt.axis('off')