前两章我们学习了图像的基础知识和OpenCV的核心操作。本章将深入探讨图像滤波技术,理解如何去除噪声、平滑图像,以及如何检测图像中的边缘信息。这些是计算机视觉中特征提取的基础。


1. 环境声明

  • Python版本Python 3.12+
  • PyTorch版本PyTorch 2.2+
  • OpenCV版本OpenCV 4.10+
  • 操作系统:Windows / macOS / Linux (通用)

2. 什么是图像滤波

2.1 滤波的本质

想象你正在拍摄一张照片,但光线不足导致图像有很多噪点。图像滤波就像给照片戴上了一副特殊的眼镜,能够去除噪点、平滑图像,或者增强某些特征。

数学本质
图像滤波是一个卷积操作。对于每个像素,我们用一个核(kernel)去扫描它周围的像素,根据核的权重计算新的像素值。

公式表示

g(x,y) = Σ Σ f(x+i, y+j) * h(i,j)

其中:

  • f(x,y) 是原始图像
  • h(i,j) 是滤波核(卷积核)
  • g(x,y) 是滤波后的图像

2.2 卷积核(滤波核)

卷积核是一个小的矩阵,决定了如何计算每个像素的新值。

举例:3x3均值滤波核

[1/9  1/9  1/9]
[1/9  1/9  1/9]
[1/9  1/9  1/9]

这个核将每个像素替换为它周围3x3区域内所有像素的平均值,达到平滑效果。

卷积过程的可视化

  1. 将核放在图像的某个像素上
  2. 核的中心对准该像素
  3. 将核的每个元素与对应像素的值相乘
  4. 将所有乘积相加,得到新的像素值
  5. 移动到下一个像素,重复步骤1-4

3. 线性滤波

3.1 均值滤波(Mean Filtering)

原理:用邻域内像素的平均值替代中心像素值。

特点

  • 简单快速
  • 会模糊图像边缘
  • 对所有像素一视同仁

数学公式

g(x,y) = (1/(2k+1)^2) * Σ Σ f(x+i, y+j)

其中k是核的半径(如3x3核k=1)

import cv2
import numpy as np

# 读取图像
img = cv2.imread('image.jpg')

# 应用均值滤波
# 参数:图像,核的大小(宽,高)
blurred_3x3 = cv2.blur(img, (3, 3))
blurred_5x5 = cv2.blur(img, (5, 5))
blurred_10x10 = cv2.blur(img, (10, 10))

# 使用自定义核
kernel_size = 5
kernel = np.ones((kernel_size, kernel_size), np.float32) / (kernel_size ** 2)
custom_blur = cv2.filter2D(img, -1, kernel)

3.2 高斯滤波(Gaussian Filtering)

原理:使用高斯函数作为权重,离中心越近的像素权重越大。

为什么高斯滤波更好?

  • 符合自然规律(很多自然现象服从高斯分布)
  • 边缘保持较好
  • 可分离性(计算效率高)

二维高斯函数

G(x,y) = (1/(2πσ²)) * exp(-(x²+y²)/(2σ²))

其中σ(sigma)控制平滑程度:

  • σ越小,权重越集中,平滑效果越弱
  • σ越大,权重越分散,平滑效果越强
import cv2

# 高斯滤波
# 参数:图像,核宽,核高,X方向标准差,Y方向标准差
gaussian_3 = cv2.GaussianBlur(img, (3, 3), 0)
gaussian_5 = cv2.GaussianBlur(img, (5, 5), 0)
gaussian_7 = cv2.GaussianBlur(img, (7, 7), 0)

# 使用自定义sigma
gaussian_custom = cv2.GaussianBlur(img, (0, 0), sigmaX=2.0)

# 注意:核大小必须是奇数
# 如果设为0,OpenCV会自动计算合适的核大小

高斯核的计算示例(5x5,sigma=1)

import numpy as np
from scipy import ndimage

