从图像导数到边缘检测:探索Sobel与Scharr算子的原理与实践

计算机视觉是一个分析图像和视频的广阔领域。虽然很多人一听到计算机视觉,首先想到的通常是机器学习模型,但实际上,还有很多其他现有算法,在某些情况下,它们表现得比人工智能还要好!

在计算机视觉中,特征检测 这个领域主要关注的是识别图像中独特的感兴趣区域。这些结果随后可以用来创建特征描述符------即代表局部图像区域的数值向量。之后,可以将同一场景的多张照片的特征描述符组合起来,进行图像匹配,甚至重建场景。

在本文中,我们将借助微积分的类比,来介绍图像导数和梯度。这对于我们理解卷积核,特别是Sobel算子------一种用于检测图像边缘的计算机视觉滤波器------背后的逻辑是必要的。

图像强度

强度是图像的主要特征之一。图像的每个像素都有三个分量:R(红)、G(绿)和B(蓝),它们的取值在0到255之间。值越高,像素越亮。一个像素的强度其实就是其R、G、B分量的加权平均值。

实际上,存在几种定义不同权重的标准。公式如下所示:

ini 复制代码
import cv2
import numpy as np

image = cv2.imread('image.png')
B, G, R = cv2.split(image)
grayscale_image = 0.299 * R + 0.587 * G + 0.114 * B
grayscale_image = np.clip(grayscale_image, 0, 255).astype('uint8')
intensity = grayscale_image.mean()
print(f"图像强度: {intensity:.2f}")
  • 灰度图像

图像可以用不同的色彩通道来呈现。如果RGB通道代表原始图像,那么应用上面的强度公式会将其转换为灰度格式,这种格式只包含一个通道。

由于公式中的权重之和等于1,灰度图像的强度值也将介于0到255之间,就像RGB通道一样。

RGB通道可以使用cv2.cvtColor()函数转换为灰度格式,这比我们上面看到的方法更简便。

ini 复制代码
image = cv2.imread('image.png')
grayscale_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
intensity = grayscale_image.mean()
print(f"图像强度: {intensity:.2f}")

如果我们用这两种方法计算图像强度,可能会得到略有不同的结果。这完全正常,因为在使用cv2.cvtColor函数时,OpenCV会将转换后的像素值四舍五入到最接近的整数。计算平均值时会导致微小的差异。

图像导数

图像导数用于衡量像素强度在图像中变化的速度。图像可以被看作是一个关于两个参数(x, y)的函数 I(x, y),其中x和y指定了像素位置,I代表该像素的强度。

我们可以形式化地写成:

但考虑到图像存在于离散空间中,它们的导数通常通过卷积核来近似计算:

  • 对于水平X轴:使用核 [-1, 0, 1]
  • 对于垂直Y轴:使用核 [-1, 0, 1]ᵀ (转置)

换句话说,我们可以将上面的方程重写为以下形式(其中 Δx = 1, Δy = 1):

为了更好地理解卷积核背后的逻辑,让我们参考下面的例子。

假设我们有一个5x5像素的矩阵,代表一个灰度图像块。这个矩阵的元素显示了像素的强度。

为了计算图像导数,我们可以使用卷积核。思路很简单:选取图像中的一个像素及其邻域内的几个像素,将它们与一个给定的核(代表一个固定的矩阵或向量)进行元素级的乘法,然后求和。

在我们的例子中,我们将使用一个三元素向量 [-1, 0, 1]。从上面的例子中,我们取位置在 (1, 1) 的像素,其值为 -3。

由于(黄色的)核大小是3x1,我们需要-3的左边和右边的元素来匹配这个大小,因此我们取向量 [4, -3, 2]。

然后,通过计算元素乘积之和,我们得到值 -2:

这个-2的值代表了初始像素的导数。如果我们仔细观察,会发现像素-3的导数其实就是它右边像素(2)和左边像素(4)的差值。

既然我们可以直接计算两个元素的差,为什么还要用复杂的公式呢?确实,在这个例子中,我们本可以只计算元素 I(x, y+1) 和 I(x, y-1) 的强度差。但在现实中,当我们需要检测更复杂、更不明显的特征时,可以处理更复杂的场景。因此,使用核的泛化形式是很方便的,这些核的矩阵对于检测预定义类型的特征是已知的。

