在本章中, - 我们将学习使用分水岭算法实现基于标记的图像分割 - 我们将看到:cv.watershed()
任何灰度图像都可以看作是一个地形表面,其中高强度表示山峰,低强度表示山谷。你开始用不同颜色的水(标签)填充每个孤立的山谷(局部最小值)。随着水位的上升,根据附近的山峰(坡度),来自不同山谷的水明显会开始合并,颜色也不同。为了避免这种情况,你要在水融合的地方建造屏障。你继续填满水,建造障碍,直到所有的山峰都在水下。然后你创建的屏障将返回你的分割结果。这就是Watershed背后的“思想”。你可以访问Watershed的CMM网页,了解它与一些动画的帮助。
但是这种方法会由于图像中的噪声或其他不规则性而产生过度分割的结果。因此OpenCV实现了一个基于标记的分水岭算法,你可以指定哪些是要合并的山谷点,哪些不是。这是一个交互式的图像分割。我们所做的是给我们知道的对象赋予不同的标签。用一种颜色(或强度)标记我们确定为前景或对象的区域,用另一种颜色标记我们确定为背景或非对象的区域,最后用0
标记我们不确定的区域。这是我们的标记。然后应用分水岭算法。然后我们的标记将使用我们给出的标签进行更新,对象的边界值将为-1
。
下面我们将看到一个有关如何使用距离变换和分水岭来分割相互接触的对象的示例。
考虑下面的硬币图像,硬币彼此接触。即使你设置阈值,它也会彼此接触。
我们先从寻找硬币的近似估计开始。因此,我们可以使用Otsu的二值化。
现在我们需要去除图像中的任何白点噪声。为此,我们可以使用形态学扩张。要去除对象中的任何小孔,我们可以使用形态学侵蚀。因此,现在我们可以确定,靠近对象中心的区域是前景,而离对象中心很远的区域是背景。我们不确定的唯一区域是硬币的边界区域。
因此,我们需要提取我们可确定为硬币的区域。侵蚀会去除边界像素。因此,无论剩余多少,我们都可以肯定它是硬币。如果物体彼此不接触,那将起作用。但是,由于它们彼此接触,因此另一个好选择是找到距离变换并应用适当的阈值。接下来,我们需要找到我们确定它们不是硬币的区域。为此,我们扩张了结果。膨胀将对象边界增加到背景。这样,由于边界区域已删除,因此我们可以确保结果中背景中的任何区域实际上都是背景。参见下图。
剩下的区域是我们不知道的区域,无论是硬币还是背景。分水岭算法应该找到它。这些区域通常位于前景和背景相遇(甚至两个不同的硬币相遇)的硬币边界附近。我们称之为边界。可以通过从sure_bg
区域中减去sure_fg
区域来获得。
# 噪声去除 morphology形态学
kernel = np.ones((3,3),np.uint8)
opening = cv.morphologyEx(thresh,cv.MORPH_OPEN,kernel, iterations = 2)
# 确定背景区域 dilate扩张
sure_bg = cv.dilate(opening,kernel,iterations=3)
# 寻找前景区域distanceTransform方法用于计算图像中每一个非零点距离离自己最近的零点的距离,
# distanceTransform的第二个Mat矩阵参数dst保存了每一个点与最近的零点的距离信息
# ,图像上越亮的点,代表了离零点的距离越远
dist_transform = cv.distanceTransform(opening,cv.DIST_L2,5)
ret, sure_fg = cv.threshold(dist_transform,0.7*dist_transform.max(),255,0)
cv.imshow("dist",dist_transform)
cv.waitKey(0)
cv.imshow("sure_fg",sure_fg)
cv.waitKey(0)
# 找到未知区域
sure_fg = np.uint8(sure_fg)
unknown = cv.subtract(sure_bg,sure_fg)#强度值相减,前面减后面
cv.imshow("2",unknown)
cv.waitKey(0)
查看结果。在阈值图像中,我们得到了一些硬币区域,我们确定它们是硬币,并且现在已分离它们。(在某些情况下,你可能只对前景分割感兴趣,而不对分离相互接触的对象感兴趣。在那种情况下,你无需使用距离变换,只需侵蚀就足够了。侵蚀只是提取确定前景区域的另一种方法。)
现在我们可以确定哪些是硬币的区域,哪些是背景。因此,我们创建了标记(它的大小与原始图像的大小相同,但具有int32数据类型),并标记其中的区域。我们肯定知道的区域(无论是前景还是背景)都标有任何正整数,但是带有不同的整数,而我们不确定的区域则保留为零。为此,我们使用**cv.connectedComponents**()。它用0标记图像的背景,然后其他对象用从1开始的整数标记。
但是我们知道,如果背景标记为0,则分水岭会将其视为未知区域。所以我们想用不同的整数来标记它。相反,我们将未知定义的未知区域标记为0。
# 类别标记
ret, markers = cv.connectedComponents(sure_fg)#这个函数在后面有详细说明
# 为所有的标记加1,保证背景是0而不是1
markers = markers+1
# 现在让所有的未知区域为0
markers[unknown==255] = 0
连通区域计算:功能很强大,比计算轮廓好用
cv::connectedComponents(InputArray img_, OutputArray _labels, int connectivity, int ltype);
参数分别为:输入图像,输出图像,连通区域计算方式(4连通 or 8连通),图像类型(CV_16U or CV_32S)
输出图像创建了一个标记图(图中不同连通域使用不同的标记,和原图宽高一致)。
(备注:connectedComponentsWithStats()也可以完成上面任务,除此之外,还可以返回每个连通区域的重要信息-- bounding box, area, and center of mass( centroid).)
应用实例:识别图像中的物理像素点
原图 二值化图
连通区域计算结果图
填充不同颜色后的图像
参见JET colormap中显示的结果。深蓝色区域显示未知区域。当然,硬币被不同的值染上不同的颜色。与未知区域相比,确定背景的剩余区域显示为浅蓝色。
现在我们的标记已准备就绪。现在是最后一步的时候了,使用分水岭算法。然后标记图像将被修改。边界区域将标记为-1。
markers = cv.watershed(img,markers)
img[markers == -1] = [0,0,255]#img中makers为-1的位置置为红色(bgr)
cv.imshow("img",img)
cv.waitKey(0)
请参阅下面的结果。对某些硬币,它们接触的区域被正确地分割,而对于某些硬币,却不是。
在本章中, - 我们将看到GrabCut算法来提取图像中的前景 - 我们将为此创建一个交互式应用程序。
GrabCut算法由英国微软研究院的Carsten Rother,Vladimir Kolmogorov和Andrew Blake设计。在他们的论文“GrabCut”中:使用迭代图割的交互式前景提取。需要用最少的用户交互进行前景提取的算法,结果是GrabCut。
从用户角度来看,它是如何工作的?最初,用户在前景区域周围绘制一个矩形(前景区域应完全位于矩形内部)。然后,算法会对其进行迭代分割,以获得最佳结果。做完了但在某些情况下,分割可能不会很好,例如,可能已将某些前景区域标记为背景,反之亦然。在这种情况下,需要用户进行精修。只需在图像错误分割区域上画些笔画。笔画基本上说 “嘿,该区域应该是前景,你将其标记为背景,在下一次迭代中对其进行校正”或与背景相反。然后在下一次迭代中,你将获得更好的结果。
参见下图。第一名球员和橄榄球被封闭在一个蓝色矩形中。然后用白色笔划(表示前景)和黑色笔划(表示背景)进行最后的修饰。而且我们得到了不错的结果。
那么背景发生了什么呢?
- 用户输入矩形。此矩形外部的所有内容都将作为背景(这是在矩形应包含所有对象之前提到的原因)。矩形内的所有内容都是未知的。同样,任何指定前景和背景的用户输入都被视为硬标签,这意味着它们在此过程中不会更改。
- 计算机根据我们提供的数据进行初始标记。它标记前景和背景像素(或对其进行硬标记),现在使用高斯混合模型(GMM)对前景和背景进行建模。
- 根据我们提供的数据,GMM高斯混合模型(Gaussian Mixed Model)可以学习并创建新的像素分布。也就是说,未知像素根据颜色统计上与其他硬标记像素的关系而被标记为可能的前景或可能的背景(就像聚类一样)。
- 根据此像素分布构建图形。图中的节点为像素。添加了另外两个节点,即“源”(source)节点和“汇”(sink)节点。每个前景像素都连接到源节点,每个背景像素都连接到接收器节点。
- 通过像素是前景/背景的概率来定义将像素连接到源节点/末端(end)节点的边缘的权重。像素之间的权重由边缘信息或像素相似度定义。如果像素颜色差异很大,则它们之间的边缘将变低。
- 然后使用mincut算法对图进行分割。它将图切成具有最小成本函数的两个分离的源节点和汇节点。成本函数是被切割边缘的所有权重的总和。剪切后,连接到“源”节点的所有像素都变为前景,而连接到“接收器”节点的像素都变为背景。
- 继续该过程,直到分类收敛为止。
如下图所示(图片提供:"GrabCut")
现在我们使用OpenCV进行抓取算法。OpenCV为此具有功能**cv.grabCut**(),我们将首先看到其参数:
- img - 输入图像
- mask - 这是一个掩码图像,在其中我们指定哪些区域是背景,前景或可能的背景/前景等。这是通过以下标志完成的:cv.GC_BGD,cv.GC_FGD, cv.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(ie background 即背景 ie 即的意思),而所有1像素和3像素均置为1(即前景像素)。现在,我们的最终mask已经准备就绪。只需将其与输入图像相乘即可得到分割的图像
import numpy as np
import cv2 as cv
from matplotlib import pyplot as plt
img = cv.imread('3people.png')
mask = np.zeros(img.shape[:2],np.uint8)
bgdModel = np.zeros((1,65),np.float64)
fgdModel = np.zeros((1,65),np.float64)
rect = (50,50,450,290)
cv.grabCut(img,mask,rect,bgdModel,fgdModel,5,cv.GC_INIT_WITH_RECT)
mask2 = np.where((mask==2)|(mask==0),0,1).astype('uint8')
'''np.where(条件,结果1,结果2)类似c里面的条件选择'''
img = img*mask2[:,:,np.newaxis]#第三维加一维,防止乘不了
'''np.newaxis的作用就是在这一位置增加一个一维,这一位置指的是np.newaxis所在的位置,举个例子如下。
x1 = np.array([1, 2, 3, 4, 5])
# the shape of x1 is (5,)
x1_new = x1[:, np.newaxis]
# now, the shape of x1_new is (5, 1)
# array([[1],
# [2],
# [3],
# [4],
# [5]])
x1_new = x1[np.newaxis,:]
# now, the shape of x1_new is (1, 5)
# array([[1, 2, 3, 4, 5]])
newaxis本质:
是不是还是不理解?没关系,上面例子先放一放,继续往下看:
type(np.newaxis)
NoneType
np.newaxis == None
True
从这里可知,其实np.newaxis就是None,'''
plt.imshow(img),plt.colorbar(),plt.show()
'''plt.colorbar()就是展示一个调色板'''
糟糕,梅西的头发不见了。谁会喜欢没有头发的梅西?我们需要把它找回来。因此,我们将使用1像素(确保前景)进行精细修饰。同时,一些不需要的地面也出现在图片里。我们需要删除它们。在那里,我们给出了一些0像素的修饰(确保背景)。因此,如现在所说,我们在以前的情况下修改生成的mask。
我实际上所做的是,我在paint应用程序中打开了输入图像,并在图像中添加了另一层。使用画笔中的画笔工具,我在新图层上用白色标记了错过的前景(头发,鞋子,球等),而用黑色标记了不需要的背景(例如logo,地面等)。然后用灰色填充剩余的背景。然后将该mask图像加载到OpenCV中,编辑我们在新添加的mask图像中具有相应值的原始mask图像。
检查以下代码:
#newmask是我手动标记过的mask图像
newmask = cv.imread('newmask.png',0)
# 标记为白色(确保前景)的地方,更改mask = 1
# 标记为黑色(确保背景)的地方,更改mask = 0
mask[newmask == 0] = 0
mask[newmask == 255] = 1
mask, bgdModel, fgdModel = cv.grabCut(img,mask,None,bgdModel,fgdModel,5,cv.GC_INIT_WITH_MASK)
mask = np.where((mask==2)|(mask==0),0,1).astype('uint8')
img = img*mask[:,:,np.newaxis]
plt.imshow(img),plt.colorbar(),plt.show()
查看以下结果:
就是这样了。在这里,你无需直接在rect模式下初始化,而可以直接进入mask模式。只需用2像素或3像素(可能的背景/前景)标记mask图像中的矩形区域。然后像在第二个示例中一样,将我们的sure_foreground标记为1像素。然后直接在mask模式下应用grabCut功能。