计算机视觉是一个广阔的领域,专注于图像和视频的分析。许多人在听到“计算机视觉”时,首先想到的是机器学习模型,但实际上,还存在许多其他算法,在某些情况下,它们的表现甚至优于人工智能模型!
在计算机视觉中,特征检测的目标是识别图像中独特的兴趣区域。这些检测结果随后可用于创建特征描述符——代表局部图像区域的数值向量。之后,可以将同一场景多张照片的特征描述符结合起来,以进行图像匹配,甚至重建整个场景。
本文将从微积分的角度引入图像导数和梯度的概念。理解卷积核,特别是Sobel算子(一种用于检测图像边缘的计算机视觉滤波器)背后的逻辑,对于后续学习至关重要。
图像强度
是图像的主要特性之一。图像的每个像素都包含三个分量:R(红色)、G(绿色)和B(蓝色),取值范围在0到255之间。值越高,像素越亮。像素的强度是其R、G、B分量的加权平均值。
实际上,存在多种标准定义不同的权重。鉴于本文将侧重于OpenCV,因此将使用OpenCV提供的公式,如下所示:

强度公式
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"Image intensity: {intensity:2f}")
灰度图像
图像可以使用不同的颜色通道来表示。如果RGB通道代表原始图像,应用上述强度公式将将其转换为灰度格式,此时图像只包含一个通道。
由于公式中权重的总和等于1,因此灰度图像将包含介于0到255之间的强度值,与RGB通道的范围相同。

左侧为RGB格式的大本钟,右侧为灰度格式
在OpenCV中,RGB通道可以通过cv2.cvtColor()函数转换为灰度格式,这种方法比前面介绍的手动计算方式更为简便高效。
image = cv2.imread('image.png')
grayscale_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
intensity = grayscale_image.mean()
print(f"Image intensity: {intensity:2f}")
OpenCV使用BGR调色板,而非标准的RGB调色板。两者本质相同,只是R和B元素互换。为简化讨论,本文及本系列后续文章将互换使用RGB和BGR术语。
如果使用OpenCV中的两种方法计算图像强度,可能会得到略微不同的结果。这是完全正常的,因为在使用
cv2.cvtColor函数时,OpenCV会将转换后的像素值四舍五入到最接近的整数。这种舍入操作会导致平均值出现细微差异。
图像导数
图像导数用于衡量像素强度在图像中变化的快慢。可以将图像视为一个二元函数 I(x, y),其中 x 和 y 表示像素位置,I 代表该像素的强度。
从数学上可以形式化表示为:

然而,由于图像存在于离散空间中,其导数通常通过卷积核进行近似计算:
- 对于水平X轴方向:[-1, 0, 1]
- 对于垂直Y轴方向:[-1, 0, 1]ᵀ
换言之,可以将上述方程重写为以下形式:

为了更好地理解卷积核背后的逻辑,可以参考下面的示例。
示例
假设有一个5×5像素的矩阵,代表一个灰度图像块。矩阵中的元素表示像素的强度。

要计算图像导数,可以使用卷积核。其核心思想很简单:通过选取图像中的一个像素及其周围的几个像素,将其与一个表示固定矩阵(或向量)的给定卷积核进行元素级乘法,并求其和。
在本例中,将使用一个三元素向量[-1, 0, 1]。以上述示例为例,取位置(1, 1)的像素,其值为-3。
由于卷积核的尺寸(黄色部分)为3×1,需要-3左侧和右侧的元素来匹配尺寸,因此选取向量[4, -3, 2]。然后,通过计算元素级乘积之和,得到-2:

值-2代表了原始像素的导数。仔细观察会发现,像素-3的导数仅是其右侧像素(2)与其左侧像素(4)之间的差值。
读者可能会好奇,既然可以直接计算两个元素之间的差值,为何还要使用复杂的公式?确实,在此示例中,本可以直接计算元素I(x, y + 1)和I(x, y – 1)之间的强度差。但在实际应用中,卷积核能够处理更复杂的场景,以检测更精细、不那么明显的特征。因此,使用已知矩阵的通用卷积核来检测预定义类型的特征更为方便。
根据导数值,可以得出一些观察结果:
- 如果给定图像区域的导数值显著,意味着该区域的强度变化剧烈。反之,则亮度没有明显变化。
- 如果导数值为正,表示从左到右,图像区域变得更亮;如果为负,则表示从左到右,图像区域变得更暗。
通过类比线性代数,卷积核可以被视为作用于图像,转换局部图像区域的线性算子。
同理,也可以计算与垂直卷积核的卷积。过程保持不变,只是现在窗口(卷积核)是垂直地在图像矩阵上移动。

读者可能会注意到,在对原始5×5图像应用卷积滤波器后,其尺寸变为3×3。这是正常现象,因为无法以同样的方式对边缘像素应用卷积(否则会超出边界)。
为了保持图像维度,通常会使用填充(padding)技术,即临时扩展/插值图像边界或用零填充,以便也能计算边缘像素的卷积。
默认情况下,OpenCV等库会自动对边界进行填充,以确保输入和输出图像的维度一致。
图像梯度
图像梯度显示了在给定像素处,强度(亮度)在X和Y两个方向上变化的快慢。

