Canny边缘检测算法的原理和实现

简介

本文简单介绍了canny边缘识别算法的原理和c++实现

原理解析

Canny算法主要由四个步骤组成

  1. 消除噪声

  2. 计算梯度和大小

  3. 利用梯度和大小去除非极大值

  4. 连接边缘

前置操作 - 图像灰度化

由于边缘检测只需要关心图像边缘,图像色彩不会造成影响。并且,储存灰度值只需要一个字节,这样子可以大大加速计算过程和节约内存空间。

所以在进行canny算法的实现之前,我们首先需要对图像进行灰度化操作。

常见的图像灰度化如下。

  1. 取rgb通道中任意一个通道的值作为灰度值
  2. 取rgb通道中最大的一个通道的值作为灰度值
  3. 对rgb通道取平均值
  4. 用人眼对三原色的敏感度,对三个通道做加权平均得到灰度值

在参考资料后,选择4作为灰度方法。

1
float grey = 0.114 * blue + 0.587 * green + 0.299 * red

这个计算在整型的计算方法如下

1
2
3
4
5
const uint32_t B_coef = 30;   // 0.114 * 256
const uint32_t G_coef = 150; // 0.587 * 256
const uint32_t R_coef = 77; // 0.299 * 256

uint8_t grey = (uint8_t) ((blue * B_coef + green * G_coef + red * R_coef) >> 8);

asdf

高斯消除噪声 (Gaussian Filter)

在进行边缘检测之前,我们需要对图像进行平滑处理,以便减少图像中的噪声。噪声会导致边缘检测算法误检边缘,因此消除噪声是非常重要的一步。在Canny算法中,通常使用高斯模糊来平滑图像。

高斯模糊的原理是将每个像素的值与其周围像素的值进行加权平均,加权系数由高斯函数确定。具体公式如下:

a

二维高斯函数

通过二维高斯函数,我们就能够确定权重矩阵了。

计算方法:首先用高斯函数计算出每个点的值,然后做归一化处理,得到最后的权重矩阵。

asdf

权重矩阵近似值

利用这个权重矩阵,对每个像素进行处理,就能得到高斯模糊后的图片了。

比如,一个像素点和他周围的值为

1
2
3
32 32 32
32 32 32
32 32 32

在经过运算后,中间的像素值就是

1
32 * 4 / 16 + 4 * 32 * 2 /16 + 4 * 32 * 1 / 16 = 32

为什么需要先做高斯平滑

为什么消除噪声非常重要,如果我们不对图像做平滑处理,图像的信号就会如下所示。在这个时候直接计算derivative,会因为每个连续的点之间不够平滑,无法很好的得到一个first derivative signal,这就导致无法很好的通过first derivative判断边缘的位置

a

credit: UBC CPSC427 @ Leonid Sigal

在经过高斯后,我们可以发现信号就很平滑了,边缘的位置也比较好判断

a

credit: UBC CPSC427 @ Leonid Sigal

计算梯度大小和方向 (Gradient Calculation)

边缘通常位于图像中灰度值变化较大的区域,因此我们需要计算图像中每个像素点的梯度。

由于2d图直接算比较复杂,且消耗运算量,我们可以分别计算图像在水平方向(x轴)和垂直方向(y轴)上的梯度。最后在通过公式计算出方向和大小。

梯度大小可以通过绝对值相加的方式计算(减少运算量)

1
magnitude = abs(dx) + abs(dy)

梯度方向可以通过arctan计算

1
direction = arctan(dy/dx)

通过合并计算梯度与高斯平滑加速运算

通过预先计算高斯平滑的导数,我们可以合并高斯平滑和导数计算。事先计算好高斯平滑的导数,在用这个函数与图像在傅立叶空间中相乘,直接得到高斯平滑后的导数。

a

credit: UBC CPSC427 @ Leonid Sigal

利用梯度大小和方向去除非极大值 (Non Maximum Suppression)

在计算出图像中每个像素点的梯度大小后,我们需要对其进行非极大值抑制。非极大值抑制的目的是去除那些不在边缘上的像素点,使得最终的边缘结果更加细致。

非极大值抑制的原理是:对每个像素点,根据其梯度方向,检查其在梯度方向上的两个邻近像素点的梯度大小。如果当前像素点的梯度大小不是最大的,则将其梯度值置为0。

a

source: https://docs.opencv.org/3.4/da/d22/tutorial_py_canny.html

在canny算法中,梯度方向被分为了0°, 45°, 90°, 135° 四个方向,即水平,垂直,两个斜45°方向。

nms_direction

