文章目录
- 图像区域基本算法——形态学运算
- 腐蚀与膨胀
- 开运算与闭运算
- opencv中的形态学运算
- 距离计算——distanceTransform函数
- 连通域
- 连通的定义
- 计算连通域——connectedComponents
- 连通域实验
- 基于区域的分割
- 区域生长算法
- 自定义一个最简单区域生长算法实现
- 区域分割
- 一般区域分割
- opencv中的分水岭算法
- 分水岭算法原理简单说明
- 分水岭算法使用
前面两篇文章说的分割,一个是基于阈值的分割,一个是基于边缘算法的分割。在传统的图像处理算法中,还有一个大类是基于区域的分割。
图像区域基本算法——形态学运算
基于区域的分割,需要先补充一点其他的预备知识,首先是图像形态学。
图像形态学就是对图像在形态上的一些算法,或者说运算。
腐蚀与膨胀
腐蚀和膨胀使形态学运算中最基本的用法,这个在之前的文章里描述过opencv中的原理和具体用法:
https://blog.csdn.net/pcgamer/article/details/124729236?spm=1001.2014.3001.5502
这里就不多说了。
开运算与闭运算
在形态学运算中,还定义了另外了两个运算:
- 开运算:先腐蚀图像,再膨胀图像,同样需要一个kernel。
- 闭运算:先膨胀图像,再腐蚀图像,同样需要一个kernel。
opencv中的形态学运算
opencv中除了提供了腐蚀,膨胀这些基本函数之外,还弄了一个综合函数:cv2.morphologyEx
这个函数可以对图像进行各种形态学操作,由其中一个参数op确认,可执行的运算列表根据枚举量MorphTypes确定:
官网上的这张图像说的非常的明白。
其他的参数和腐蚀和膨胀基本类似。
距离计算——distanceTransform函数
cv2.distanceTransform这个函数计算了一幅图像当中每个点与最近的0像素点之间的距离,如果本身像素为0,那么就等于0.
-
关于距离的定义,可以有三种选项(opencv官网)
-
还有一个参数是maskSize,我理解是在哪个范围之内进行计算,官网上也是提供了三个选项:
如果是DIST_MASK_PRECISE的话,就是在整张图中进行计算。
-
具体是用什么算法优化和减少计算量,官网上也提供了文献,有兴趣的朋友可以去了解了解。
连通域
连通域是图像运算中的一个重要概念,就是判断两个区域是否是连接的。
连通的定义
首先,两个像素怎么才叫连通,这个是可以定义的,比如两个像素像素值相同算连通,或者说两者相差不超过N之类。
其次,一个像素和周边的像素比较的时候,一般有下面几种方式。
- 4连通,像素和周边的四个像素点进行比较(按照上面的规则进行比较),就是上、下、左、右四个像素点。如果4个点都是与当前像素点相连通,那么说这几个像素点组成的区域就是一个4连通区域,很多区域生长算法就是以这个为标准来进行计算和生长的。
- 或者反过来说,一个区域是一个4连通区域的话,这个区域中的像素点与其周边的四个像素点都是连通的(最外边的一圈除外)
- 8连通,和上面的类似,就是像素点的8个邻域像素点都是连通的。
计算连通域——connectedComponents
这个函数就是用来做连通域计算的,有如下的参数:
-
image: 图像,单通道的8位图像。
-
connectivity,使用4连通还是8连通。直接填4或者8.
-
ltype,返回的图像数据(在返回值那里说)使用CV_32S或者CV_16U。
-
ccltype,实用计算连通域的算法(我的python版本好像没有这个入参):
每一种都有对应的论文去阐述,不细说。
返回值有两个:
- ret,返回有几个连通域,就是上面的ltype
- labels,连通域的图像。实际上,计算的结果就是给输入图像的每个区域打个标记,标记一下,这是哪个连通域的。如果标记为0,则为背景。
连通域实验
直接上代码:
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)
结果如下:
结果不太理想,还需要别的动作。不是这篇的重点,后续再说
基于区域的分割
区域生长算法
基于区域的分割逻辑上比较简单,就是需要分割出来的区域理论上来说,其中的像素值是相似的,或者说是有关联的。那么算法可以引入一个先验知识,或者说一个前提,算法的初始条件中就需要增加一个:种子点。
这个种子点就是从各个需要分割区域中挑选出来的点(通过各种手段,可以是手动,可以是自动,后续会详细讲到)来根据相似性进行扩展(生长),一直生长到区域的边缘为止(停止生长)。
所以区域生长算法的几个关键问题就是:
- 如何挑选生长点
- 如何确定生长原则
- 如何确定生长停止条件
各种各样的算法就是针对这三个问题提出各自的解决方案。
自定义一个最简单区域生长算法实现
为了理解这个算法,我弄了一个最简单区域生长。
- 固定一个点作为种子节点
- 往四个方向生长,只要像素差值小于20就生长,而且生长规则是直接赋值
- 停止规则也很简单,100个迭代
代码如下:
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 = 100
point_y = 100
grey[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] = 255
if grey[point_x + i, point_y] - 255 < 20:
grey[point_x + i, point_y] = 255
if grey[point_x, point_y - i] - 255 < 20:
grey[point_x, point_y - i] = 255
if grey[point_x, point_y + i] - 255 < 20:
grey[point_x, point_y + i] = 255
cv2.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]
很明显,如果两个区域隔的比较近,一膨胀就容易让两个区域变成一个连通域。那么针对这种两个区域有比较接近的边界的情况,有一种区域生长算法是叫做分水岭算法。
opencv中的分水岭算法
watershed函数有两个参数:
- imgage, 输入图像,一个3通道图。
- marker,单通道的图像,有点类似于掩膜的作用,具体的作用我们下面会具体讲到。
分水岭算法原理简单说明
首先说一下,为什么要用分水岭算法,我一直有个疑问的是opencv里面提供了connectedComponents函数来计算连通域(也有不同的算法,opencv中提供了参考文献)。那么分水岭算法和那些有什么区别呢?我个人的理解是在需分割区域的边界比较接近的时候比较有用,就和我上面提到的例子一样,一般来说用基于区域的算法进行分割的时候,通常会使用膨胀算法对内部的杂质或者空洞就行补充,但是如果使用了膨胀,因为分割区域挨的比较近,就会导致变成一个连通区域了。
这个时候就可以用到分水岭算法
分水岭算法的基本逻辑是把整张图像的灰度值或者是像素值想象成一个地形图,灰度值较低的是山谷,或者说盆地;灰度值较高的像素点就是山峰。
- 假设从图像上的盆地开始往图像里注水,水平面慢慢上升就会形成一个一个的水坑或者湖,就是一个一个的区域。
- 当两块水域连接到一起的时候,这里就可以停止注水了,这样就形成了一个边界,就可以完成区域分割操作了。
- 在opencv的分水岭算法中,通过第二个参数marker来给出种子点进行生长。
- opencv的分水岭算法中,是需要基于连通域算法的。
上面是一个基本原理,下面来说说具体的使用。
分水岭算法使用
我们还是使用上面的硬币图来进行实验。
我简单说说我对分水岭算法的理解:
- 上面提到了,分水岭算法是基于连通域算法的基础的。也就是说要通过连通域算法来给图像的每个区域进行编号,这些编号就是给分水岭算法提供的种子点,标记了这些已经是一块一块的区域了。
- 分水岭分割算法的目标是要区分出前景区域和背景区域来。
- 分水岭算法中的markker主要分为三类区域,前景区域(数字标记为1,2,3等等);背景区域,数字标记为-1。unkonwn区域,数字标记为0。这个就是用于解决连通域区域无法很好解决的边界过近,通过膨胀会连在一起的情况。
- 分水岭算法的主要逻辑就是通过确定好一些前景区域(种子点),然后确定一些背景区域,中间模糊的区域就是unknown区域。然后通过注水过程来在模糊区域中找到边界。
根据上面的基本逻辑,详细说说应用分水岭算法的代码:
- 计算背景区域
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.
这两句可以理解为就是缩小开运算之后的目标区域,这部分区域确定为前景区域。
显示出来看一下就是:
区域小多了。那么实际上的边界就会存在于这个前景和背景之间!分水岭算法的任务就是要在这个区域中找到真实的边界。
- 在unknown区域中应用分水岭算法找到边界
fg和bg的中间范围就是上面逻辑中提到的unknown区域,也就是不确定区域。
显示出来结果如下:
sure_fg = np.uint8(sure_fg)
unknown = cv2.subtract(sure_bg, sure_fg)
ret, markers = cv2.connectedComponents(sure_fg)
markers[unknown == 255] = 0
markers = cv2.watershed(img, markers)
img[markers==-1] = [0, 0, 255]
- 上面的代码中,用bg图减去fg图就是中间区域。
- 然后通过连通域算法得到前景区域中的区域标记。
- markers[unknown == 255],这句代码就是通过把unknown这个mat中等于255的,在marker这个mat的标记中标记为0.还记得上面提到的逻辑么,0就表示unknown区域。
- 最后应用watershed算法。
- 最后一句,watershed算法计算完成后,marker中为-1的像素值就是边界(marker的尺寸和原图一样)
最终的结果如下:
通过分水岭算法就可以较为准确的找到边界。
当然上图中还有一些多余的分割线,我觉得是可以通过对上面的unknown区域做一些改进和处理,是可以去除掉这些分割线的,这里就不在这里多说了。有兴趣的朋友也可以自行尝试。