# 计算高斯核
def gaussian_kernel(size, sigma):
    """生成高斯核"""
    ax = np.linspace(-(size - 1) / 2., (size - 1) / 2., size)
    gauss = np.exp(-0.5 * np.square(ax) / np.square(sigma))
    kernel = np.outer(gauss, gauss)
    return kernel / np.sum(kernel)

# 生成5x5核,sigma=1
kernel_5x5 = gaussian_kernel(5, 1.0)
print("5x5高斯核 (sigma=1):")
print(kernel_5x5)

3.3 可分离滤波的优势

高斯滤波的一个重要特性是可分离性

G(x,y) = G(x) * G(y)

这意味着我们可以先对行进行1D高斯滤波,再对列进行1D高斯滤波,计算复杂度从O(n²)降低到O(n)。

对于NxN的图像和MxM的核:

  • 2D卷积:O(N² × M²)
  • 可分离1D卷积:O(N² × 2M) = O(2N²M)

当M较大时,性能提升非常显著。


4. 非线性滤波

4.1 中值滤波(Median Filtering)

原理:用邻域内像素的中值替代中心像素。

特点

  • 对椒盐噪声(salt-and-pepper noise)特别有效
  • 边缘保持较好
  • 计算复杂度较高(需要排序)

椒盐噪声示例

  • "椒"噪声:随机黑色像素
  • "盐"噪声:随机白色像素
  • 常见于图像传输错误或传感器故障
import cv2
import numpy as np

# 添加椒盐噪声
def add_salt_and_pepper_noise(image, salt_prob=0.01, pepper_prob=0.01):
    noisy = image.copy()
    total_pixels = image.shape[0] * image.shape[1]
    
    # 盐噪声(白色)
    num_salt = int(total_pixels * salt_prob)
    salt_coords = [np.random.randint(0, i-1, num_salt) for i in image.shape[:2]]
    noisy[salt_coords[0], salt_coords[1]] = 255
    
    # 椒噪声(黑色)
    num_pepper = int(total_pixels * pepper_prob)
    pepper_coords = [np.random.randint(0, i-1, num_pepper) for i in image.shape[:2]]
    noisy[pepper_coords[0], pepper_coords[1]] = 0
    
    return noisy

# 生成含噪声图像
noisy_img = add_salt_and_pepper_noise(img, 0.02, 0.02)

# 中值滤波
median_3 = cv2.medianBlur(noisy_img, 3)
median_5 = cv2.medianBlur(noisy_img, 5)

# 对比:高斯滤波对椒盐噪声效果不好
gaussian_noisy = cv2.GaussianBlur(noisy_img, (5, 5), 0)

4.2 双边滤波(Bilateral Filtering)

问题:传统滤波会模糊边缘,如何在平滑的同时保持边缘清晰?

双边滤波原理
考虑两个权重:

  1. 空间权重:像空间距离,与普通高斯滤波相同
  2. 灰度权重:像素值差异,值差异大的像素权重小

公式

I_filtered(x) = (1/W) * Σ I(xi) * G_space(||x - xi||) * G_range(|I(x) - I(xi)|)

其中:

  • G_space:空间高斯(距离越近权重越大)
  • G_range:灰度高斯(颜色越相似权重越大)
  • W:归一化因子
import cv2

# 双边滤波
# 参数:图像,直径,色彩空间sigma,坐标空间sigma
bilateral = cv2.bilateralFilter(img, 9, 75, 75)

# 参数说明:
# - d: 滤波直径,每个像素邻域的直径。-1表示根据sigmaSpace自动计算
# - sigmaColor: 颜色空间滤波sigma,值越大意味着越远的颜色会被混合
# - sigmaSpace: 坐标空间滤波sigma,值越大意味着越远的像素会影响当前像素

# 更强的平滑效果
bilateral_strong = cv2.bilateralFilter(img, 15, 100, 100)

应用场景

  • 人像美颜(平滑皮肤但保持五官清晰)
  • 图像风格化
  • 预处理去噪

5. 边缘检测基础