在具体实现中,我们可以使用tan函数先计算不同角度下dy/dx的值,然后通过dy/dx的比值来判断属于哪个方向。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const uint32_t tan225 = 27145; // 22.5° math.tan(math.pi/8) * (2**16)
const uint32_t tan675 = 158217; // 67.5° math.tan(math.pi/8*3) * (2**16)
uint32_t tanxy;
if (gx != 0) {
tanxy = (static_cast<uint32_t>(abs(gy)) << 16) / abs(gx);
} else {
tanxy = UINT32_MAX;
}
if (tanxy < tan225) {
// 小于22.5° => 0°方向
}else if (tanxy > tan675) {
// 大于67.5° => 90°方向
}else if (gy*gx > 0) {
// dx,dy方向相同,45°方向
}else {
// dx,dy方向相反,135°方向
}

asdf

双阈值检测 (Double Threshold)

双阈值检测的目的是将梯度值较大的边缘标记为强边缘,将梯度值较小但仍可能是边缘的像素标记为弱边缘。

通过设定两个阈值: 高阈值(khigh), 和低阈值(klow),我们可以将图像中的像素分类为强边缘 (A)、弱边缘(B, C) 和 非边缘。

a

source: https://docs.opencv.org/3.4/da/d22/tutorial_py_canny.html

一般来说,高阀值设置为低阀值的两倍

a

credit: UBC CPSC427 @ Leonid Sigal

在具体实现中,把高于maxVal的像素点直接设置为255,把低于minVal的点直接设置为0,弱边缘的值保持不变。

1
2
3
4
5
6
7
if (src.at<uchar>(row, col) < low_threshold) {
dst.at<uchar>(row, col) = 0;
}else if (src.at<uchar>(row, col) > high_threshold) {
dst.at<uchar>(row, col) = 255;
}else {
dst.at<uchar>(row, col) = src.at<uchar>(row, col);
}

asd

连接边缘 (hysteresis)

在双阈值检测之后,我们需要将断开的弱边缘连接起来,使得边缘更加连贯。

其核心概念是把和强边缘(A)连接的弱边缘(C)也标记为边缘。

把没有和强边缘连接的弱边缘(B)标记为非边缘

解决方法思路可以通过对弱边缘进行dfs搜索,如果搜索到一个强边缘,则把路径上的的所有点标记为强边缘。否则把该点标记为非边缘。

再通过一个数组标记所有搜索过的点,那么每个像素最多就只会被遍历一遍。

asd

与OpenCV的实现进行比较

对于复杂图片,我的实现和opencv的实现在像素上的区别低于0.5%

a

复杂图片例子,区别0.31% mse=0.00

asdf

复杂图片例子 区别0.30% mse=0.00

对于简单图片,我的实现和opencv的实现在像素点上的区别低于0.1%

as

简单图片例子1 区别0.08% mse=0.00

as

简单图片例子2 区别0.01% mse=0.00

优化

虽然在效果上相差已经不大了,但是和opencv在速度上差距很大,还需要对算法做进一步优化

a

分割步骤后对三个步骤逐个进行分析并进行优化

a

缓存优化 (cache line optimization)

缓存优化是通过减少缓存未命中次数、提高缓存命中率来提升程序性能的方法。

缓存未命中会导致处理器等待从主存中读取数据,从而降低程序执行速度。通过优化数据结构的排列方式,使数据访问更加连续,从而提高缓存命中率,加快算法执行。

例如,在边缘连接的dfs算法中,使用了在循环中使用了多个变量,并同时对这些变量进行了读取和储存。由于这些内存无法对其到同一条cache line上,会极大减少缓存命中率。

asd

优化方法为用宏替代direction,用dst数组本身替代visited记录是否访问过,以及别的方法,提高缓存命中率

指针优化

通过减少不必要的指针运算和索引操作,提高程序效率。

由于opencv里的Mat::at函数开销巨大,将使用at的地方替换成,直接使用指针进行数据访问,降低索引操作的开销。

同时,通过预先加载相邻像素值到局部变量,减少循环内的指针计算。

优化前

优化后

优化结果

优化后,速度提高了3倍

要进行进一步优化加速,可能需要是使用多线程或者simd

Reference

  1. OpenCV Canny Edge Detection
  2. Wikipedia: Canny Eddge Detector
  3. 阮一峰: 高斯模糊的算法
  4. Canny边缘检测算法解析
  5. Canny 图像边缘检测算法
  6. Canny边缘检测算法及实现
  7. CPU 性能和Cache Line
  8. CPU 是如何执行任务的