HOWTO · OpenCV

OpenCV 边缘检测

本教程将讨论使用 OpenCV 中的 Canny 或 Sobel 边缘检测器检测图像中的边缘。

本页内容

边缘检测在图像处理中广泛用于背景去除、形状检测和图像结构分析。在图像中,边缘是像素强度发生突变的位置。

例如,如果有一张黑色背景的猫的照片,猫的轮廓处会出现从黑色到白色的像素值突变。

OpenCV 提供两种检测图像边缘的方法:Canny 边缘检测器会运行完整的多阶段流程并返回细线状的二值边缘图;Sobel 边缘检测器 则计算选定方向上的图像导数,并把后续过滤留给使用者。本教程将讨论使用 OpenCV 中的 Canny 或 Sobel 边缘检测器检测图像中的边缘。

两种检测器寻找的是同一种特征:在强度跃变处数值较大的像素值导数。你可以只用纯 Python 中的一行灰度值来理解这个思路,无需 OpenCV。

运行下面的示例:

本示例的代码以及输出中的标签仍为英文。

pixels = [20, 20, 20, 20, 200, 200, 200, 200]
response = [pixels[i + 1] - pixels[i - 1] for i in range(1, len(pixels) - 1)]
print("response:", response)
print("peak:", max(response))
response: [0, 0, 180, 180, 0, 0]
peak: 180

这两个非零值标出了强度阶跃的位置。边缘检测器会在整张图像的两个方向上计算类似的响应,然后判断哪些响应可算作真实边缘。

OpenCV Canny 边缘检测

我们可以使用 OpenCV 的 Canny() 函数进行 Canny 边缘检测。该函数需要灰度图像作为输入,先用 GaussianBlur() 函数对其进行平滑处理会得到更好的结果。噪声引起的像素强度突变可能被检测为多余的边缘,而这些边缘并不属于我们想要检测的真实结构,因此去除图像中的噪声很重要。

下面的示例会构建一张小型灰度测试图像,因此安装 OpenCV(pip install opencv-python)后即可独立运行。它仅以源代码形式展示,而不是已执行输出;对于真实照片,在平滑处理前先用 cv2.imread("cat.jpg") 加载,再用 cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 转换为灰度图。结果通过 cv2.imwrite() 保存;在桌面机器上,也可以改用 cv2.imshow() 和 cv2.waitKey(0) 在窗口中显示结果。

示例代码中的注释和输出标签仍为英文。

import os
import tempfile

try:
    import cv2
    import numpy as np
except ModuleNotFoundError:
    print("this example needs the opencv-python package")
    raise SystemExit(0)

img_src = np.zeros((240, 320), dtype=np.uint8)
cv2.rectangle(img_src, (40, 40), (200, 200), 120, -1)
cv2.circle(img_src, (240, 120), 50, 220, -1)

blur_img = cv2.GaussianBlur(img_src, (3, 3), 0)
img_edges = cv2.Canny(image=blur_img, threshold1=50, threshold2=155)

out_path = os.path.join(tempfile.gettempdir(), "canny-edges.png")
cv2.imwrite(out_path, img_edges)
print("edge map:", img_edges.shape, img_edges.dtype)

在安装了 OpenCV 的机器上,该脚本会将 canny-edges.png 写入临时目录,并打印 edge map: (240, 320) uint8。Canny() 的输出是一张单通道 8 位图像,其中边缘像素为 255,其余像素均为 0。将其应用到照片时,结果如下所示:

原始小猫照片与 Canny 边缘检测生成的黑白边缘图并列展示

原始照片,以及 Canny() 返回的细白线边缘图。

Canny 边缘检测器是多阶段的,需要经过多个步骤来找出图像中的边缘。第一步,算法使用高斯滤波器减少给定图像中的噪声。

第二步,算法求图像在 x 和 y 方向上的一阶导数,并根据该导数计算每个像素处边缘梯度的幅值和方向。

第三步,算法应用非极大值抑制:只保留沿梯度方向上的局部最大值像素,从而把检测到的边缘细化成单像素宽的线条。

第四步,算法使用用户定义的两个强度级别过滤边缘,这一步称为滞后阈值处理。梯度高于上限的像素保留为强边缘,低于下限的像素被丢弃,介于两个级别之间的像素只有与强边缘相连时才会保留。

在上面的代码中,Canny() 函数的第一个参数 image 是给定图像,应为 8 位图像。第二个参数 threshold1 和第三个参数 threshold2 分别设置该滞后范围的下限和上限。

Canny() 函数还有两个可选参数。第一个可选参数 apertureSize 用于设置内部 Sobel 算子计算梯度时的孔径大小,默认值为 3。

