目录html
最近在学习ORB的过程当中又仔细学习了Canny,故写下此篇笔记,以做总结。java
Canny边缘检测于1986年由JOHN CANNY首次在论文《A Computational Approach to Edge Detection》中提出,就此拉开了Canny边缘检测算法的序幕。算法
Canny边缘检测是从不一样视觉对象中提取有用的结构信息并大大减小要处理的数据量的一种技术,目前已普遍应用于各类计算机视觉系统。Canny发现,在不一样视觉系统上对边缘检测的要求较为相似,所以,能够实现一种具备普遍应用意义的边缘检测技术。边缘检测的通常标准包括:多线程
为了知足这些要求,Canny使用了变分法。Canny检测器中的最优函数使用四个指数项的和来描述,它能够由高斯函数的一阶导数来近似。函数
在目前经常使用的边缘检测方法中,Canny边缘检测算法是具备严格定义的,能够提供良好可靠检测的方法之一。因为它具备知足边缘检测的三个标准和实现过程简单的优点,成为边缘检测最流行的算法之一。性能
Canny边缘检测算法能够分为如下5个步骤:学习
下面详细介绍每一步的实现思路。优化
高斯滤波是一种线性平滑滤波,适用于消除高斯噪声,特别是对抑制或消除服从正态分布的噪声很是有效。滤波能够消除或下降图像中噪声的影响,使用高斯滤波器主要是基于在滤波降噪的同时也能够最大限度保留边缘信息的考虑。this
高斯滤波实现步骤:spa
边缘检测是基于对图像灰度差别运算实现的,因此若是输入的是RGB彩色图像,须要先进行灰度图的转换。
RGB转换成灰度图像的一个经常使用公式是:
\[ Gray = R*0.299 + G*0.587 + B*0.114 \]
注意通常状况下图像处理中彩色图像各份量的排列顺序是B、G、R。
RGB原图像: 转换后的灰度图:
Java
代码调用系统库实现:
public static Mat RGB2Gray(Mat image) { // Gray = R*0.299 + G*0.587 + B*0.114 Mat gray = new Mat(); Imgproc.cvtColor(image, gray, Imgproc.COLOR_BGR2GRAY); return gray; }
一维高斯函数表述为:
\[ G(x) = \frac {1}{\sqrt {2\pi}\sigma}\exp(-\frac {(x-\mu_x)^2}{2\sigma^2}) \]
对应图形:
二维高斯函数表述为:
\[ G(x) = \frac {1}{ {2\pi}\sigma^2}\exp(-\frac {(x-\mu_x)^2+(y-\mu_y)^2}{2\sigma^2}) \]
对应图形:
一些重要特性说明:
在图形或滤波效果上表现为:\(σ\)越大,曲线越扁平,高斯滤波器的频带就越宽,平滑程度就越好,\(σ\)越小,曲线越瘦高,高斯滤波的频带就越窄,平滑程度也越弱;
如下对比一下不一样大小标准差\(σ\)(Sigma)对图像平滑的影响:
原图:
卷积核尺寸5*5,σ=0.1:
卷积核尺寸5*5,σ=1:
对比能够看到,Sigma(σ)越大,平滑效果越明显。
滤波的主要目的是降噪,通常的图像处理算法都须要先进行降噪。而高斯滤波主要使图像变得平滑(模糊),同时也有可能增大了边缘的宽度。
高斯函数是一个相似与正态分布的中间大两边小的函数。
对于一个位置(m,n)的像素点,其灰度值(这里只考虑二值图)为f(m,n)。
那么通过高斯滤波后的灰度值将变为:
\[ g_\sigma(m,n)=\frac 1 {2\pi\sigma^2} \exp(-\frac {m^2+n^2}{2\sigma^2})f(m,n) \]
其中,
\[ \frac 1 {{2\pi\sigma^2}} \exp(-\frac {m^2+n^2}{2\sigma^2}) \]
是二元高斯函数。
为了尽量减小噪声对边缘检测结果的影响,因此必须滤除噪声以防止由噪声引发的错误检测。为了平滑图像,使用高斯滤波器与图像进行卷积,该步骤将平滑图像,以减小边缘检测器上明显的噪声影响。大小为(2k+1)x(2k+1)的高斯滤波器核的生成方程式由下式给出:
下面是一个sigma = 1.4,尺寸为3x3的高斯卷积核的例子(须要注意归一化):
若图像中一个3x3的窗口为A,要滤波的像素点为e,则通过高斯滤波以后,像素点e的亮度值为:
其中*为卷积符号,sum表示矩阵中全部元素相加求和。
重要的是须要理解,高斯卷积核大小的选择将影响Canny检测器的性能。尺寸越大,检测器对噪声的敏感度越低,可是边缘检测的定位偏差也将略有增长。通常5x5是一个比较不错的trade off。
public static double[][] getGaussianArray(int size, double sigma) { double[][] array = new double[size][size]; double center_i, center_j; if (size % 2 == 1) { center_i = (double) (size / 2); center_j = (double) (size / 2); } else { center_i = (double) (size / 2) - 0.5; center_j = (double) (size / 2) - 0.5; } double sum = 0.0; for (int i = 0; i < size; i ++) for (int j = 0; j < size; j ++) { array[i][j] = Math.exp((-1.0) * ((i - center_i) * (i - center_i) + (j - center_j) * (j - center_j)) / 2.0 / sigma / sigma); sum += array[i][j]; } for (int i = 0; i < size; i ++) for (int j = 0; j < size; j++) array[i][j] /= sum; return array; }
Sigma为1时,求得的3*3大小的高斯卷积核参数为:
Sigma为1,5*5大小的高斯卷积核参数为:
在计算出高斯滤波卷积核以后就是用这个卷积核来扫过整张图像,对每一个像素点进行加权平均。
加入了多线程的优化,代码实现:
public static Mat greyGaussianFilter(Mat src, double[][] array, int size) throws InterruptedException { Mat temp = src.clone(); final CountDownLatch latch = new CountDownLatch(src.rows()); for (int i = 0; i < src.rows(); i ++) { int finalI = i; new Thread(() -> { for (int j = 0; j < src.cols(); j ++) if (finalI > (size / 2) - 1 && j > (size / 2) - 1 && finalI < src.rows() - (size / 2) && j < src.cols() - (size / 2)) { double sum = 0.0; for (int k = 0; k < size; k++) for (int l = 0; l < size; l++) sum += src.get(finalI - k + size / 2, j - l + size / 2)[0] * array[k][l]; temp.put(finalI, j, sum); } latch.countDown(); }).start(); } latch.await(); return temp; } public static Mat colorGaussianFilter(Mat src, int size, double sigma) throws InterruptedException { // return variable Mat ret = new Mat(); // list for merge and split List<Mat> channels = new ArrayList<>(); List<Mat> new_channels = new ArrayList<>(); Map<Integer, Mat> temp_channels = new TreeMap<>(); // split into 3 channels (r, g, b) Core.split(src, channels); // get gaussion array double[][] array = SmartGaussian.getGaussianArray(size, sigma); // multi-thread final CountDownLatch latch = new CountDownLatch(channels.size()); channels.forEach(mat -> { new Thread(() -> { Mat tmp = new Mat(); try { tmp = SmartGaussian.greyGaussianFilter(mat, array, size); } catch (InterruptedException e) { e.printStackTrace(); } temp_channels.put(channels.indexOf(mat), tmp); latch.countDown(); }).start(); }); latch.await(); for (int i = 0; i < channels.size(); i ++) new_channels.add(temp_channels.get(i)); Core.merge(new_channels, ret); return ret; }
效果图:(左图为原图,中间为上面的实现,右边是OpenCV
实现)
梯度的本意是一个向量(矢量),表示某一函数在该点处的方向导数沿着该方向取得最大值,即函数在该点处沿着该方向(此梯度的方向)变化最快,变化率最大(为该梯度的模)。
设二元函数:
\[ z=f(x,y) \]
在平面区域D上具备一阶连续偏导数,则对于每个点\(P(x,y)\)均可定出一个向量:
\[ \Big\{\frac {∂ f}{∂ x},\frac {∂ f} {∂ y} \Big\} =f_x(x,y)\vec i + f_y(x,y)\vec j \]
该函数就称为函数\(z=f(x,y)\)在点\(P(x,y)\)的梯度,记做\(\text{grad}~f(x,y)\)或\(\nabla f(x,y)\),即有:
\[ \text{grad}~f(x,y)=\nabla f(x,y)=\Big\{\frac {∂ f}{∂ x},\frac {∂ f} {∂ y} \Big\} =f_x(x,y)\vec i + f_y(x,y)\vec j \]
其中称为(二维的)向量微分算子或Nabla算子。
设 是方向\(l\)上的单位向量,则
因为当方向\(l\)与梯度方向一致时,有。
因此当\(l\)与梯度方向一致时,方向导数有最大值,且最大值为梯度的模,即
。
所以说,函数在一点沿梯度方向的变化率最大,最大值为该梯度的模。
图像灰度值的梯度可使用最简单的一阶有限差分来进行近似,使用如下图像在x和y方向上偏导数的两个矩阵:
计算公式为:
其中\(f\)为图像灰度值,\(P[i,j]\)表明\([i,j]\)在\(X\)方向梯度幅值,\(Q[i,j]\)表明\([i,j]\)在\(Y\)方向的梯度幅值,\(M[i,j]\)是该点幅值,\(\theta[i,j]\)是梯度方向,也就是角度。
图像中的边缘能够指向各个方向,所以Canny算法使用四个算子来检测图像中的水平、垂直和对角边缘。边缘检测的算子(如Roberts,Prewitt,Sobel等)返回水平Gx和垂直Gy方向的一阶导数值,由此即可以肯定像素点的梯度G和方向theta 。
其中G为梯度强度, theta表示梯度方向,arctan为反正切函数。下面以Sobel算子为例讲述如何计算梯度强度和方向。
x和y方向的Sobel算子分别为:
其中Sx表示x方向的Sobel算子,用于检测y方向的边缘; Sy表示y方向的Sobel算子,用于检测x方向的边缘(边缘方向和梯度方向垂直)。在直角坐标系中,Sobel算子的方向以下图所示。
若图像中一个3x3的窗口为A,要计算梯度的像素点为e,则和Sobel算子进行卷积以后,像素点e在x和y方向的梯度值分别为:
其中*为卷积符号,sum表示矩阵中全部元素相加求和。根据公式(3-2)即可以计算出像素点e的梯度和方向。
下面是Sobel算子求梯度的java实现:
package edu.sfls.Jeff.JavaDev.CVLib; import org.opencv.core.Core; import org.opencv.core.CvType; import org.opencv.core.Mat; import java.util.concurrent.CountDownLatch; public class SmartSobel { private Mat gradientX = new Mat(), gradientY = new Mat(), gradientXY = new Mat(); private double[][] pointDirection; public SmartSobel() {} public void compute(Mat image) throws InterruptedException { pointDirection = new double[image.rows()][image.cols()]; for (int i = 0; i < image.rows(); i ++) for (int j = 0; j < image.cols(); j ++) pointDirection[i][j] = 0; gradientX = Mat.zeros(image.size(), CvType.CV_32SC1); gradientY = Mat.zeros(image.size(), CvType.CV_32SC1); gradientXY = Mat.zeros(image.size(), CvType.CV_32SC1); final CountDownLatch latch = new CountDownLatch(image.rows() - 2); for (int i = 1; i < image.rows() - 1; i ++) { int finalI = i; new Thread(() -> { for (int j = 1; j < image.cols() - 1; j++) { double gX = (-1) * image.get(finalI - 1, j - 1)[0] + 1 * image.get(finalI - 1, j + 1)[0] + (-2) * image.get(finalI, j - 1)[0] + 2 * image.get(finalI, j + 1)[0] + (-1) * image.get(finalI + 1, j - 1)[0] + 1 * image.get(finalI + 1, j + 1)[0]; double gY = 1 * image.get(finalI - 1, j - 1)[0] + 2 * image.get(finalI - 1, j)[0] + 1 * image.get(finalI - 1, j + 1)[0] + (-1) * image.get(finalI + 1, j - 1)[0] + (-2) * image.get(finalI + 1, j)[0] + (-1) * image.get(finalI + 1, j + 1)[0]; gradientY.put(finalI, j, Math.abs(gY)); gradientX.put(finalI, j, Math.abs(gX)); gradientXY.put(finalI, j, Math.sqrt(gX * gX + gY * gY)); // 防止除以0的状况发生 if (gX == 0) gX = 0.00000000000000001; pointDirection[finalI][j] = Math.atan(gY / gX); } latch.countDown(); }).start(); } latch.await(); } public void convert() { Core.convertScaleAbs(gradientX, gradientX); Core.convertScaleAbs(gradientY, gradientY); Core.convertScaleAbs(gradientXY, gradientXY); } public Mat getGradientX() { return this.gradientX; } public Mat getGradientY() { return this.gradientY; } public Mat getGradientXY() { return this.gradientXY; } public double[][] getPointDirection() { return this.pointDirection; } }
因为这里使用的是\(Math.tan()\),因此最终的角度是映射在\([-\frac \pi 2, \frac \pi 2]\)的范围以内的。若是使用\(Math.tan2()\)会映射到\([-\pi,\pi]\)的范围内,而且无需考虑符号影响,更加精确。可是这里咱们并不关心另外的一个\(\pi\)的状况,咱们只关心其所在直线(这在后文中会提到,也就是非极大值抑制),因此无需多考虑。
X方向梯度图: Y方向梯度图:
X、Y方向梯度融合效果: Opencv Sobel函数效果:
非极大值抑制是一种边缘稀疏技术,非极大值抑制的做用在于“瘦”边。对图像进行梯度计算后,仅仅基于梯度值提取的边缘仍然很模糊。对于标准3,对边缘有且应当只有一个准确的响应。而非极大值抑制则能够帮助将局部最大值以外的全部梯度值抑制为0,对梯度图像中每一个像素进行非极大值抑制的算法是:
1) 将当前像素的梯度强度与沿正负梯度方向上的两个像素进行比较。
2) 若是当前像素的梯度强度与另外两个像素相比最大,则该像素点保留为边缘点,不然该像素点将被抑制。
一般为了更加精确的计算,在跨越梯度方向的两个相邻像素之间使用线性插值来获得要比较的像素梯度,现举例以下:
图3-2 梯度方向分割
如图3-2所示,将梯度分为8个方向,分别为E、NE、N、NW、W、SW、S、SE,其中0表明\(0^\circ\sim45^\circ\),1表明\(45^\circ\sim90^\circ\),2表明\(-90^\circ\sim-45^\circ\),3表明\(-45^\circ\sim0^\circ\)。像素点P的梯度方向为\(\theta\),则像素点P1和P2的梯度线性插值为:
\[ \tan \theta = G_y ~/~G_x \\ G_{p1} = (1-\tan\theta)\times E + \tan\theta \times NE \\ G_{p2} = (1-\tan\theta)\times W + \tan\theta \times SW \\ \]
上面也只是图中的状况,具体状况以下:
\[ \theta \in [0, \frac \pi 4]: \begin {cases} G_{p1} = (1-\tan\theta)\times E + \tan\theta \times NE \\ G_{p2} = (1-\tan\theta)\times W + \tan\theta \times SW \end {cases}\\\theta \in [\frac \pi 4, \frac \pi 2]: \begin {cases} G_{p1} = (1-\tan\theta)\times N + \tan\theta \times NE \\ G_{p2} = (1-\tan\theta)\times S + \tan\theta \times SW \end {cases}\\\theta \in [-\frac \pi 4, 0]: \begin {cases} G_{p1} = (1-\tan(-\theta))\times E + \tan(-\theta) \times SE \\ G_{p2} = (1-\tan(-\theta))\times W + \tan(-\theta) \times NW \end {cases}\\\theta \in [-\frac \pi 2, -\frac \pi 4]: \begin {cases} G_{p1} = (1-\tan(-\theta))\times S + \tan(-\theta) \times SE \\ G_{p2} = (1-\tan(-\theta))\times N + \tan(-\theta) \times NW \end {cases}\\ \]
所以非极大值抑制的伪代码描写以下:
须要注意的是,如何标志方向并不重要,重要的是梯度方向的计算要和梯度算子的选取保持一致。
Java实现:
package edu.sfls.Jeff.JavaDev.CVLib; import org.opencv.core.CvType; import org.opencv.core.Mat; import java.util.concurrent.CountDownLatch; public class SmartNMS { public static Mat NMS(Mat gradientImage, double[][] pointDirection) throws InterruptedException { Mat outputImage = gradientImage.clone(); final CountDownLatch latch = new CountDownLatch(gradientImage.rows() - 2); for (int i = 1; i < gradientImage.rows() - 1; i ++) { int finalI = i; new Thread(() -> { for (int j = 1; j < gradientImage.cols() - 1; j ++) { double GP = gradientImage.get(finalI, j)[0], E = gradientImage.get(finalI, j + 1)[0], NE = gradientImage.get(finalI - 1, j + 1)[0], N = gradientImage.get(finalI - 1, j)[0], NW = gradientImage.get(finalI - 1, j - 1)[0], W = gradientImage.get(finalI, j - 1)[0], SW = gradientImage.get(finalI + 1, j - 1)[0], S = gradientImage.get(finalI + 1, j)[0], SE = gradientImage.get(finalI + 1, j + 1)[0]; double GP1 = 0, GP2 = 0; double theta = pointDirection[finalI][j]; if (theta >= 0 && theta <= Math.PI / 4) { GP1 = E * (1 - Math.tan(theta)) + NE * Math.tan(theta); GP2 = W * (1 - Math.tan(theta)) + SW * Math.tan(theta); } else if (theta > Math.PI / 4) { GP1 = N * (1 - 1 / Math.tan(theta)) + NE * 1 / Math.tan(theta); GP2 = S * (1 - 1 / Math.tan(theta)) + SW * 1 / Math.tan(theta); } else if (theta < 0 && theta >= -Math.PI / 4) { GP1 = E * (1 - Math.tan(-theta)) + SE * Math.tan(-theta); GP2 = W * (1 - Math.tan(-theta)) + NW * Math.tan(-theta); } else { GP1 = S * (1 - 1 / Math.tan(-theta)) + SE * 1 / Math.tan(-theta); GP2 = N * (1 - 1 / Math.tan(-theta)) + NW * 1 / Math.tan(-theta); } if (GP < GP1 || GP < GP2) outputImage.put(finalI, j, 0); } latch.countDown(); }).start(); } latch.await(); return outputImage; } }
在施加非极大值抑制以后,剩余的像素能够更准确地表示图像中的实际边缘。然而,仍然存在因为噪声和颜色变化引发的一些边缘像素。为了解决这些杂散响应,必须用弱梯度值过滤边缘像素,并保留具备高梯度值的边缘像素,能够经过选择高低阈值来实现。若是边缘像素的梯度值高于高阈值,则将其标记为强边缘像素;若是边缘像素的梯度值小于高阈值而且大于低阈值,则将其标记为弱边缘像素;若是边缘像素的梯度值小于低阈值,则会被抑制。阈值的选择取决于给定输入图像的内容。
双阈值检测的伪代码描写以下:
到目前为止,被划分为强边缘的像素点已经被肯定为边缘,由于它们是从图像中的真实边缘中提取出来的。然而,对于弱边缘像素,将会有一些争论,由于这些像素能够从真实边缘提取也能够是因噪声或颜色变化引发的。为了得到准确的结果,应该抑制由后者引发的弱边缘。一般,由真实边缘引发的弱边缘像素将链接到强边缘像素,而噪声响应未链接。为了跟踪边缘链接,经过查看弱边缘像素及其8个邻域像素,只要其中一个为强边缘像素,则该弱边缘点就能够保留为真实的边缘。
抑制孤立边缘点的伪代码描述以下:
实现:(使用递归)
package edu.sfls.Jeff.JavaDev.CVLib; import org.opencv.core.Mat; import java.util.ArrayList; import java.util.List; public class SmartCanny { private List<Integer[]> highPoints = new ArrayList<Integer[]>(); private void DoubleThreshold(Mat image, double lowThreshold, double highThreshold) { for (int i = 1; i < image.rows() - 1; i ++) for (int j = 1; j < image.cols() - 1; j ++) if (image.get(i, j)[0] >= highThreshold) { image.put(i, j, 255); Integer[] p = new Integer[2]; p[0] = i; p[1] = j; highPoints.add(p); } else if (image.get(i, j)[0] < lowThreshold) image.put(i, j, 0); } private void DoubleThresholdLink(Mat image, double lowThreshold) { for (Integer[] p : highPoints) { DoubleThresholdLinkRecurrent(image, lowThreshold, p[0], p[1]); } for (int i = 1; i < image.rows() - 1; i ++) for (int j = 1; j < image.cols() - 1; j ++) if (image.get(i, j)[0] < 255) image.put(i, j, 0); } private void DoubleThresholdLinkRecurrent(Mat image, double lowThreshold, int i, int j) { if (i <= 0 || j <= 0 || i >= image.rows() - 1 || j >= image.cols() - 1) return; if (image.get(i - 1, j - 1)[0] >= lowThreshold && image.get(i - 1, j - 1)[0] < 255) { image.put(i - 1, j - 1, 255); DoubleThresholdLinkRecurrent(image, lowThreshold, i - 1, j - 1); } if (image.get(i - 1, j)[0] >= lowThreshold && image.get(i - 1, j)[0] < 255) { image.put(i - 1, j, 255); DoubleThresholdLinkRecurrent(image, lowThreshold, i - 1, j); } if (image.get(i - 1, j + 1)[0] >= lowThreshold && image.get(i - 1, j + 1)[0] < 255) { image.put(i - 1, j + 1, 255); DoubleThresholdLinkRecurrent(image, lowThreshold, i - 1, j + 1); } if (image.get(i, j - 1)[0] >= lowThreshold && image.get(i, j - 1)[0] < 255) { image.put(i, j - 1, 255); DoubleThresholdLinkRecurrent(image, lowThreshold, i, j - 1); } if (image.get(i, j + 1)[0] >= lowThreshold && image.get(i, j + 1)[0] < 255) { image.put(i, j + 1, 255); DoubleThresholdLinkRecurrent(image, lowThreshold, i, j + 1); } if (image.get(i + 1, j - 1)[0] >= lowThreshold && image.get(i + 1, j - 1)[0] < 255) { image.put(i + 1, j - 1, 255); DoubleThresholdLinkRecurrent(image, lowThreshold, i + 1, j - 1); } if (image.get(i + 1, j)[0] >= lowThreshold && image.get(i + 1, j)[0] < 255) { image.put(i + 1, j, 255); DoubleThresholdLinkRecurrent(image, lowThreshold, i + 1, j); } if (image.get(i + 1, j + 1)[0] >= lowThreshold && image.get(i + 1, j + 1)[0] < 255) { image.put(i + 1, j + 1, 255); DoubleThresholdLinkRecurrent(image, lowThreshold, i + 1, j + 1); } } public Mat Canny(Mat image, int size, double sigma, double lowThreshold, double highThreshold) throws InterruptedException { Mat tmp = SmartConverter.RGB2Gray((SmartGaussian.colorGaussianFilter(image, size, sigma))); SmartSobel ss = new SmartSobel(); ss.compute(tmp); ss.convert(); Mat ret = SmartNMS.NMS(ss.getGradientXY(), ss.getPointDirection()); this.DoubleThreshold(ret, lowThreshold, highThreshold); this.DoubleThresholdLink(ret, lowThreshold); return ret; } }
[1] 高斯滤波及高斯卷积核C++实现 https://blog.csdn.net/dcrmg/article/details/52304446
[2] 边缘检测之Canny http://www.javashuo.com/article/p-xoxvvnpc-ko.html
[3] Canny边缘检测及C++实现 https://blog.csdn.net/dcrmg/article/details/52344902
[4] Canny边缘检测算法 https://zhuanlan.zhihu.com/p/42122107
[5] Sobel算子及C++实现 https://blog.csdn.net/dcrmg/article/details/52280768