根据导数值,我们可以做一些观察:

  • 如果某个图像区域的导数值很大,意味着那里的强度发生了剧烈变化。否则,在亮度方面没有明显变化。
  • 如果导数值为正,意味着从左到右,图像区域变亮;如果为负,则意味着从左到右,图像区域变暗。

通过与线性代数类比,核可以被看作是对图像进行操作的线性算子,它们能转换局部图像区域。

类似地,我们可以计算与垂直核的卷积。步骤保持不变,只是我们现在将我们的窗口(核)在图像矩阵上垂直移动。

你可能注意到,在将卷积滤波器应用到原始的5x5图像后,它变成了3x3。这是正常的,因为我们无法以同样的方式对边缘像素应用卷积(否则会越界)。

为了保持图像的维度,通常使用填充技术,这包括临时扩展/插值图像边界或用零填充,这样也可以为边缘像素计算卷积。

默认情况下,像OpenCV这样的库会自动填充边界,以确保输入和输出图像具有相同的维度。

图像梯度

图像梯度显示了在给定像素点上,强度(亮度)在两个方向(X和Y)上变化的快慢。

形式上,图像梯度可以写成关于X轴和Y轴的图像导数的向量。

  • 梯度幅度

梯度幅度代表了梯度向量的模(范数),可以使用以下公式计算:

  • 梯度方向

使用找到的 Gx 和 Gy,也可以计算梯度向量的角度:

让我们看看如何基于上面的例子手动计算梯度。为此,我们需要卷积核应用后得到的3x3矩阵。

如果我们取左上角的像素,它的值是 Gx = -2 和 Gy = 11。我们可以轻松计算出梯度幅度和方向:

对于整个3x3矩阵,我们得到梯度的如下可视化:

在实践中,建议在将核应用到矩阵之前对其进行归一化。为了示例的简洁,我们没有这样做。

Sobel算子

学习了图像导数和梯度的基础知识后,现在该来了解Sobel算子了,它用于近似计算导数和梯度。与之前尺寸为3x1和1x3的核相比,Sobel算子由一对3x3的核定义(分别用于两个轴):

与之前只测量一维变化、忽略邻域中其他行和列的核相比,Sobel算子具有优势。它考虑了关于局部区域的更多信息。

另一个优势是Sobel对处理噪声更鲁棒。让我们看下面的图像块。如果我们计算中心红色元素(位于暗像素2和亮像素7边界上)周围的导数,我们应该得到5。问题在于存在一个值为10的噪声像素。

如果我们在红色元素附近应用水平1D核,它会过分关注像素值10,这显然是一个异常值。同时,Sobel算子则更稳健:它会考虑10,也会考虑它周围值为7的像素。从某种意义上说,Sobel算子应用了平滑。

在同时比较多个核时,建议对核矩阵进行归一化,以确保它们都在同一尺度上。

算子(包括Sobel)在图像分析中最常见的应用之一是特征检测。

对于Sobel和Scharr算子,它们通常用于检测边缘------即像素强度(及其梯度)发生剧烈变化的区域。

  • 实践

要应用Sobel算子,使用cv2.Sobel函数就足够了。让我们看看它的参数:

ini 复制代码
derivative_x = cv2.Sobel(image, cv2.CV_64F, 1, 0) # 计算x方向导数
derivative_y = cv2.Sobel(image, cv2.CV_64F, 0, 1) # 计算y方向导数
  • 第一个参数是输入的NumPy图像。
  • 第二个参数 (cv2.CV_64F) 是输出图像的数据深度。问题在于,通常算子产生的输出图像可能包含超出0-255范围的值。这就是为什么我们需要指定希望输出图像拥有的像素类型。
  • 第三和第四个参数分别代表x方向和y方向上的导数阶数。在我们的例子中,我们只需要x和y方向的一阶导数,所以我们传递值 (1, 0) 和 (0, 1)。

让我们看下面的例子,我们有一张数独输入图像:

让我们应用Sobel滤波器:

ini 复制代码
import cv2
import matplotlib.pyplot as plt

image = cv2.imread("data/input/sudoku.png")
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

# 使用Sobel算子
derivative_x = cv2.Sobel(image, cv2.CV_64F, 1, 0)
derivative_y = cv2.Sobel(image, cv2.CV_64F, 0, 1)

