OpenCV 笔记(12):常用的边缘检测算子—— Canny

1. Canny 算子产生的背景

一阶导数、二阶导数的边缘算子虽然简单易用,但存在一些缺点。例如容易受噪声影响,容易产生虚假边缘。

John F. Canny 在 1986 年提出了 Canny 边缘检测算法。它是结合了梯度计算方法和非极大值抑制技术的一种边缘检测算法。该算法克服了之前的边缘检测算法在抑制噪声和保持边缘信息方面的缺陷,具有较好的性能。

Canny 边缘检测算法的优点:

  • 能够有效地抑制噪声,同时保持边缘信息。

  • 能够检测到细小的边缘。

  • 具有较高的鲁棒性,能够处理各种噪声类型的图像

Canny 边缘检测算法是最经典的边缘检测算法之一,它在图像分割、目标检测、图像识别等领域有着广泛的应用。

2. Canny 算子

2.1 Canny 边缘检测的步骤

2.1.1 使用高斯滤波器平滑图像去除噪声

高斯滤波器是一种常用的平滑滤波器,它可以用来去除图像中的噪声。

使用高斯滤波器与原图进行卷积,该步骤将平滑图像,以便在进行梯度计算时可以更好地抑制噪声。

2.1.2 计算图像的梯度幅度和方向角

在该系列的第八篇文章中,我们曾介绍过图像的梯度,它是图像灰度变化的速率,可以用来表示图像的边缘信息。

梯度的大小表示图像灰度变化的大小,梯度的方向表示图像灰度变化的方向。梯度幅度表示梯度的大小,用来表示图像灰度变化的剧烈程度。

Canny 边缘检测算法通常使用 Sobel 算子来计算图像的梯度幅度和方向角。

其中,用 L1 范数来近似梯度幅度: <math xmlns="http://www.w3.org/1998/Math/MathML"> M ( x , y ) = ∣ g x ( x , y ) ∣ + ∣ g y ( x , y ) ∣ M(x,y) = |g_x(x,y)| + |g_y(x,y)| </math>M(x,y) = ∣gx(x,y)∣+∣gy(x,y)∣

梯度的方向角: <math xmlns="http://www.w3.org/1998/Math/MathML"> θ = arctan ⁡ [ g y ( x , y ) g x ( x , y ) ] \theta = \arctan \begin{bmatrix} \frac{g_y(x,y)}{g_x(x,y)} \end{bmatrix} </math>θ = arctan[gx(x,y)gy(x,y)]

cpp 复制代码
// 计算梯度、梯度幅度和方向
Mat gradXY, theta;
theta = Mat::zeros(src.size(), CV_8U);
Mat grad_x, grad_y;
Sobel(gauss, grad_x, CV_32F, 1, 0, 3);
Sobel(gauss, grad_y, CV_32F, 0, 1, 3);

Mat gradX, gradY;
convertScaleAbs(grad_x, gradX);
convertScaleAbs(grad_y, gradY);
gradXY = gradX + gradY;

2.1.3 使用非极大值抑制消除边缘检测的虚假响应

非极大值抑制(Non-Maximum Suppression,NMS) 其思想是搜素局部最大值,抑制非极大值。

在 Canny 边缘检测算法中,非极大值抑制是指在一个邻域内 ,对于一个像素点如果其梯度幅度小于其邻域内同方向 梯度幅度的最大值,则该像素点不是边缘点。在 8 邻域内,非极大值抑制就只是在 0 度、90 度、45 度、135 度四个梯度方向上进行的,每个像素点梯度方向按照相近程度 用这四个方向来代替

这样简化了计算,因为现实中图像中的边缘梯度方向不一定是沿着这四个方向的,就需要进行线性插值,会略显繁琐。