5.1 什么是边缘

边缘是图像中灰度值发生剧烈变化的地方,通常对应物体的边界。

边缘类型

  1. 阶跃边缘(Step Edge):灰度值从一个值突变到另一个值
  2. 斜坡边缘(Ramp Edge):灰度值渐变
  3. 屋脊边缘(Roof Edge):灰度值先增后减(如细线)
  4. 脉冲边缘(Spike Edge):孤立的突变点

5.2 图像梯度

边缘检测的核心是计算图像的梯度(灰度变化率)。

梯度定义

∇f = [∂f/∂x, ∂f/∂y]

梯度的大小(幅值):

|∇f| = sqrt((∂f/∂x)² + (∂f/∂y)²)

梯度的方向:

θ = arctan(∂f/∂y / ∂f/∂x)

离散近似
由于图像是离散的,我们用差分近似导数:

∂f/∂x ≈ f(x+1,y) - f(x,y)
∂f/∂y ≈ f(x,y+1) - f(x,y)

6. 边缘检测算子

6.1 Sobel算子

原理:使用两个3x3核分别检测水平和垂直方向的边缘。

水平核(检测垂直边缘)

[-1  0  1]
[-2  0  2]
[-1  0  1]

垂直核(检测水平边缘)

[-1 -2 -1]
[ 0  0  0]
[ 1  2  1]

权重设计原理

  • 中心行/列权重更大,因为中心像素与当前像素更相关
  • 对称性保证旋转不变性
  • 权重和为0,对均匀区域响应为0
import cv2
import numpy as np

# 读取图像并转为灰度
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# Sobel算子
# 参数:图像,输出深度,x方向导数阶数,y方向导数阶数,核大小
ddepth = cv2.CV_64F  # 64位浮点,避免溢出

sobelx = cv2.Sobel(gray, ddepth, 1, 0, ksize=3)  # 水平边缘
sobely = cv2.Sobel(gray, ddepth, 0, 1, ksize=3)  # 垂直边缘

# 取绝对值并转为8位
sobelx_abs = cv2.convertScaleAbs(sobelx)
sobely_abs = cv2.convertScaleAbs(sobely)

# 合并梯度
sobel_combined = cv2.addWeighted(sobelx_abs, 0.5, sobely_abs, 0.5, 0)

# 或者计算梯度幅值
gradient_magnitude = np.sqrt(sobelx**2 + sobely**2)
gradient_magnitude = cv2.convertScaleAbs(gradient_magnitude)

6.2 Scharr算子

改进之处
Scharr算子是Sobel算子的改进版本,使用不同的权重,对弱边缘更敏感。

核设计

  • 考虑了旋转最优性
  • 在45度角上的响应更好

使用场景

  • 当需要检测弱边缘时
  • 当Sobel核大小为3时,Scharr通常效果更好
# Scharr算子
scharrx = cv2.Scharr(gray, ddepth, 1, 0)
scharry = cv2.Scharr(gray, ddepth, 0, 1)

scharrx_abs = cv2.convertScaleAbs(scharrx)
scharry_abs = cv2.convertScaleAbs(scharry)
scharr_combined = cv2.addWeighted(scharrx_abs, 0.5, scharry_abs, 0.5, 0)

6.3 Laplacian算子

原理:计算二阶导数,检测灰度值的快速变化。

特点

  • 对噪声敏感(需要预处理平滑)
  • 可以检测所有方向的边缘
  • 会产生双边缘(正负响应)

常用核

[ 0  1  0]
[ 1 -4  1]
[ 0  1  0]
# Laplacian算子
laplacian = cv2.Laplacian(gray, ddepth, ksize=3)
laplacian_abs = cv2.convertScaleAbs(laplacian)

# 更好的做法:先高斯平滑,再Laplacian(LoG:Laplacian of Gaussian)
gaussian_blur = cv2.GaussianBlur(gray, (3, 3), 0)
laplacian_gaussian = cv2.Laplacian(gaussian_blur, ddepth, ksize=3)
laplacian_gaussian_abs = cv2.convertScaleAbs(laplacian_gaussian)

