基于区域的分割,需要先补充一点其他的预备知识,首先是图像形态学。
图像形态学就是对图像在形态上的一些算法,或者说运算。
腐蚀和膨胀使形态学运算中最基本的用法,这个在之前的文章里描述过opencv中的原理和具体用法:
https://blog.csdn.net/pcgamer/article/details/124729236?spm=1001.2014.3001.5502
这里就不多说了。
在形态学运算中,还定义了另外了两个运算:
opencv中除了提供了腐蚀,膨胀这些基本函数之外,还弄了一个综合函数:cv2.morphologyEx
这个函数可以对图像进行各种形态学操作,由其中一个参数op确认,可执行的运算列表根据枚举量MorphTypes确定:

官网上的这张图像说的非常的明白。
其他的参数和腐蚀和膨胀基本类似。
cv2.distanceTransform这个函数计算了一幅图像当中每个点与最近的0像素点之间的距离,如果本身像素为0,那么就等于0.
关于距离的定义,可以有三种选项(opencv官网)

还有一个参数是maskSize,我理解是在哪个范围之内进行计算,官网上也是提供了三个选项:

如果是DIST_MASK_PRECISE的话,就是在整张图中进行计算。
具体是用什么算法优化和减少计算量,官网上也提供了文献,有兴趣的朋友可以去了解了解。
连通域是图像运算中的一个重要概念,就是判断两个区域是否是连接的。
首先,两个像素怎么才叫连通,这个是可以定义的,比如两个像素像素值相同算连通,或者说两者相差不超过N之类。
其次,一个像素和周边的像素比较的时候,一般有下面几种方式。
这个函数就是用来做连通域计算的,有如下的参数:
image: 图像,单通道的8位图像。
connectivity,使用4连通还是8连通。直接填4或者8.
ltype,返回的图像数据(在返回值那里说)使用CV_32S或者CV_16U。
ccltype,实用计算连通域的算法(我的python版本好像没有这个入参):

每一种都有对应的论文去阐述,不细说。
返回值有两个:
直接上代码:
img = cv2.imread('/Users/zoulei/files/personal/blog/images/car.jpeg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)cv2.imshow("grey", gray)ret, markers = cv2.connectedComponents(gray)img[markers == 0] = [0, 0, 0]#就直接使用三原色对不同的连通域进行涂色
colors = [[255, 0, 0],[0, 255, 0],[0, 0, 255]
]for i in range(1, ret+1):# python中的特有方式,好用的很img[markers == i] = colors[i % 3]cv2.imshow("component", img)
结果如下:

结果不太理想,还需要别的动作。不是这篇的重点,后续再说
基于区域的分割逻辑上比较简单,就是需要分割出来的区域理论上来说,其中的像素值是相似的,或者说是有关联的。那么算法可以引入一个先验知识,或者说一个前提,算法的初始条件中就需要增加一个:种子点。
这个种子点就是从各个需要分割区域中挑选出来的点(通过各种手段,可以是手动,可以是自动,后续会详细讲到)来根据相似性进行扩展(生长),一直生长到区域的边缘为止(停止生长)。
所以区域生长算法的几个关键问题就是:
各种各样的算法就是针对这三个问题提出各自的解决方案。
为了理解这个算法,我弄了一个最简单区域生长。
代码如下:
if __name__ == '__main__':img = cv2.imread("/Users/zoulei/files/personal/blog/images/tubeImg.jpeg")grey = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)cv2.imshow("origin", grey)# 随便挑一个点作为种子点point_x = 100point_y = 100grey[point_x, point_y] = 255# 四个生长方向# 如果差值超过20就停下# 暂时先生成20个迭代for i in range(100):if grey[point_x - i, point_y] - 255 < 20:grey[point_x - i, point_y] = 255if grey[point_x + i, point_y] - 255 < 20:grey[point_x + i, point_y] = 255if grey[point_x, point_y - i] - 255 < 20:grey[point_x, point_y - i] = 255if grey[point_x, point_y + i] - 255 < 20:grey[point_x, point_y + i] = 255cv2.imshow("changed", grey)cv2.waitKey()cv2.destroyAllWindows()
输出的图像为:

也就是说,通过4个方向的简单生长变成了一个十字形的区域,或者说分割出了一个十字形的区域。
我们可以通过修改种子点,生长规则和停止规则来确定一种新的区域分割算法,分水岭算法就是其中的一个代表算法,而opencv里提供了函数watershed来实现一种分水岭算法(分水岭算法也有很多种变形,我只说说opencv的实现算法)。
一般来说,可以直接使用连通域函数函数来做区域分割,我们也拿一个硬币图来实验。