cpp 复制代码
// 非最大值抑制
void nonMaximumSuppression (Mat srcGx, Mat srcGy, Mat &gradXY, Mat &theta, Mat &dst) {
    dst = gradXY.clone();
    for (int j = 1; j < gradXY.rows-1; j++) {
        for (int i = 1; i < gradXY.cols-1; i++) {
            double gradX = srcGx.ptr<uchar>(j)[i];
            double gradY = srcGy.ptr<uchar>(j)[i];

            theta.ptr<uchar>(j)[i] = atan(gradY/gradX); //计算梯度方向

            double t = double(theta.ptr<uchar>(j)[i]);
            double g = double(dst.ptr<uchar>(j)[i]);
            if (g == 0.0) {
                continue;
            }
            double g0, g1;
            if ((t >= -(3*M_PI/8)) && (t < -(M_PI/8))) {
                g0 = double(dst.ptr<uchar>(j-1)[i-1]);
                g1 = double(dst.ptr<uchar>(j+1)[i+1]);
            }
            else if ((t >= -(M_PI/8)) && (t < M_PI/8)) {
                g0 = double(dst.ptr<uchar>(j)[i-1]);
                g1 = double(dst.ptr<uchar>(j)[i+1]);
            }
            else if ((t >= M_PI/8) && (t < 3*M_PI/8)) {
                g0 = double(dst.ptr<uchar>(j-1)[i+1]);
                g1 = double(dst.ptr<uchar>(j+1)[i-1]);
            }
            else {
                g0 = double(dst.ptr<uchar>(j-1)[i]);
                g1 = double(dst.ptr<uchar>(j+1)[i]);
            }

            if (g <= g0 || g <= g1) {
                dst.ptr<uchar>(j)[i] = 0.0;
            }
        }
    }
}

2.1.4 使用双阈值处理确定潜在边缘

使用两个阈值来确定边缘。

  • 强边缘点是梯度值大于高阈值的像素点,将像素值置为255。

  • 弱边缘点是梯度值大于低阈值但小于高阈值的像素点,保留像素值不变。

  • 如果梯度值小于低阈值,则会被抑制,将像素值置为0。

cpp 复制代码
// 双阈值算法
void doubleThreshold (Mat &src, double low, double high, Mat &dst) {
    dst = src.clone();

    // 区分出强边缘点和弱边缘点
    for (int j = 0; j < src.rows - 1; j++) {
        for (int i = 0; i < src.cols - 1; i++) {
            double x = double(dst.ptr<uchar>(j)[i]);
            // 像素点为强边缘点,置255
            if (x > high) {
                dst.ptr<uchar>(j)[i] = 255;
            }
            // 像素点置0,被抑制掉
            else if (x < low) {
                dst.ptr<uchar>(j)[i] = 0;
            }
        }
    }
}

2.1.5 滞后连接

强边缘点可以认为是真的边缘,弱边缘点则可能是真的边缘也可能是噪声或颜色变化引起的。

通常会认为,真实边缘引起的弱边缘点和强边缘点是连通的,而又噪声引起的弱边缘点则不会。

Canny 算子中的滞后连接,是指将弱边缘点连接到强边缘点,从而减少边缘断裂。也就是在一个弱边缘点的 8 邻域像素内,只要有强边缘点存在,那么这个弱边缘点的像素点置为 255 被保留下来;如果它的 8 邻域内不存在强边缘点,则这是一个孤立的弱边缘点,像素点置为 0。

cpp 复制代码
// 通过滞后连接断开的边缘
void hysteresis (Mat &src) {
    // 循环找到强边缘点,把其领域内的弱边缘点变为强边缘点
    for (int j = 1; j < src.rows - 2; j++) {
        for (int i = 1; i < src.cols - 2; i++) {
            // 如果该点是强边缘点
            if (src.ptr<uchar>(j)[i] == 255) {
                // 遍历该强边缘点领域
                for (int m = -1; m < 1; m++) {
                    for (int n = -1; n < 1; n++) {
                        // 该点为弱边缘点(不是强边缘点,也不是被抑制的0点)
                        if (src.ptr<uchar>(j + m)[i + n] != 0 && src.ptr<uchar>(j + m)[i + n] != 255) {
                            src.ptr<uchar>(j + m)[i + n] = 255; //该弱边缘点补充为强边缘点
                        }
                    }
                }
            }
        }
    }

    for (int j = 0; j < src.rows - 1; j++) {
        for (int i = 0; i < src.cols - 1; i++) {
            // 如果该点依旧是弱边缘点,及此点是孤立边缘点
            if (src.ptr<uchar>(j)[i] != 255 && src.ptr<uchar>(j)[i] != 255) {
                src.ptr<uchar>(j)[i] = 0; //该孤立弱边缘点抑制
            }
        }
    }
}