7. Canny边缘检测

7.1 Canny算法的五个步骤

Canny算法是目前最流行的边缘检测算法,包含以下步骤:

步骤1:高斯平滑
去除噪声,防止噪声被误判为边缘。

步骤2:计算梯度幅值和方向
使用Sobel算子计算每个像素的梯度和方向。

步骤3:非极大值抑制(NMS)
沿着梯度方向,只保留局部最大值,细化边缘。

步骤4:双阈值检测

  • 高阈值(maxVal):高于此值的肯定是边缘
  • 低阈值(minVal):低于此值的肯定不是边缘
  • 中间值:如果与强边缘相连,则为边缘;否则丢弃

步骤5:边缘连接
通过滞后阈值连接边缘,形成连续的边缘线。

7.2 Canny算法实现

import cv2

# Canny边缘检测
# 参数:图像,低阈值,高阈值
edges = cv2.Canny(gray, 50, 150)

# 调整阈值
def nothing(x):
    pass

# 创建窗口
cv2.namedWindow('Canny')
cv2.createTrackbar('Low Threshold', 'Canny', 50, 255, nothing)
cv2.createTrackbar('High Threshold', 'Canny', 150, 255, nothing)

while True:
    low = cv2.getTrackbarPos('Low Threshold', 'Canny')
    high = cv2.getTrackbarPos('High Threshold', 'Canny')
    
    edges = cv2.Canny(gray, low, high)
    cv2.imshow('Canny', edges)
    
    if cv2.waitKey(1) & 0xFF == 27:  # ESC键退出
        break

cv2.destroyAllWindows()

7.3 阈值选择策略

经验法则

  • 低阈值 = 高阈值 × 0.3 到 0.5
  • 高阈值通常在100-200之间
  • 对于弱边缘,可以降低阈值
  • 对于强噪声,先进行更强的平滑

自适应阈值

# 基于图像中值自动计算阈值
median = np.median(gray)
lower = int(max(0, 0.33 * median))
upper = int(min(255, 1.33 * median))
edges_auto = cv2.Canny(gray, lower, upper)

8. 多尺度边缘检测

8.1 为什么要多尺度

不同尺度的边缘代表不同层级的信息:

  • 细尺度:细节纹理、小物体边缘
  • 粗尺度:大物体轮廓、整体结构

8.2 图像金字塔

高斯金字塔
通过连续降采样构建,每一层是上一层的低分辨率版本。

# 构建高斯金字塔
layer = img.copy()
gaussian_pyramid = [layer]

for i in range(3):
    layer = cv2.pyrDown(layer)
    gaussian_pyramid.append(layer)

# 拉普拉斯金字塔(用于图像重建)
laplacian_pyramid = []
for i in range(len(gaussian_pyramid)-1, 0, -1):
    size = (gaussian_pyramid[i-1].shape[1], gaussian_pyramid[i-1].shape[0])
    GE = cv2.pyrUp(gaussian_pyramid[i], dstsize=size)
    L = cv2.subtract(gaussian_pyramid[i-1], GE)
    laplacian_pyramid.append(L)

8.3 多尺度Canny

# 在不同尺度上进行Canny检测
scales = [1.0, 0.5, 0.25]  # 原图、1/2、1/4
canny_results = []

for scale in scales:
    if scale == 1.0:
        scaled_img = gray
    else:
        scaled_img = cv2.resize(gray, None, fx=scale, fy=scale)
    
    edges = cv2.Canny(scaled_img, 50, 150)
    
    # 缩放回原始大小
    if scale != 1.0:
        edges = cv2.resize(edges, (gray.shape[1], gray.shape[0]))
    
    canny_results.append(edges)

9. 实战项目:边缘检测工具箱

创建一个综合的边缘检测工具类:

import cv2
import numpy as np
from typing import Tuple, Optional