当然不能直接使用连通域计算的函数,还需要做一些预处理:
img = cv2.imread('/Users/zoulei/files/personal/blog/images/coin.jpg')grey = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)ret, thresh_img = cv2.threshold(grey, 50, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))opening = cv2.morphologyEx(thresh_img, cv2.MORPH_OPEN, kernel, iterations=2)ret, markers = cv2.connectedComponents(opening)for i in range(1, ret+1):# python中的特有方式,好用的很img[markers == i] = colors[i % 3]
结果不甚理想:

感觉是最下面两个分成了两个区域,多膨胀几次试试看:
img = cv2.imread('/Users/zoulei/files/personal/blog/images/coin.jpg')grey = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)ret, thresh_img = cv2.threshold(grey, 50, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))opening = cv2.morphologyEx(thresh_img, cv2.MORPH_OPEN, kernel, iterations=2)dialate = cv2.dilate(opening, kernel, iterations=3)ret, markers = cv2.connectedComponents(dialate)for i in range(1, ret+1):# python中的特有方式,好用的很img[markers == i] = colors[i % 3]

很明显,如果两个区域隔的比较近,一膨胀就容易让两个区域变成一个连通域。那么针对这种两个区域有比较接近的边界的情况,有一种区域生长算法是叫做分水岭算法。
watershed函数有两个参数:
首先说一下,为什么要用分水岭算法,我一直有个疑问的是opencv里面提供了connectedComponents函数来计算连通域(也有不同的算法,opencv中提供了参考文献)。那么分水岭算法和那些有什么区别呢?我个人的理解是在需分割区域的边界比较接近的时候比较有用,就和我上面提到的例子一样,一般来说用基于区域的算法进行分割的时候,通常会使用膨胀算法对内部的杂质或者空洞就行补充,但是如果使用了膨胀,因为分割区域挨的比较近,就会导致变成一个连通区域了。
这个时候就可以用到分水岭算法
分水岭算法的基本逻辑是把整张图像的灰度值或者是像素值想象成一个地形图,灰度值较低的是山谷,或者说盆地;灰度值较高的像素点就是山峰。
上面是一个基本原理,下面来说说具体的使用。
我们还是使用上面的硬币图来进行实验。
我简单说说我对分水岭算法的理解:
根据上面的基本逻辑,详细说说应用分水岭算法的代码:
img = cv2.imread('./images/coin.jpg')grey = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)ret, thresh_img = cv2.threshold(grey, 50, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))opening = cv2.morphologyEx(thresh_img, cv2.MORPH_OPEN, kernel, iterations=2)# 背景区域sure_bg = cv2.dilate(opening, kernel, iterations=2)
上面代码中最后一句是通过一次膨胀来确定背景区域。我是这么理解这个代码的,通过一次threshold的区域分割后,基本上已经将前景区域大致轮廓找到了,经过一次膨胀后,就会比目标区域的范围更大,就可以把这样的一个区域称作为背景区域。
显示出来看一下:

很明显是比目标区域宽一些了。
# 前景区域dist_transform = cv2.distanceTransform(opening, cv2.DIST_L2, 5)ret, sure_fg = cv2.threshold(dist_transform, 0.2 * dist_transform.max(), 255, cv2.THRESH_BINARY)
这几句代码我理解了一阵子。
distanceTransform函数计算的就是图像中离0最近的像素点的距离。
那么上面的第一句代码计算得到的就是经过开运算之后的前景区域离后面的黑色背景区域的距离。
配合下一句代码的threshold函数,对计算的距离进行阈值运算,就是如果是小于距离最大值的0.2的点就为像素值0,否则则是255.
这两句可以理解为就是缩小开运算之后的目标区域,这部分区域确定为前景区域。
显示出来看一下就是:

区域小多了。那么实际上的边界就会存在于这个前景和背景之间!分水岭算法的任务就是要在这个区域中找到真实的边界。
显示出来结果如下:

sure_fg = np.uint8(sure_fg)
unknown = cv2.subtract(sure_bg, sure_fg)ret, markers = cv2.connectedComponents(sure_fg)markers[unknown == 255] = 0markers = cv2.watershed(img, markers)img[markers==-1] = [0, 0, 255]
最终的结果如下:

通过分水岭算法就可以较为准确的找到边界。
当然上图中还有一些多余的分割线,我觉得是可以通过对上面的unknown区域做一些改进和处理,是可以去除掉这些分割线的,这里就不在这里多说了。有兴趣的朋友也可以自行尝试。
上一篇:2022年杂学之机器人篇