形式上,图像梯度可以写成一个图像导数向量,包含X轴和Y轴方向的导数。
梯度幅度
梯度幅度代表梯度向量的范数,可以使用以下公式计算:

梯度方向
利用已求得的Gₓ和Gᵧ,还可以计算梯度向量的角度:

示例
接下来,将以上述示例为基础,手动计算梯度。为此,需要用到应用卷积核后得到的3×3矩阵。
如果取左上角的像素,其值为 Gₓ = -2 和 Gᵧ = 11。可以轻松计算出梯度幅度和方向:

对于整个3×3矩阵,得到的梯度可视化结果如下:

在实际应用中,建议在将卷积核应用于矩阵之前对其进行归一化。为简化本示例,此处未进行归一化处理。
Sobel算子
在学习了图像导数和梯度的基本原理后,现在是时候探讨Sobel算子了,它被用于近似计算这些值。与之前尺寸为3×1和1×3的卷积核相比,Sobel算子由一对3×3的卷积核定义(分别用于X轴和Y轴方向):

这赋予了Sobel算子一个优势,因为之前的卷积核仅测量一维变化,忽略了邻域中的其他行和列。Sobel算子则考虑了更多关于局部区域的信息。
Sobel算子的另一个优势是它在处理噪声时更具鲁棒性。观察下面的图像块。如果计算中心红色元素(位于暗像素2和亮像素7的边界上)周围的导数,理论上应该得到5。但问题是存在一个值为10的噪声像素。

如果将水平一维卷积核应用于红色元素附近,它会显著强调值为10的像素,而这显然是一个异常值。与此同时,Sobel算子更具鲁棒性:它会考虑10这个值,同时也会考虑其周围值为7的像素。从某种意义上说,Sobel算子具有平滑效果。
在比较多个卷积核时,建议对矩阵卷积核进行归一化,以确保它们处于同一尺度。总的来说,算子在图像分析中最常见的应用之一就是特征检测。
对于Sobel和Scharr算子,它们通常用于检测边缘——即像素强度(及其梯度)发生剧烈变化的区域。
OpenCV
要应用Sobel算子,只需使用OpenCV函数cv2.Sobel。以下是其参数说明:
derivative_x = cv2.Sobel(image, cv2.CV_64F, 1, 0)
derivative_y = cv2.Sobel(image, cv2.CV_64F, 0, 1)
- 第一个参数是输入的NumPy图像。
- 第二个参数(
cv2.CV_64F)是输出图像的数据深度。问题在于,通常算子可能产生包含超出0–255范围的值的输出图像。因此,需要指定输出图像所需的像素类型。 - 第三和第四个参数分别表示X方向和Y方向的导数阶数。在本例中,只希望计算X方向和Y方向的一阶导数,因此分别传入(1, 0)和(0, 1)。
以下是一个使用数独输入图像的示例:

现在,对图像应用Sobel滤波器:
import cv2
import matplotlib.pyplot as plt
image = cv2.imread("data/input/sudoku.png")
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
derivative_x = cv2.Scharr(image, cv2.CV_64F, 1, 0) # 原文此处使用Scharr,保持一致
derivative_y = cv2.Scharr(image, cv2.CV_64F, 0, 1) # 原文此处使用Scharr,保持一致
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"Value range: ({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("Horizontal derivative")
axes[0].axis('off')
image_1 = axes[1].imshow(derivative_y, cmap='gray', vmin=min_value, vmax=max_value)
axes[1].set_title("Vertical derivative")
axes[1].axis('off')
image_2 = axes[2].imshow(derivative_combined, cmap='gray', vmin=min_value, vmax=max_value)
axes[2].set_title("Combined derivative")
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.png")
plt.show()
结果显示,水平和垂直导数能够很好地检测出图像中的线条!此外,将这些线条组合起来,可以同时检测两种类型的特征:

Scharr算子
除了Sobel卷积核之外,另一种流行的替代方案是Scharr算子:

尽管其结构与Sobel算子非常相似,但Scharr卷积核在边缘检测任务中能够达到更高的精度。它具有本文不详细讨论的几个关键数学特性。
OpenCV
在OpenCV中使用Scharr滤波器与前面介绍的Sobel滤波器非常相似。唯一的区别在于方法名称不同(其他参数保持一致):
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之间的值。
总结
通过将微积分基础应用于计算机视觉领域,本文探讨了重要的图像属性,这些属性使能够检测图像中的强度峰值。这项知识非常有用,因为特征检测是图像分析中的常见任务,尤其是在图像处理存在约束或不使用机器学习算法的情况下。
此外,本文还通过OpenCV示例,展示了Sobel和Scharr算子如何进行边缘检测。在后续文章中,将继续深入研究更高级的特征检测算法,并提供更多OpenCV的实践示例。