class EdgeDetector:
    """边缘检测工具类"""
    
    def __init__(self, image: np.ndarray):
        """初始化
        Args:
            image: 输入图像(BGR或灰度)
        """
        if len(image.shape) == 3:
            self.gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
        else:
            self.gray = image.copy()
        self.original = self.gray.copy()
    
    def sobel(self, ksize: int = 3, combine: bool = True) -> np.ndarray:
        """Sobel边缘检测"""
        ddepth = cv2.CV_64F
        sobelx = cv2.Sobel(self.gray, ddepth, 1, 0, ksize=ksize)
        sobely = cv2.Sobel(self.gray, ddepth, 0, 1, ksize=ksize)
        
        sobelx = cv2.convertScaleAbs(sobelx)
        sobely = cv2.convertScaleAbs(sobely)
        
        if combine:
            return cv2.addWeighted(sobelx, 0.5, sobely, 0.5, 0)
        return sobelx, sobely
    
    def scharr(self, combine: bool = True) -> np.ndarray:
        """Scharr边缘检测"""
        ddepth = cv2.CV_64F
        scharrx = cv2.Scharr(self.gray, ddepth, 1, 0)
        scharry = cv2.Scharr(self.gray, ddepth, 0, 1)
        
        scharrx = cv2.convertScaleAbs(scharrx)
        scharry = cv2.convertScaleAbs(scharry)
        
        if combine:
            return cv2.addWeighted(scharrx, 0.5, scharry, 0.5, 0)
        return scharrx, scharry
    
    def laplacian(self, ksize: int = 3, blur_first: bool = True) -> np.ndarray:
        """Laplacian边缘检测"""
        if blur_first:
            img = cv2.GaussianBlur(self.gray, (3, 3), 0)
        else:
            img = self.gray
        
        ddepth = cv2.CV_64F
        laplacian = cv2.Laplacian(img, ddepth, ksize=ksize)
        return cv2.convertScaleAbs(laplacian)
    
    def canny(self, threshold1: int = 50, threshold2: int = 150, 
              apertureSize: int = 3, L2gradient: bool = False) -> np.ndarray:
        """Canny边缘检测"""
        return cv2.Canny(self.gray, threshold1, threshold2, 
                        apertureSize=apertureSize, L2gradient=L2gradient)
    
    def auto_canny(self, sigma: float = 0.33) -> np.ndarray:
        """自适应阈值Canny"""
        median = np.median(self.gray)
        lower = int(max(0, (1.0 - sigma) * median))
        upper = int(min(255, (1.0 + sigma) * median))
        return cv2.Canny(self.gray, lower, upper)
    
    def prewitt(self) -> np.ndarray:
        """Prewitt边缘检测(OpenCV没有内置,手动实现)"""
        kernelx = np.array([[1, 1, 1], [0, 0, 0], [-1, -1, -1]])
        kernely = np.array([[-1, 0, 1], [-1, 0, 1], [-1, 0, 1]])
        
        prewittx = cv2.filter2D(self.gray, cv2.CV_64F, kernelx)
        prewitty = cv2.filter2D(self.gray, cv2.CV_64F, kernely)
        
        prewittx = cv2.convertScaleAbs(prewittx)
        prewitty = cv2.convertScaleAbs(prewitty)
        
        return cv2.addWeighted(prewittx, 0.5, prewitty, 0.5, 0)
    
    def roberts(self) -> np.ndarray:
        """Roberts边缘检测"""
        kernelx = np.array([[1, 0], [0, -1]])
        kernely = np.array([[0, 1], [-1, 0]])
        
        robertsx = cv2.filter2D(self.gray, cv2.CV_64F, kernelx)
        robertsy = cv2.filter2D(self.gray, cv2.CV_64F, kernely)
        
        robertsx = cv2.convertScaleAbs(robertsx)
        robertsy = cv2.convertScaleAbs(robertsy)
        
        return cv2.addWeighted(robertsx, 0.5, robertsy, 0.5, 0)
    
    def compare_all(self) -> dict:
        """对比所有边缘检测方法"""
        results = {
            'Original': self.gray,
            'Sobel': self.sobel(),
            'Scharr': self.scharr(),
            'Laplacian': self.laplacian(),
            'Canny': self.canny(),
            'Auto Canny': self.auto_canny(),
            'Prewitt': self.prewitt(),
            'Roberts': self.roberts()
        }
        return results


