边缘检测是图像处理和计算机视觉的基本问题,边缘检测的目的是标识数字图像中亮度变化明显的点,图像属性中的显著变化通常反映了属性的重要事件和变化。这些包括:深度上的不连续,表面方向的不连续,物质属性变化和场景照明变化。边缘检测是图像处理和计算机视觉中,尤其是特征提取中的一个研究领域。图像边缘检测大幅度的减少了数据量,并且剔除了可以认为不相关的信息,保留了图像重要的结构属性。
在图像中,边缘可以看做是位于一阶导数较大的像素处,因此,我们可以求图像的一阶导数来确定图像的边缘,像sobel算子等一系列算子都是基于这个思想的。如下图a表示函数在边沿的时候关系,求导得b图,可知边沿可就是函数的极值点,对应二阶导数为0处,如图c的二阶导图。
图a 图b 图c在实际的图像分割中,往往只用到一阶和二阶导数,虽然原理上,可以用更高阶的导数,但是因为噪声的影响,在纯粹二阶的导数操作中就会出现对噪声的敏感现象,三阶以上的导数信息往往失去了应用价值。二阶导数还可以说明灰度突变的类型。在某些情况下,如灰度变化均匀的图像,只利用一阶导数可能找不到边界,此时二阶导数就能提供很有用的信息。二阶导数对噪声也比较敏感,解决的方法是先对图像进行平滑滤波,消除部分噪声,再进行边缘检测。不过,利用二阶导数信息的算法是基于过零检测的,因此得到的边缘点数比较少,有利于后继的处理和识别工作。
在OpenCV中,边缘检测的方法有以下几种:Sobel、Scharr、Laplace以及Canny,其中前三种方法是带方向的。
测试方法,将一张200*200的图片,在进行二值化之后再分别使用这几种方法进行边缘检测;生成一个1000*1000的矩阵,并且画出网格线,网格大小为5*5,每个网格图上测试图片的相应的像素颜色,以此更方便的观察集中边缘检测的效果。
下图为原图未经边缘检测生成的对比图片。由于该图片为专门生成的图片,没有噪点,所以不用进行降噪操作。
原始图片 原始图片映射之所以要做一个映射的原因是,这里的评判标准为,尽可能得到单像素边缘。直接将图片放大,得到的图片不够清晰,而映射之后,能够足够清晰的显示边缘宽度。
1.Sobel算子
其主要用于边缘检测,在技术上它是以离散型的差分算子,用来运算图像亮度函数的梯度的近似值,Sobel算子是典型的基于一阶导数的边缘检测算子,由于该算子中引入了类似局部平均的运算,因此对噪声具有平滑作用,能很好的消除噪声的影响。
Sobel算子包含两组3x3的矩阵,分别为横向及纵向模板,将之与图像作平面卷积,即可分别得出横向及纵向的亮度差分近似值。实际使用中,常用如下两个模板来检测图像边缘。
图像的每一个像素的横向及纵向梯度近似值可用以下的公式结合,来计算梯度的大小。
然后可用以下公式计算梯度方向。
如果等于零,即代表图像该处拥有纵向边缘,左方较右方暗。
在opencv中,Sobel的函数原型为:
dst = cv2.Sobel(src, ddepth, dx, dy[, dst[, ksize[, scale[, delta[, borderType]]]]])
前四个是必须的参数:
src是需要处理的图像;
ddepth是图像的深度,-1表示采用的是与原图像相同的深度。目标图像的深度必须大于等于原图像的深度;
dx和dy表示的是求导的阶数,0表示这个方向上没有求导,一般为0、1、2。
其后是可选的参数:
ksize是Sobel算子的大小,必须为1、3、5、7,默认为3,当kSize = 1的时候,采用的模板为1*3或者3*1而非平时的那些格式;
scale是缩放导数的比例常数,默认情况下没有伸缩系数;
delta是一个可选的增量,将会加到最终的dst中,同样,默认情况下没有额外的值加到dst中;
borderType是判断图像边界的模式。这个参数默认值为cv2.BORDER_DEFAULT。
部分代码如下:
resImg_x = cv2.Sobel(thresh_1, cv2.CV_64F, 1, 0)
resImg_y = cv2.Sobel(thresh_1, cv2.CV_64F, 0, 1)
resImg_x = cv2.convertScaleAbs(resImg_x)
resImg_y = cv2.convertScaleAbs(resImg_y)
resImg = cv2.addWeighted(src1=resImg_x, alpha=0.5, src2=resImg_y, beta=0.5, gamma=0)
在Sobel函数的第二个参数这里使用了cv2.CV_64F。因为OpenCV文档中对Sobel算子的介绍中有这么一句:“in the case of 8-bit input images it will result in truncated derivatives”。即Sobel函数求完导数后会有负值,还有会大于255的值。而原图像是uint8,即8位无符号数,所以Sobel建立的图像位数不够,会有截断。因此要使用更大的数据类型,如cv2.CV_16S、cv2.CV_32F或cv2.CV_64F,然后在计算完毕之后用convertScaleAbs()函数将其转回原来的uint8形式。
由于Sobel算子是在两个方向计算的,最后还需要用cv2.addWeighted函数将其组合起来。
边缘检测结果如下:
ksize设为1的情况:
都没有得到单像素边缘,ksize为1时,边缘较窄,但可能出现空洞的问题。
2.Scharr算子
Scharr算子与Sobel基本类似,他的出现是由于当ksize为3时,Sobel可能会出现较为明显的误差,而提出的解决方案。具有跟sobel一样的速度,但结果更精确。他的两个方向的模板分别为:
在opencv中,Sobel的函数原型为
dst = cv2.Scharr(src, ddepth, dx, dy[, dst[, scale[, delta[, borderType]]]])
其参数意义与Sobel基本一致,只是ksize固定为3。
部分代码如下:
resImg_x = cv2.Scharr(thresh_1, cv2.CV_16S, 1, 0)
resImg_y = cv2.Scharr(thresh_1, cv2.CV_16S, 0, 1)
resImg_x = cv2.convertScaleAbs(resImg_x)
resImg_y = cv2.convertScaleAbs(resImg_y)
resImg = cv2.addWeighted(src1=resImg_x, alpha=0.5, src2=resImg_y, beta=0.5, gamma=0)
边缘检测结果如下:
就目前该场景而言,Scharr与Sobel没有太大的差别。
3.Laplacian算子
拉普拉斯(Laplacian)算子是n维欧几里德空间中的一个二阶微分算子,常用于图像增强领域和边缘提取。它通过灰度差分计算邻域内的像素,基本流程是:判断图像中心像素灰度值与它周围其他像素的灰度值,如果中心像素的灰度更高,则提升中心像素的灰度;反之降低中心像素的灰度,从而实现图像锐化操作。在算法实现过程中,Laplacian算子通过对邻域中心像素的四方向或八方向求梯度,再将梯度相加起来判断中心像素灰度与邻域内其他像素灰度的关系,最后通过梯度运算的结果对像素灰度进行调整。
该算子是一种各向同性算子,二阶微分算子,具有旋转不变性。在只关心边缘的位置而不考虑其周围的象素灰度差值时比较合适。同时他只适用于无噪声图像,因为其对孤立象素的响应要比对边缘或线的响应要更强烈,对于存在噪声情况下,需要先进行相关降噪操作。
一个二维图像函数的拉普拉斯变换是各向同性的二阶导数,定义为:
其离散形式为:
拉普拉斯算子还可以表示成模板的形式,如下图所示。从模板形式容易看出,如果在图像中一个较暗的区域中出现了一个亮点,那么用拉普拉斯运算就会使这个亮点变得更亮。因为图像中的边缘就是那些灰度发生跳变的区域,所以拉普拉斯锐化模板在边缘检测中很有用。一般增强技术对于陡峭的边缘和缓慢变化的边缘很难确定其边缘线的位置。但此算子却可用二次微分正峰和负峰之间的过零点来确定,对孤立点或端点更为敏感,因此特别适用于以突出图像中的孤立点、孤立线或线端点为目的的场合。当然,这种增强也会作用于图像中的噪声。
Laplacian算子分为四邻域和八邻域,四邻域是对邻域中心像素的四方向求梯度,八邻域是对八方向求梯度。其中四邻域模板如公式所示:
八邻域模板如下:
通过模板可以发现,当邻域内像素灰度相同时,模板的卷积运算结果为0;当中心像素灰度高于邻域内其他像素的平均灰度时,模板的卷积运算结果为正数;当中心像素的灰度低于邻域内其他像素的平均灰度时,模板的卷积为负数。对卷积运算的结果用适当的衰弱因子处理并加在原中心像素上,就可以实现图像的锐化处理。
还有两种扩展模板:
和
在opencv中Laplacian函数原型为
dst = cv2.Laplacian(src, ddepth[, dst[, ksize[, scale[, delta[, borderType]]]]])
其中:
src表示输入图像;
ddepth表示目标图像所需的深度。
其后是可选参数:
ksize表示滤波器的孔径大小,其值必须是正数和奇数,且默认值为1;
scale表示计算拉普拉斯算子值的可选比例因子。默认值为1;
delta表示添加到结果中的可选增量值,默认值为0;
borderType表示边框模式。
Laplacian其实利用Sobel算子的运算,得到图像在x方向和y方向的导数,最终得到结果。
以上这几个算子,其本质上还是卷积,所以,如果使用filter2D函数直接将图像和算子进行2D卷积,其结果和直接使用算子并没有什么不同,这里将几种模板都试一下,部分代码如下:
resImg = cv2.Laplacian(thresh_1, -1)
# kernel= np.matrix('0 1 0; 1 -4 1; 0 1 0')
# kernel= np.matrix('1 1 1; 1 -8 1; 1 1 1')
# kernel= np.matrix('0 -1 0; -1 4 -1; 0 -1 0')
# kernel= np.matrix('-1 -1 -1; -1 8 -1; -1 -1 -1')
# resImg= cv2.filter2D(thresh_1, -1 ,kernel)
其中第一行为直接调用Laplacian函数,所以其跟直接用模板卷积的结果应该一致,边缘检测结果如下:
Laplacian函数执行结果
使用4邻域模板卷积的结果其结果同直接使用Laplacian函数
使用8邻域模板卷积的结果 使用4邻域扩展模板卷积的结果 使用8邻域扩展模板卷积的结果从结果上看,已经相当接近单像素边缘的目标了。
4.Canny函算子
Canny边缘检测是一种比较新的边缘检测算子,具有很好地边缘检测性能,但是它实现起来较为麻烦,Canny算子是一个具有滤波,增强,检测的多阶段的优化算子,在进行处理前,Canny算子先利用高斯平滑滤波器来平滑图像以除去噪声,Canny分割算法采用一阶偏导的有限差分来计算梯度幅值和方向,在处理过程中,Canny算子还将经过一个非极大值抑制的过程,最后Canny算子还采用两个阈值来连接边缘。
Canny边缘检测算法包含一下四个步骤:
Step1.用高斯滤波器平滑图象:
高斯平滑函数:
令g为平滑后的图像,用h对图像f的平滑可表示为:
其中*代表卷积
Step2.使用一阶有限差分计算偏导数阵列P和Q:
已平滑图像g的梯度可以使用2×2一阶有限差分近似式来计算x与y偏导数的两个阵列
并且
M反应了图像的边缘强度,反映了边缘的方向。使得M取得局部最大值的方向角,就反映了边缘的方向。
Step3.非极大值抑制:
将梯度角离散为圆周的四个扇区之一,以便用3×3的窗口作抑制运算。四个扇区的标号为0到3,对应3×3邻域的四种可能组合
在每一点上,邻域的中心像素M与沿着梯度线的两个像素相比。如果M的梯度值不比沿梯度线的两个相邻像素梯度值大,则令M=0
Step4.用双阈值算法检测和连接边缘
对非极大值抑制图像作用两个阈值th1和th2,两者关系th1=0.4th2。我们把梯度值小于th1的像素的灰度值设为0,得到图像1。然后把梯度值小于th2的像素的灰度值设为0,得到图像2。由于图像2的阈值较高,去除大部分噪音,但同时也损失了有用的边缘信息。而图像1的阈值较低,保留了较多的信息,我们可以以图像2为基础,以图像1为补充来连结图像的边缘。
链接边缘的具体步骤如下:
1.对图像2进行扫描,当遇到一个非零灰度的像素p(x,y)时,跟踪以p(x,y)为开始点的轮廓线,直到轮廓线的终点q(x,y)。
2.考察图像1中与图像2中q(x,y)点位置对应的点s(x,y)的8邻近区域。如果在s(x,y)点的8邻近区域中有非零像素s(x,y)存在,则将其包括到图像2中,作为r(x,y)点。从r(x,y)开始,重复第一步,直到我们在图像1和图像2中都无法继续为止。
3.当完成对包含p(x,y)的轮廓线的连结之后,将这条轮廓线标记为已经访问。回到第一步,寻找下一条轮廓线。重复第一步、第二步、第三步,直到图像2中找不到新轮廓线为止。
至此,完成Canny算子的边缘检测。
在opencv中,Canny的函数原型为:
def cv2.Canny(image, threshold1, threshold2[, edges[, apertureSize[, L2gradient]]])
其中:
image表示输入图像;
threshold1和threshold2表示两个阈值;
其后为可选参数:
apertureSize表示算子内核大小,且只能在3到7之间取值,通过使用Sobel算子;
L2gradient表示是否使用更精确的L2范数进行计算,bool类型。
部分调用代码如下:
resImg = cv2.Canny(thresh_1, 150, 300, apertureSize=3, L2gradient=True)
边缘检测结果如下:
可以看出,其结果在本测试中,没有完全闭合
以上四种算子的结果对比,从单像素边缘的角度分析,Canny算子虽然是单像素,但是其边缘没有封闭,而Laplacian算子的结果更好,但是其对噪声非常敏感,所以对去噪手段比较依赖。
如果仅仅是需要获取单像素边缘的角度,可以通过opencv提供的findContours函数获取轮廓,然后绘制边缘,而findContours的原理较为复杂,就不多赘述,贴一张结果图。
完整代码如下:
import os
import cv2
import numpy as np
img = cv2.imdecode(np.fromfile("./test.jpg", dtype=np.uint8), cv2.IMREAD_COLOR)
img_grey = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, thresh_0 = cv2.threshold(img_grey, 0, 255, cv2.THRESH_OTSU)#二值化自动取阈值
thresh_1 = cv2.resize(thresh_0, (200, 200))
# resImg = cv2.bitwise_not(thresh_1)
resImg = cv2.Canny(thresh_1, 150, 300, apertureSize=3, L2gradient=True)
# resImg_x = cv2.Sobel(thresh_1, cv2.CV_64F, 1, 0, ksize=1)
# resImg_y = cv2.Sobel(thresh_1, cv2.CV_64F, 0, 1, ksize=1)
# resImg_x = cv2.convertScaleAbs(resImg_x)
# resImg_y = cv2.convertScaleAbs(resImg_y)
# resImg = cv2.addWeighted(src1=resImg_x, alpha=0.5, src2=resImg_y, beta=0.5, gamma=0)
# resImg_x = cv2.Scharr(thresh_1, cv2.CV_16S, 1, 0)
# resImg_y = cv2.Scharr(thresh_1, cv2.CV_16S, 0, 1)
# resImg_x = cv2.convertScaleAbs(resImg_x)
# resImg_y = cv2.convertScaleAbs(resImg_y)
# resImg = cv2.addWeighted(src1=resImg_x, alpha=0.5, src2=resImg_y, beta=0.5, gamma=0)
# resImg = cv2.Laplacian(thresh_1, -1)
# kernel= np.matrix('0 1 0; 1 -4 1; 0 1 0')
# kernel= np.matrix('1 1 1; 1 -8 1; 1 1 1')
# kernel= np.matrix('0 -1 0; -1 4 -1; 0 -1 0')
# kernel= np.matrix('-1 -1 -1; -1 8 -1; -1 -1 -1')
# resImg= cv2.filter2D(thresh_1, -1 ,kernel)
# thresh_1 = cv2.bitwise_not(thresh_1)
# _, contours, _ = cv2.findContours(thresh_1, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
# resImg = np.full((200, 200), 0, dtype=np.uint8)
# cv2.drawContours(resImg, contours, -1, (255, 255, 255), 1)
newImg = np.full((1000, 1000, 3), 0, dtype=np.uint8)
for i in range(len(newImg)):
for j in range(len(newImg[i])):
if resImg[i//5, j//5] > 0:
newImg[i,j] = (255, 255, 255)
for i in range(len(newImg)):
for j in range(len(newImg[i])):
if i % 5 == 0 or j % 5 == 0:
newImg[i,j] = (0, 255, 0)
cv2.imencode(".jpg", newImg)[1].tofile("./Laplacian_d.jpg")
# cv2.imshow("res", newImg)
# cv2.waitKey()
参考资料:
网友评论