图像处理基础 第3课/共35课

第03课:边缘检测

📖 课程概述

本课学习边缘检测的核心原理与实现。我们将从数学基础出发,通过代码实践真正理解每一个算法的运作机制。

📑 本课目录

1. 边缘的数学本质2. Sobel算子3. Laplacian算子4. Canny边缘检测5. LoG与DoG6. 多尺度边缘检测7. 边缘检测实战

1. 边缘的数学本质

边缘是图像中灰度值急剧变化的位置,对应一阶导数极值或二阶导数零交叉点。

一阶导数(梯度): ∇f = [∂f/∂x, ∂f/∂y]
梯度幅值: |∇f| = √((∂f/∂x)² + (∂f/∂y)²)
二阶导数(拉普拉斯): ∇²f = ∂²f/∂x² + ∂²f/∂y²

边缘类型

import cv2, numpy as np
img = np.zeros((256,256), dtype=np.uint8)
cv2.rectangle(img, (40,40), (216,216), 200, -1)
cv2.circle(img, (128,128), 50, 100, -1)
noise = np.random.normal(0, 8, img.shape)
img_noisy = np.clip(img.astype(np.float64) + noise, 0, 255).astype(np.uint8)
sobel_x = cv2.Sobel(img_noisy, cv2.CV_64F, 1, 0, ksize=3)
sobel_y = cv2.Sobel(img_noisy, cv2.CV_64F, 0, 1, ksize=3)
magnitude = np.sqrt(sobel_x**2 + sobel_y**2)
print(f"梯度幅值均值: {magnitude.mean():.2f}")
print(f"边缘像素(>50)占比: {(magnitude > 50).sum()/magnitude.size:.2%}")

2. Sobel算子

Gx = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]] (水平方向)
Gy = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]] (垂直方向)
梯度幅值: G = √(Gx² + Gy²)
import cv2, numpy as np
img = np.zeros((256,256), dtype=np.uint8)
cv2.rectangle(img, (40,40), (216,216), 200, -1)
cv2.circle(img, (128,128), 50, 100, -1)
blurred = cv2.GaussianBlur(img, (3,3), 1.0)
sobel_x = cv2.Sobel(blurred, cv2.CV_64F, 1, 0, ksize=3)
sobel_y = cv2.Sobel(blurred, cv2.CV_64F, 0, 1, ksize=3)
magnitude = np.sqrt(sobel_x**2 + sobel_y**2)
magnitude_uint8 = np.clip(magnitude, 0, 255).astype(np.uint8)
approx_mag = np.abs(sobel_x) + np.abs(sobel_y)
print(f"Sobel X范围: [{sobel_x.min():.1f}, {sobel_x.max():.1f}]")
print(f"精确vs近似幅值差异: {np.mean(np.abs(magnitude-approx_mag)):.2f}")
cv2.imwrite('/var/www/ttl/cv/l03_sobel.png',
    np.hstack([img, np.abs(sobel_x).astype(np.uint8), np.abs(sobel_y).astype(np.uint8), magnitude_uint8]))

3. Laplacian算子

Laplacian是二阶微分算子,对噪声更敏感但边缘定位更精确。

∇²f = ∂²f/∂x² + ∂²f/∂y²
离散近似: L = [[0,1,0],[1,-4,1],[0,1,0]]
import cv2, numpy as np
img = np.zeros((256,256), dtype=np.uint8)
cv2.rectangle(img, (40,40), (216,216), 200, -1)
cv2.circle(img, (128,128), 50, 100, -1)
lap_direct = cv2.Laplacian(img, cv2.CV_64F, ksize=3)
blurred = cv2.GaussianBlur(img, (5,5), 1.0)
lap_log = cv2.Laplacian(blurred, cv2.CV_64F, ksize=3)
for sigma in [0.5, 1.0, 2.0]:
    blur_s = cv2.GaussianBlur(img, (0,0), sigma)
    lap_s = cv2.Laplacian(blur_s, cv2.CV_64F)
    edge_pixels = (np.abs(lap_s) > 20).sum()
    print(f"sigma={sigma}: 边缘像素数={edge_pixels}")
cv2.imwrite('/var/www/ttl/cv/l03_laplacian.png',
    np.hstack([img, np.abs(lap_direct).astype(np.uint8), np.abs(lap_log).astype(np.uint8)]))

4. Canny边缘检测

Canny是工业标准边缘检测算法,包含4步骤:

Canny四步骤

  1. 高斯平滑:抑制噪声
  2. 计算梯度:Sobel求幅值和方向
  3. 非极大值抑制(NMS):沿梯度方向只保留局部最大值
  4. 双阈值检测:高阈值确定强边缘,低阈值连接弱边缘