2.2 Canny 算子的实现

下面,将上述五个步骤结合起来,一步步实现 Canny 边缘检测算法。

cpp 复制代码
#include <opencv2/opencv.hpp>
#include <math.h>

using namespace std;
using namespace cv;

// 非最大值抑制
void nonMaximumSuppression (Mat srcGx, Mat srcGy, Mat &gradXY, Mat &theta, Mat &dst) {
    dst = gradXY.clone();
    for (int j = 1; j < gradXY.rows-1; j++) {
        for (int i = 1; i < gradXY.cols-1; i++) {
            double gradX = srcGx.ptr<uchar>(j)[i];
            double gradY = srcGy.ptr<uchar>(j)[i];

            theta.ptr<uchar>(j)[i] = atan(gradY/gradX); //计算梯度方向

            double t = double(theta.ptr<uchar>(j)[i]);
            double g = double(dst.ptr<uchar>(j)[i]);
            if (g == 0.0) {
                continue;
            }
            double g0, g1;
            if ((t >= -(3*M_PI/8)) && (t < -(M_PI/8))) {
                g0 = double(dst.ptr<uchar>(j-1)[i-1]);
                g1 = double(dst.ptr<uchar>(j+1)[i+1]);
            }
            else if ((t >= -(M_PI/8)) && (t < M_PI/8)) {
                g0 = double(dst.ptr<uchar>(j)[i-1]);
                g1 = double(dst.ptr<uchar>(j)[i+1]);
            }
            else if ((t >= M_PI/8) && (t < 3*M_PI/8)) {
                g0 = double(dst.ptr<uchar>(j-1)[i+1]);
                g1 = double(dst.ptr<uchar>(j+1)[i-1]);
            }
            else {
                g0 = double(dst.ptr<uchar>(j-1)[i]);
                g1 = double(dst.ptr<uchar>(j+1)[i]);
            }

            if (g <= g0 || g <= g1) {
                dst.ptr<uchar>(j)[i] = 0.0;
            }
        }
    }
}

// 用双阈值算法检测
void doubleThreshold (Mat &src, double low, double high, Mat &dst) {
    dst = src.clone();

    // 区分出强边缘点和弱边缘点
    for (int j = 0; j < src.rows - 1; j++) {
        for (int i = 0; i < src.cols - 1; i++) {
            double x = double(dst.ptr<uchar>(j)[i]);
            // 像素点为强边缘点,置255
            if (x > high) {
                dst.ptr<uchar>(j)[i] = 255;
            }
            // 像素点置0,被抑制掉
            else if (x < low) {
                dst.ptr<uchar>(j)[i] = 0;
            }
        }
    }
}

// 通过滞后连接断开的边缘
void hysteresis (Mat &src) {
    // 循环找到强边缘点,把其领域内的弱边缘点变为强边缘点
    for (int j = 1; j < src.rows - 2; j++) {
        for (int i = 1; i < src.cols - 2; i++) {
            // 如果该点是强边缘点
            if (src.ptr<uchar>(j)[i] == 255) {
                // 遍历该强边缘点领域
                for (int m = -1; m < 1; m++) {
                    for (int n = -1; n < 1; n++) {
                        // 该点为弱边缘点(不是强边缘点,也不是被抑制的0点)
                        if (src.ptr<uchar>(j + m)[i + n] != 0 && src.ptr<uchar>(j + m)[i + n] != 255) {
                            src.ptr<uchar>(j + m)[i + n] = 255; //该弱边缘点补充为强边缘点
                        }
                    }
                }
            }
        }
    }

    for (int j = 0; j < src.rows - 1; j++) {
        for (int i = 0; i < src.cols - 1; i++) {
            // 如果该点依旧是弱边缘点,及此点是孤立边缘点
            if (src.ptr<uchar>(j)[i] != 255 && src.ptr<uchar>(j)[i] != 255) {
                src.ptr<uchar>(j)[i] = 0; //该孤立弱边缘点抑制
            }
        }
    }
}

