图像分割与分水岭算法
任何灰度图像都可以看作是一个地形表面,其中高强度表示山峰,低强度表示山谷;用不同颜色的水(标签)填充每个孤立的山谷(局部最小值);随着水位的上升,根据附近的山峰(坡度),来自不同山谷的水明显会开始合并,颜色也不同;为了避免这种情况,要在水融合的地方建造屏障;继续填满水,建造障碍,直到所有的山峰都在水下,然后创建的屏障将返回分割结果。这就是Watershed背后的“思想”。
但是这种方法会由于图像中的噪声或其他不规则性而产生过度分割的结果。因此OpenCV实现了一个基于标记的分水岭算法,可以指定哪些是要合并的山谷点,哪些不是;这是一个交互式的图像分割,给对象赋予不同的标签,用一种颜色(或强度)标记确定为前景或对象的区域,用另一种颜色标记确定为背景或非对象的区域,最后用0标记不确定的区域;然后应用分水岭算法;标记将使用给出的标签进行更新,对象的边界值将为-1。
使用距离变换和分水岭分割相互接触的对象
如下面的硬币图像,硬币彼此接触,即使设置阈值,它们也会彼此接触。
先从寻找硬币的近似估计开始,可以使用Otsu的二值化。
现在需要去除图像中的任何白点噪声,为此,可以使用形态学扩张;要去除对象中的任何小孔,可以使用形态学侵蚀;因此,现在可以确定,靠近对象中心的区域是前景,而离对象中心很远的区域是背景。唯一不确定的区域是硬币的边界区域。
因此,需要提取可确定为硬币的区域;侵蚀会去除边界像素。因此,无论剩余多少,都可以肯定它是硬币;如果物体彼此不接触,那将起作用;但是,由于它们彼此接触,因此另一个好选择是找到距离变换并应用适当的阈值。接下来,需要找到确定它们不是硬币的区域;为此,扩张了结果,膨胀将对象边界增加到背景。这样,由于边界区域已删除,因此可以确保结果中背景中的任何区域实际上都是背景。
剩下的区域是不知道的区域,无论是硬币还是背景,分水岭算法应该找到它;这些区域通常位于前景和背景相遇(甚至两个不同的硬币相遇)的硬币边界附近,称之为边界;可以通过从sure_bg区域中减去sure_fg区域来获得。
查看结果,在阈值图像中,得到了一些硬币区域,确定它们是硬币,并且现在已分离它们(在某些情况下,可能只对前景分割感兴趣,而不对分离相互接触的对象感兴趣,无需使用距离变换,只需侵蚀就足够了,侵蚀只是提取确定前景区域的另一种方法)。
现在可以确定哪些是硬币的区域,哪些是背景;因此,创建了标记(它的大小与原始图像的大小相同,但具有int32数据类型),并标记其中的区域;肯定知道的区域(无论是前景还是背景)都标有任何正整数,但是带有不同的整数,而不确定的区域则保留为零。为此,使用函数cv.connectedComponents()
,它用0标记图像的背景,然后其他对象用从1开始的整数标记。
但是如果背景标记为0,则分水岭会将其视为未知区域,所以要用不同的整数来标记它;相反,将未知定义的未知区域标记为0。
标记已准备就绪,进行最后一步,使用分水岭算法,然后标记图像将被修改,边界区域将标记为-1。
watershed_trans.py
import cv2 as cv
import numpy as np
from matplotlib import pyplot as plt
img = cv.imread('./OpenCV/water_coins.jpg')
img2 = img.copy()
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
ret, thresh = cv.threshold(gray, 0, 255, cv.THRESH_BINARY + cv.THRESH_OTSU)
# 噪声去除
# kernel = np.ones((3, 3), np.uint8)
kernel = cv.getStructuringElement(cv.MORPH_RECT, (3, 3))
opening = cv.morphologyEx(thresh, cv.MORPH_OPEN, kernel, iterations=2)
# 确定背景区域
sure_bg = cv.dilate(opening, kernel, iterations=3)
# 寻找前景区域
sure_fg = cv.erode(opening, kernel, iterations=2)
# 找到未知区域
sure_fg = np.uint8(sure_fg)
unknown = cv.subtract(sure_bg, sure_fg)
#类别标记
ret, markers = cv.connectedComponents(sure_fg)
# 为所有的标记加1,保证背景是0而不是1
markers = markers + 1
# 现在让所有的未知区域为0
markers[unknown==255] = 0
markers_copy = markers.copy()
markers_copy[markers==0] = 150 # 灰色表示未知区域
markers_copy[markers==1] = 0 # 黑色表示背景
markers_copy[markers>1] = 255 # 白色表示前景
markers_copy = np.uint8(markers_copy)
markers = cv.watershed(img2, markers)
img2[markers == -1] = [0, 0, 255]
images = [img, thresh, sure_bg, sure_fg, markers_copy, img2]
titles = ['Original', 'Otsu', 'Sure Background', 'Sure Foreground', 'Markers', 'Result']
for i in range(6):
plt.subplot(2, 3, i + 1)
if i == 1:
plt.imshow(images[i], cmap='gray')
else:
plt.imshow(cv.cvtColor(images[i], cv.COLOR_BGR2RGB))
plt.title(titles[i])
plt.xticks([]), plt.yticks([])
plt.show()
交互式前景提取使用GrabCut算法
GrabCut算法由英国微软研究院的Carsten Rother,Vladimir Kolmogorov和Andrew Blake设计。在论文“GrabCut”中:使用迭代图割的交互式前景提取,需要用最少的用户交互进行前景提取的算法,结果是GrabCut。
从用户角度来看,它是如何工作的?最初,用户在前景区域周围绘制一个矩形(前景区域应完全位于矩形内部);然后,算法会对其进行迭代分割,以获得最佳结果;做完后,但在某些情况下,分割可能不会很好,例如,可能将某些前景区域标记为背景,反之亦然;在这种情况下,需要用户进行精修,只需在图像错误分割区域上画些笔画,笔画大致表示 “嘿,该区域应该是前景,你将其标记为背景,在下一次迭代中对其进行校正”或与背景相反。然后在下一次迭代中,你将获得更好的结果。
例如对象被封闭在一个蓝色矩形中,然后用白色笔划(表示前景)和黑色笔划(表示背景)进行最后的修饰,将得到不错的结果。
那么背景发生了什么呢? 用户输入矩形,此矩形外部的所有内容都将作为背景(这是之前提到的在矩形中应包含所有对象的原因);矩形内的所有内容都是未知的,同样,任何指定前景和背景的用户输入都被视为硬标签,这意味着它们在此过程中不会更改。计算机根据提供的数据进行初始标记,它标记前景和背景像素(或对其进行硬标记),使用高斯混合模型(GMM)对前景和背景进行建;根据提供的数据,GMM可以学习并创建新的像素分布,也就是说,未知像素根据颜色统计上与其他硬标记像素的关系而被标记为可能的前景或可能的背景(就像聚类一样);根据此像素分布构建图形。图中的节点为像素,添加了另外两个节点,即“源”节点和“接收器”节点,每个前景像素都连接到源节点,每个背景像素都连接到接收器节点。;通过像素是前景/背景的概率来定义将像素连接到源节点/末端节点的边缘的权重,像素之间的权重由边缘信息或像素相似度定义;如果像素颜色差异很大,则它们之间的边缘将变低;然后使用mincut算法对图进行分割,它将图切成具有最小成本函数的两个分离的源节点和宿节点,成本函数是被切割边缘的所有权重的总和;剪切后,连接到“源”节点的所有像素都变为前景,而连接到“接收器”节点的像素都变为背景;继续该过程,直到分类收敛为止。
如下图所示(图片提供:http://www.cs.ru.ac.za/research/g02m1682/)
OpenCV实现GrabCut
使用OpenCV进行抓取算法,OpenCV为此具有功能cv.grabCut()
,其参数:
- img 输入图像
- mask 掩码图像,在其中指定哪些区域是背景,前景或可能的背景/前景等;通过以下标志完成:
cv.GC_BGD,cv.GC_FGD, ccv.GC_PR_BGD,cv.GC_PR_FGD
,或直接将0,1,2,3传递给图像。 - rect 它是矩形的坐标,其中包括前景对象,格式为(x,y,w,h)
- bdgModel、fgdModel 这些是算法内部使用的数组,只需创建两个大小为(1,65)的np.float64类型零数组。
- iterCount 算法应运行的迭代次数。
- model 应该是
cv.GC_INIT_WITH_RECT
或cv.GC_INIT_WITH_MASK
或两者结合,决定要绘制矩形还是最终的修饰笔触。
首先看矩形模式,加载图像,创建类似的mask图像;创建fgdModel和bgdModel,给出矩形参数,让算法运行5次迭代,模式应为cv.GC_INIT_WITH_RECT
, 因为使用的是矩形; 然后运行grabcut,修改mask图像,在新的mask图像中,像素将被标记有四个标记,分别表示上面指定的背景/前景;因此,我们修改mask,使所有0像素和2像素都置为0(即背景),而所有1像素和3像素均置为1(即前景像素);现在,最终mask已经准备就绪,只需将其与输入图像相乘即可得到分割的图像。
处理结果可能会丢失或者多出部分对象,将使用1像素)进行精细修饰(确保前景);使用一些0像素的修饰(确保背景),可用白色标记错过的前景,用黑色标记不需要的背景,灰色填充剩余的背景进行手动标记新的mask图像。
grabcut.py
import cv2 as cv
import numpy as np
from matplotlib import pyplot as plt
img = cv.imread('./OpenCV/fruit.jpg')
img2 = img.copy()
img3 = img.copy()
mask = np.zeros(img2.shape[:2], np.uint8)
bgdmodel = np.zeros((1, 65), np.float64)
fgdmodel = np.zeros((1, 65), np.float64)
rect = (30, 150, 220, 300)
cv.grabCut(img2, mask, rect, bgdmodel, fgdmodel, 5, cv.GC_INIT_WITH_RECT)
mask2 = np.where((mask == 2) | (mask == 0), 0, 1).astype('uint8')
img2 = img2 * mask2[:, :, np.newaxis]
# 使用新手动标记的mask图像
rnewmask = cv.imread('./OpenCV/apple_mask.png')
newmask = cv.cvtColor(rnewmask, cv.COLOR_BGR2GRAY)
mask[newmask == 0] = 0
mask[newmask == 255] = 1
mask, bgdmodel, fgdmodel = cv.grabCut(img3, mask, None, bgdmodel, fgdmodel, 5, cv.GC_INIT_WITH_MASK)
mask3 = np.where((mask == 2) | (mask == 0), 0, 1).astype('uint8')
img3 = img3 * mask3[:, :, np.newaxis]
images = [img, img2, rnewmask, img3]
titles = ['Original', 'GC_Rect', 'New Mask', 'GC_Mask']
for i in range(4):
plt.subplot(2, 2, i + 1)
plt.imshow(cv.cvtColor(images[i], cv.COLOR_BGR2RGB))
plt.colorbar()
plt.title(titles[i])
plt.xticks([]), plt.yticks([])
plt.show()
交互式示例
catchcut.py
from __future__ import print_function
import cv2 as cv
import numpy as np
import sys
class App():
BLUE = [255, 0, 0] # rectangle color
BLACK = [0, 0, 0] # sure background color
WHITE = [255, 255, 255] # sure foreground color
RED = [0,0,255] # PR BG
GREEN = [0,255,0] # PR FG
DRAW_BG = {'color':BLACK, 'val':0}
DRAW_FG = {'color':WHITE, 'val':1}
# 设置标志
rect = (0, 0, 1, 1)
drawing = False # 用于画线的标志
rectangle = False #用于画矩形的标志
rect_over = False #检查是否画了矩形的标志
rect_or_mask = 100 #用于选择矩形或掩码模式的标志
value = DRAW_FG #初始化为修饰前景
thickness = 3 #画刷宽度
def onmouse(self, event, x, y, flags, param):
# 画矩形
if event == cv.EVENT_RBUTTONDOWN:
self.rectangle = True
self.ix, self.iy = x, y
elif event == cv.EVENT_MOUSEMOVE:
if self.rectangle == True:
self.img = self.img2.copy()
cv.rectangle(self.img, (self.ix, self.iy), (x, y), self.BLUE, 2)
self.rect = (min(self.ix, x), min(self.iy, y), abs(self.ix - x), abs(self.iy - y))
self.rect_or_mask = 0
elif event == cv.EVENT_RBUTTONUP:
self.rectangle = False
self.rect_over = True
cv.rectangle(self.img, (self.ix, self.iy), (x, y), self.BLUE, 2)
self.rect = (min(self.ix, x), min(self.iy, y), abs(self.ix - x), abs(self.iy - y))
self.rect_or_mask = 0
print('现在按键“n”,直到图像没有进一步的更改\n')
# 画修饰线
if event == cv.EVENT_LBUTTONDOWN:
if self.rect_over == False:
print('先画矩形\n')
else:
self.drawing = True
cv.circle(self.img, (x, y), self.thickness, self.value['color'], -1)
cv.circle(self.mask, (x, y), self.thickness, self.value['val'], -1)
elif event == cv.EVENT_MOUSEMOVE:
if self.drawing == True:
cv.circle(self.img, (x, y), self.thickness, self.value['color'], -1)
cv.circle(self.mask, (x, y), self.thickness, self.value['val'], -1)
elif event == cv.EVENT_LBUTTONUP:
if self.drawing == True:
self.drawing = False
cv.circle(self.img, (x, y), self.thickness, self.value['color'], -1)
cv.circle(self.mask, (x, y), self.thickness, self.value['val'], -1)
def run(self):
self.img = cv.imread('./OpenCV/fruit.jpg')
self.img2 = self.img.copy()
self.mask = np.zeros(self.img.shape[:2], dtype=np.uint8)
self.output = np.zeros(self.img.shape, np.uint8)
cv.namedWindow('output')
cv.namedWindow('input')
cv.setMouseCallback('input', self.onmouse)
cv.moveWindow('input', self.img.shape[1] + 10, 90)
while(1):
cv.imshow('output', self.output)
cv.imshow('input', self.img)
k = cv.waitKey(1)
if k == 27:
break
elif k == ord('0'):
self.value = self.DRAW_BG
elif k == ord('1'):
self.value = self.DRAW_FG
elif k == ord('s'):
bar = np.zeros((self.img.shape[0], 5, 3), np.uint8)
res = np.hstack((self.img2, bar, self.img, bar, self.output))
cv.imwrite('./OpenCV/cut_output.png', res)
elif k == ord('r'): # 重新开始
self.rect = (0,0,1,1)
self.drawing = False
self.rectangle = False
self.rect_or_mask = 100
self.rect_over = False
self.value = self.DRAW_FG
self.img = self.img2.copy()
self.mask = np.zeros(self.img.shape[:2], dtype = np.uint8)
self.output = np.zeros(self.img.shape, np.uint8)
elif k == ord('n'): # 分割图像
try:
bgd_model = np.zeros((1, 65), np.float64)
fgd_model = np.zeros((1, 65), np.float64)
if self.rect_or_mask == 0:
cv.grabCut(self.img2, self.mask, self.rect, bgd_model, fgd_model, 1, cv.GC_INIT_WITH_RECT)
self.rect_or_mask = 1
elif self.rect_or_mask == 1:
cv.grabCut(self.img2, self.mask, self.rect, bgd_model, fgd_model, 1, cv.GC_INIT_WITH_MASK)
except:
import traceback
traceback.print_exc()
mask2 = np.where((self.mask==1) + (self.mask==3), 255, 0).astype('uint8')
self.output = cv.bitwise_and(self.img2, self.img2, mask=mask2)
if __name__ == '__main__':
App().run()
cv.destroyAllWindows()
cut_output.png
学习来源:OpenCV-Python中文文档
图像分隔的经典算法-分水岭算法