# 使用示例
if __name__ == "__main__":
    # 读取图像
    img = cv2.imread('test_image.jpg')
    
    # 创建检测器
    detector = EdgeDetector(img)
    
    # 对比所有方法
    results = detector.compare_all()
    
    # 显示结果
    import matplotlib.pyplot as plt
    
    fig, axes = plt.subplots(2, 4, figsize=(16, 8))
    axes = axes.flatten()
    
    for i, (name, result) in enumerate(results.items()):
        axes[i].imshow(result, cmap='gray')
        axes[i].set_title(name)
        axes[i].axis('off')
    
    plt.tight_layout()
    plt.savefig('edge_detection_comparison.png', dpi=150)
    plt.show()

10. 避坑小贴士

常见错误1:忘记转换为灰度图

现象:边缘检测结果异常,出现彩色边缘

原因:Sobel、Canny等算子通常在灰度图上计算

解决方案

# 错误
edges = cv2.Canny(color_img, 50, 150)  # 可能出错

# 正确
gray = cv2.cvtColor(color_img, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 50, 150)

常见错误2:数据类型溢出

现象:梯度计算后出现黑斑或异常亮斑

原因:8位整数运算溢出

解决方案

# 错误
ddepth = cv2.CV_8U  # 会溢出
sobelx = cv2.Sobel(gray, ddepth, 1, 0)

# 正确
ddepth = cv2.CV_64F  # 64位浮点
sobelx = cv2.Sobel(gray, ddepth, 1, 0)
sobelx = cv2.convertScaleAbs(sobelx)  # 转换回8位

常见错误3:Canny阈值设置不当

现象:边缘过多(噪声)或过少(丢失边缘)

解决方案

# 使用自适应阈值
median = np.median(gray)
lower = int(max(0, 0.33 * median))
upper = int(min(255, 1.33 * median))
edges = cv2.Canny(gray, lower, upper)

常见错误4:忽视预处理

现象:边缘检测被噪声干扰

解决方案

# 先平滑再检测
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blurred, 50, 150)

11. 本章小结

通过本章的学习,你应该掌握了:

  1. 滤波原理:理解了卷积和滤波核的概念
  2. 线性滤波:均值滤波、高斯滤波的特性和使用场景
  3. 非线性滤波:中值滤波、双边滤波的优势
  4. 边缘检测基础:梯度、图像微分的概念
  5. 边缘检测算子:Sobel、Scharr、Laplacian的原理和实现
  6. Canny算法:五步法、非极大值抑制、双阈值检测
  7. 多尺度检测:图像金字塔、多尺度边缘提取
  8. 实践编程:完整的边缘检测工具类

一句话总结:滤波是图像处理的"调色板",可以平滑、锐化或提取特征;边缘检测是图像理解的"第一步",将图像从像素域转换到特征域。


12. 练习与思考

  1. 噪声对比:比较不同滤波器对高斯噪声和椒盐噪声的效果
  2. 自适应滤波:实现一个根据局部方差自适应选择滤波器的算法
  3. 边缘方向:修改Canny算法,输出边缘方向图
  4. 角点检测:结合Sobel x和y方向的梯度,实现Harris角点检测
  5. 图像融合:使用拉普拉斯金字塔实现图像的无缝融合

下一章预告:第4章《形态学操作与图像分割》将学习如何使用形态学运算处理二值图像,以及如何使用阈值和连通域分析进行图像分割。


如果本章内容对你有帮助,欢迎点赞、收藏和关注。

更多推荐