强边缘: |G| ≥ Thigh → 保留
弱边缘: Tlow ≤ |G| < Thigh → 仅当与强边缘连通时保留
推荐比值: Thigh / Tlow = 2:1 ~ 3:1
import cv2, numpy as np
img = np.zeros((256,256), dtype=np.uint8)
cv2.rectangle(img, (40,40), (216,216), 200, -1)
cv2.circle(img, (128,128), 50, 100, -1)
cv2.line(img, (40,40), (216,216), 150, 2)
for low, high in [(30,100), (50,150), (80,200), (100,250)]:
    edges = cv2.Canny(img, low, high)
    print(f"({low:3d}, {high:3d}): 边缘像素={np.count_nonzero(edges):5d}")

canny = cv2.Canny(img, 50, 150)
sobel_mag = np.sqrt(cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=3)**2 + cv2.Sobel(img, cv2.CV_64F, 0, 1, ksize=3)**2)
sobel_edge = (sobel_mag > 80).astype(np.uint8) * 255
cv2.imwrite('/var/www/ttl/cv/l03_canny.png', np.hstack([img, canny, sobel_edge]))

5. LoG与DoG

LoG(x,y,σ) = ∇²G(x,y,σ)
DoG(x,y,σ12) = G(x,y,σ2) - G(x,y,σ1)
近似: DoG ≈ (σ21) · LoG, 当 σ21 ≈ 1.6
import cv2, numpy as np
img = np.zeros((256,256), dtype=np.uint8)
cv2.circle(img, (80,80), 40, 200, -1)
cv2.circle(img, (180,180), 20, 200, -1)
cv2.circle(img, (200,50), 10, 200, -1)
for sigma in [1.0, 2.0, 4.0]:
    blurred = cv2.GaussianBlur(img, (0,0), sigma)
    log = cv2.Laplacian(blurred, cv2.CV_64F) * sigma**2
    print(f"sigma={sigma}: 归一化LoG峰值={np.abs(log).max():.1f}")
k = 1.6
for sigma in [1.0, 2.0, 4.0]:
    g1 = cv2.GaussianBlur(img.astype(np.float64), (0,0), sigma)
    g2 = cv2.GaussianBlur(img.astype(np.float64), (0,0), sigma*k)
    dog = (g2 - g1) * sigma**2
    print(f"DoG sigma={sigma}: peak={np.abs(dog).max():.1f}")

6. 多尺度边缘检测

import cv2, numpy as np
img = np.zeros((256,256), dtype=np.uint8)
cv2.rectangle(img, (30,30), (226,226), 180, -1)
cv2.circle(img, (128,128), 60, 220, -1)
cv2.circle(img, (128,128), 30, 50, -1)
for sigma in [0.5, 1.0, 2.0, 4.0]:
    blurred = cv2.GaussianBlur(img, (0,0), sigma)
    edges = cv2.Canny(blurred, 50, 150)
    print(f"sigma={sigma}: 边缘像素={np.count_nonzero(edges)}")
all_edges = np.zeros_like(img, dtype=np.float64)
for sigma in [0.5, 1.0, 2.0]:
    blurred = cv2.GaussianBlur(img, (0,0), sigma)
    edges = cv2.Canny(blurred, 50, 150).astype(np.float64)
    all_edges += edges
fused = (all_edges > 0).astype(np.uint8) * 255
print(f"多尺度融合边缘像素: {np.count_nonzero(fused)}")
cv2.imwrite('/var/www/ttl/cv/l03_fused.png', fused)

7. 边缘检测实战

import cv2, numpy as np

def edge_pipeline(image, method='canny'):
    if method == 'canny':
        processed = cv2.GaussianBlur(image, (3,3), 1.0)
        otsu = cv2.threshold(processed, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[0]
        edges = cv2.Canny(processed, otsu*0.4, otsu)
    elif method == 'sobel':
        sx = cv2.Sobel(image, cv2.CV_64F, 1, 0, ksize=3)
        sy = cv2.Sobel(image, cv2.CV_64F, 0, 1, ksize=3)
        mag = np.sqrt(sx**2 + sy**2)
        edges = (mag > np.percentile(mag, 90)).astype(np.uint8) * 255
    elif method == 'laplacian':
        processed = cv2.GaussianBlur(image, (5,5), 1.5)
        lap = cv2.Laplacian(processed, cv2.CV_64F, ksize=3)
        edges = (np.abs(lap) > np.percentile(np.abs(lap), 92)).astype(np.uint8) * 255
    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3,3))
    edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel)
    edges = cv2.morphologyEx(edges, cv2.MORPH_OPEN, kernel)
    return edges

img = np.zeros((256,256), dtype=np.uint8)
cv2.rectangle(img, (30,30), (226,226), 180, -1)
cv2.circle(img, (128,128), 60, 220, -1)
cv2.circle(img, (128,128), 30, 50, -1)
for method in ['canny', 'sobel', 'laplacian']:
    edge = edge_pipeline(img, method)
    print(f"{method}: 边缘像素={np.count_nonzero(edge)}")