int main () {
    Mat src = imread(".../street.jpg");
    imshow("src",src);

    Mat gray;
    cvtColor(src, gray, cv::COLOR_BGR2GRAY); // 灰度化
    Mat gauss;

    GaussianBlur(gray, gauss, Size(5, 5),0);

    // 计算梯度、梯度幅度和方向
    Mat gradXY, theta;
    theta = Mat::zeros(src.size(), CV_8U);
    Mat grad_x, grad_y;
    Sobel(gauss, grad_x, CV_32F, 1, 0, 3);
    Sobel(gauss, grad_y, CV_32F, 0, 1, 3);

    Mat gradX, gradY;
    convertScaleAbs(grad_x, gradX);
    convertScaleAbs(grad_y, gradY);
    gradXY = gradX + gradY;

    // 局部非极大值抑制
    Mat nms;
    nonMaximumSuppression(grad_x,grad_y,gradXY, theta, nms);
    imshow("nms",nms);

    // 用双阈值算法检测
    Mat dst;
    doubleThreshold(nms, 50, 100,  dst);
    imshow("thresh",dst);
    // 滞后连接
    hysteresis(dst);
    imshow("edge",dst);

    // OpenCV 的 Canny 函数
    Mat result;
    Canny(gauss, result, 50, 100);
    imshow("canny", result);

    waitKey(0);

    return 0;
}

在上述代码中 Canny() 函数是 OpenCV 自带的函数,放在这里主要是做一个对比。

cpp 复制代码
Canny(InputArray image, OutputArray edges, double threshold1, double threshold2, int apertureSize = 3, bool L2gradient = false);

其各个参数的含义:

第一个参数 image:输入的源图像。

第二个参数 edges:输出的边缘图像。

第三个参数 threshold1:低阈值。

第四个参数 threshold2:高阈值。

第五个参数 apertureSize:Sobel 算子的核大小。

第六个参数 L2gradient:是否使用 L2 范数来计算梯度。默认值为 false,使用 L1 范数来计算梯度。

3. 边缘检测的三大准则

John F. 在提出 Canny 算子的同时,提出了边缘检测的三大准则:

  • 低错误率的边缘检测:检测算法应该精确地找到图像中的尽可能多的边缘,尽可能的减少漏检和误检。

  • 最优定位:检测的边缘点应该精确地定位于边缘的中心。

  • 图像中的任意边缘应该只被标记一次,同时图像噪声不应产生伪边缘。

Canny 边缘检测算法正是遵循了这些准则。

4. 各个算子的关系

将之前介绍的几种算子,整理一下他们的关系:

  • Roberts 算子和 Prewitt 算子是基础算子,Sobel 算子是它们的扩展。

  • Laplace 算子是二阶导数算子,可以计算图像的梯度二阶导数。

  • LoG 算子和 DoG 算子是带高斯核的微分算子,可以抑制噪声。

  • Canny 边缘检测算法是综合使用了高斯滤波、 Sobel 算子、非极大值抑制和双阈值检测的边缘检测算法。

5. 总结

本文介绍了 Canny 算子的特点以及如何一步步实现一个 Canny 的函数。并且,在最后总结了之前介绍的几种算子的关系。

相关推荐
技术支持者python,php6 小时前
物体识别:分类器模型
人工智能·opencv·计算机视觉
雍凉明月夜6 小时前
视觉opencv学习笔记Ⅳ
笔记·opencv·学习·计算机视觉
棒棒的皮皮8 小时前
【OpenCV】Python图像处理之图像表示方法
图像处理·python·opencv
棒棒的皮皮8 小时前
【OpenCV】Python图像处理之通道拆分与合并
图像处理·python·opencv·计算机视觉
向上的车轮9 小时前
图像处理OpenCV与深度学习框架YOLOv8的主要区别是什么?
图像处理·深度学习·opencv·yolov8
棒棒的皮皮9 小时前
【OpenCV】Python图像处理之像素操作
图像处理·python·opencv
向上的车轮9 小时前
基于深度学习与OpenCV的物体计数技术全解析
人工智能·深度学习·opencv
xrn19979 小时前
Android OpenCV SDK 编译教程(WSL2 Ubuntu 22.04 环境)
android·c++·opencv
棒棒的皮皮1 天前
【OpenCV】Python图像处理之读取与保存
图像处理·python·opencv
棒棒的皮皮1 天前
【OpenCV】Python图像处理之特征提取
图像处理·python·opencv