第二个可选参数 L2gradient 用于设置梯度幅值的计算方式。默认值为 False,此时使用更快的 L1 范数;将其设为 True 则会选用更精确的 L2 范数。

改变阈值范围会改变输出,因为该范围会过滤边缘。同时提高两个阈值会得到更少但更可靠的边缘;降低阈值则会得到更多边缘,包括微弱或噪声引起的边缘。

OpenCV Sobel 边缘检测

如上所述,Canny 边缘检测器是多阶段的:它会去除噪声、找出边缘,并使用阈值范围进行过滤。如果我们想要原始导数(例如在此基础上构建自定义过滤),可以使用 OpenCV 的 Sobel() 函数。Sobel() 函数通过将图像与导数核卷积,求图像在 x 方向、y 方向或两个方向上的导数;它不会替我们降噪或对结果进行阈值处理。

Sobel() 函数同样利用像素强度的突变来寻找边缘。下面的示例分别计算 x 和 y 方向的导数,使每个变量名都与其保存的内容一致;与 Canny 示例一样,它需要安装 opencv-python,并仅以源代码形式展示,而不是已执行输出。由于 ddepth 为 cv2.CV_64F,结果是带符号的:由亮到暗的过渡会产生与由暗到亮的过渡符号相反的数值。cv2.convertScaleAbs() 取绝对值并将结果转换回 8 位,使导数图像可以保存或显示;cv2.addWeighted() 则将两个方向合并为一个综合边缘响应。

示例代码中的注释和输出标签仍为英文。

import os
import tempfile

try:
    import cv2
    import numpy as np
except ModuleNotFoundError:
    print("this example needs the opencv-python package")
    raise SystemExit(0)

img_src = np.zeros((240, 320), dtype=np.uint8)
cv2.rectangle(img_src, (40, 40), (200, 200), 120, -1)
cv2.circle(img_src, (240, 120), 50, 220, -1)

blur_img = cv2.GaussianBlur(img_src, (3, 3), 0)
sobel_x = cv2.Sobel(src=blur_img, ddepth=cv2.CV_64F, dx=1, dy=0, ksize=3)
sobel_y = cv2.Sobel(src=blur_img, ddepth=cv2.CV_64F, dx=0, dy=1, ksize=3)
sobel_xy = cv2.addWeighted(cv2.convertScaleAbs(sobel_x), 0.5,
                          cv2.convertScaleAbs(sobel_y), 0.5, 0)

out_path = os.path.join(tempfile.gettempdir(), "sobel-edges.png")
cv2.imwrite(out_path, sobel_xy)
print("derivative:", sobel_x.dtype, sobel_x.shape)
print("combined:", sobel_xy.dtype, sobel_xy.shape)

在正常桌面环境中运行时,脚本会打印 derivative: float64 (240, 320) 和 combined: uint8 (240, 320),并将 sobel-edges.png 写入临时目录。其中,sobel_x 测量沿 x(水平)方向的强度变化,因此会突出垂直边缘;sobel_y 测量沿 y 方向的变化并突出水平边缘;sobel_xy 则合并两个方向。如果我们直接显示或保存原始的 CV_64F 导数,带符号的数值会发生饱和,窗口中会充满密集噪点,而不是干净的线条:

原始小猫照片与密集且饱和的 Sobel 边缘检测结果并列展示

原始带符号 Sobel 结果在显示时会发生饱和,这就是它看起来比 Canny 边缘图杂乱得多的原因。

Sobel() 函数的第一个参数 src 是源图像。第二个参数 ddepth 用于设置输出图像的深度;请使用 cv2.CV_64F 或 cv2.CV_16S,以便保留带符号的导数,而不是将其截断为 0。

第三个参数 dx 设置 x 导数的阶数,第四个参数 dy 设置 y 导数的阶数:dx=1, dy=0 取水平方向导数,突出垂直边缘;dx=0, dy=1 取垂直方向导数,突出水平边缘;dx=1, dy=1 则响应对角方向的变化。

第五个参数 ksize 设置核大小,其值应为 1、3、5 或 7(-1 选择 Scharr 算子)。第六个参数 scale 是导数值的可选缩放因子,默认情况下不进行缩放。

第七个参数 delta 也是可选的,用于设置加到输出上的常量值。第八个参数 borderType 也是可选的,用于设置像素外推方法,默认边框类型为 BORDER_DEFAULT。

有关边框类型的更多详细信息,请查看此链接。

当你需要方向梯度数据或想要运行自己的后处理时,选择 Sobel();当你想要一张干净的二值边缘图,并希望噪声抑制和阈值过滤都已处理好时,选择 Canny()。