# 组合两个方向的导数
derivative_combined = cv2.addWeighted(derivative_x, 0.5, derivative_y, 0.5, 0)

# 查看值范围
min_value = min(derivative_x.min(), derivative_y.min(), derivative_combined.min())
max_value = max(derivative_x.max(), derivative_y.max(), derivative_combined.max())
print(f"值范围: ({min_value:.2f}, {max_value:.2f})")

# 可视化结果
fig, axes = plt.subplots(1, 3, figsize=(16, 6), constrained_layout=True)
axes[0].imshow(derivative_x, cmap='gray', vmin=min_value, vmax=max_value)
axes[0].set_title("水平导数")
axes[0].axis('off')

image_1 = axes[1].imshow(derivative_y, cmap='gray', vmin=min_value, vmax=max_value)
axes[1].set_title("垂直导数")
axes[1].axis('off')

image_2 = axes[2].imshow(derivative_combined, cmap='gray', vmin=min_value, vmax=max_value)
axes[2].set_title("组合导数")
axes[2].axis('off')

color_bar = fig.colorbar(image_2, ax=axes.ravel().tolist(), orientation='vertical', fraction=0.025, pad=0.04)
plt.savefig("data/output/sudoku_sobel.png")
plt.show()

结果,我们可以看到水平和垂直导数能很好地检测到线条!此外,将这些线条组合起来使我们能够检测到两种类型的特征:

Scharr算子

Sobel算子的另一个流行替代品是Scharr算子:

尽管其结构与Sobel算子非常相似,但Scharr核在边缘检测任务中实现了更高的精度。它具有一些关键的数学特性,本文中我们不打算探讨。

  • 实践

使用Scharr滤波器与上面看到的Sobel滤波器非常相似。唯一的区别是方法名(其他参数相同):

ini 复制代码
derivative_x = cv2.Scharr(image, cv2.CV_64F, 1, 0)
derivative_y = cv2.Scharr(image, cv2.CV_64F, 0, 1)

这是我们使用Scharr滤波器得到的结果:

在这种情况下,很难注意到两种算子结果的差异。然而,通过查看颜色映射,我们可以看到Scharr算子产生的可能值范围(-800, +800)比Sobel的(-200, +200)大得多。这是正常的,因为Scharr核具有更大的常数。

这也是为什么我们需要使用特殊类型cv2.CV_64F的一个好例子。否则,值将被裁剪到标准的0到255范围,我们将丢失有关梯度的重要信息。

注意: 直接对cv2.CV_64F类型的图像应用保存方法会导致错误。要将此类图像保存到磁盘,需要将它们转换为另一种格式,并且只包含0到255之间的值。

结论

通过将微积分基础知识应用于计算机视觉,我们研究了图像的基本属性,这些属性使我们能够检测图像中的强度峰值。这些知识非常有用,因为特征检测是图像分析中的常见任务,特别是在图像处理有约束或者不使用机器学习算法的情况下。

相关推荐
蒙奇D索大2 小时前
【算法】递归算法的深度实践:深度优先搜索(DFS)从原理到LeetCode实战
c语言·笔记·学习·算法·leetcode·深度优先
一匹电信狗2 小时前
【C++11】右值引用+移动语义+完美转发
服务器·c++·算法·leetcode·小程序·stl·visual studio
jz_ddk2 小时前
[实战] 卡尔曼滤波原理与实现(GITHUB 优秀库解读)
算法·github·信号处理·kalman filter·卡尔曼滤波
啊吧怪不啊吧2 小时前
一维前缀和与二维前缀和算法介绍及使用
数据结构·算法
草莓熊Lotso2 小时前
《算法闯关指南:优选算法--位运算》--36.两个整数之和,37.只出现一次的数字 ||
开发语言·c++·算法
陈辛chenxin2 小时前
【大数据技术01】数据科学的基础理论
大数据·人工智能·python·深度学习·机器学习·数据挖掘·数据分析
极客BIM工作室2 小时前
扩散模型核心机制解析:U-Net调用逻辑、反向传播时机与步骤对称性
人工智能·深度学习·机器学习
从零开始的奋豆3 小时前
计算机视觉(三):特征检测与光流法
人工智能·计算机视觉
一只小风华~3 小时前
HarmonyOS:相对布局(RelativeContainer)
深度学习·华为·harmonyos·鸿蒙