cv2.imwrite('/var/www/ttl/cv/l03_pipeline.png',
    np.hstack([img, edge_pipeline(img,'canny'), edge_pipeline(img,'sobel'), edge_pipeline(img,'laplacian')]))

🔑 边缘检测核心要点

📝 课后练习

  1. 实现本课所有算法并对比效果
  2. 在不同参数下测试算法的鲁棒性
  3. 将本课方法应用到自己的数据集
  4. 分析算法的计算复杂度和优化方向
延伸阅读:推荐阅读《Digital Image Processing》(Gonzalez)相关章节,以及OpenCV官方文档中的详细API说明。实际项目中,建议先在简单数据上验证算法,再迁移到复杂场景。

📊 方法对比总结

方法优点缺点适用场景
方法A简单高效精度有限快速原型
方法B精度高计算量大离线处理
方法C平衡精度与速度参数调优复杂实际应用

📐 关键公式速查

本课涉及的核心数学公式汇总,方便快速参考:

🔬 深入理解:Canny算法的工程细节

Canny边缘检测看似简单,但每一步都有工程细节值得深入理解:

非极大值抑制(NMS)详解

NMS是Canny中最关键的一步,它将粗边缘细化到单像素宽:

  1. 在每个像素处,根据梯度方向确定两个相邻像素
  2. 如果当前像素的梯度幅值不是这三个中最大的,则抑制为0
  3. 梯度方向量化为4个方向:0°, 45°, 90°, 135°
  4. 结果:边缘从多像素宽变为单像素宽
import cv2, numpy as np

# 手动实现NMS理解原理
img = np.zeros((100,100), dtype=np.uint8)
cv2.rectangle(img, (20,20), (80,80), 200, -1)

# Step 1: 计算梯度
Ix = cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=3)
Iy = cv2.Sobel(img, cv2.CV_64F, 0, 1, ksize=3)
mag = np.sqrt(Ix**2 + Iy**2)
angle = np.arctan2(Iy, Ix) * 180 / np.pi

# Step 2: NMS
def nms(mag, angle):
    h, w = mag.shape
    result = np.zeros_like(mag)
    for i in range(1, h-1):
        for j in range(1, w-1):
            a = angle[i, j]
            # 量化到4个方向
            if (0 <= a < 22.5) or (157.5 <= a <= 180) or (-22.5 <= a < 0) or (-180 <= a < -157.5):
                n1, n2 = mag[i, j+1], mag[i, j-1]
            elif 22.5 <= a < 67.5 or -157.5 <= a < -112.5:
                n1, n2 = mag[i-1, j+1], mag[i+1, j-1]
            elif 67.5 <= a < 112.5 or -112.5 <= a < -67.5:
                n1, n2 = mag[i-1, j], mag[i+1, j]
            else:
                n1, n2 = mag[i-1, j-1], mag[i+1, j+1]
            if mag[i, j] >= n1 and mag[i, j] >= n2:
                result[i, j] = mag[i, j]
    return result

nms_result = nms(mag, angle)
print(f"NMS前非零: {np.count_nonzero(mag > 50)}")
print(f"NMS后非零: {np.count_nonzero(nms_result > 50)}")
print(f"细化率: {1 - np.count_nonzero(nms_result > 50)/max(np.count_nonzero(mag > 50),1):.2%}")

🔬 双阈值滞后跟踪

Canny的第四步是双阈值+滞后跟踪,它解决了弱边缘的连接问题:

强边缘像素: |G| ≥ Thigh → 立即保留
弱边缘像素: Tlow ≤ |G| < Thigh → 仅当与强边缘8-连通时保留
噪声像素: |G| < Tlow → 丢弃
推荐: Thigh = 2×Tlow, 或用Otsu自动确定
import cv2, numpy as np

# 自动Canny阈值
img = np.zeros((200,200), dtype=np.uint8)
cv2.rectangle(img, (40,40), (160,160), 200, -1)
cv2.circle(img, (100,100), 30, 100, -1)
noise = np.random.normal(0, 15, img.shape)
img = np.clip(img + noise, 0, 255).astype(np.uint8)

# Otsu自动阈值
otsu = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[0]
auto_canny = cv2.Canny(img, otsu * 0.5, otsu)
manual_canny = cv2.Canny(img, 50, 150)

auto_edges = np.count_nonzero(auto_canny)
manual_edges = np.count_nonzero(manual_canny)
print(f"自动阈值Canny: {auto_edges} 边缘像素")
print(f"手动阈值Canny: {manual_edges} 边缘像素")
cv2.imwrite('/var/www/ttl/cv/l03_auto_canny.png', np.hstack([auto_canny, manual_canny]))

⚠️ 边缘检测的常见误区

✅ 实机验证

🏆

边缘猎手

你已经掌握了边缘检测的核心知识,继续前进!