4、边缘检测(Canny):Canny算法原理与参数调优实战

各位同学,今天我们来聊聊边缘检测里最经典的一个算法——Canny。说实话,我在车载视觉领域摸爬滚打这么多年,Canny 几乎是我每次做车道线检测预处理时都会用到的工具。它不像一些深度学习模型那么“黑盒”,每一步都有清晰的数学逻辑,调参也很有讲究。

嗯,咱们直接进入正题。Canny 算法之所以经典,是因为它解决了三个核心问题:低错误率(尽量少漏检真实边缘)、高定位精度(检测到的边缘要贴近真实位置)、单像素响应(每个边缘只被标记一次)。说白了,就是既要找得准,又要画得细,还不能重复。

4.1 算法原理:四步走

Canny 算法其实就四个步骤,我习惯把它记成“平滑-梯度-NMS-双阈值”。咱们一个一个拆开看。

4.1.1 高斯平滑

第一步,去噪。图像里的噪声,比如传感器热噪声、路面颗粒感,都会在梯度计算时被放大成假边缘。所以先得用高斯滤波器把图像模糊一下。

高斯核的大小很关键。核越大,平滑效果越强,但边缘也会变模糊。我在项目中遇到过一个问题:某次在雨天测试,图像噪声特别大,我用了 5x5 的高斯核,结果车道线边缘被磨得太圆润,后续拟合出来的线歪了。后来我改成 3x3 核,配合后续的双阈值调整,才把问题解决。

我的经验:车载场景下,高斯核大小建议从 3x3 开始试。如果噪声明显(比如夜间),可以试试 5x5,但一定要配合后续的梯度阈值做补偿。

4.1.2 梯度计算

平滑之后,我们要找图像中亮度变化最剧烈的地方。这里用的是 Sobel 算子,分别计算水平方向(Gx)和垂直方向(Gy)的梯度。

梯度幅值公式很简单:G = sqrt(Gx² + Gy²)。方向呢?θ = atan2(Gy, Gx)。嗯,这里要注意,方向是垂直于边缘的,我们后面做非极大值抑制时会用到。

我个人习惯在代码里直接用 OpenCV 的 cv2.Sobel(),但如果你要自己实现,记得把数据类型转成 float,不然计算精度会丢。

import cv2
import numpy as np

# 假设 img 是灰度图
grad_x = cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=3)
grad_y = cv2.Sobel(img, cv2.CV_64F, 0, 1, ksize=3)
magnitude = np.sqrt(grad_x**2 + grad_y**2)
angle = np.arctan2(grad_y, grad_x) * 180 / np.pi
angle = (angle + 180) % 180  # 映射到 0-180 度

4.1.3 非极大值抑制(NMS)

这一步是 Canny 的精髓。你想想看,梯度计算完后,边缘其实是一条“粗”的带子,有很多像素点都算出了高梯度值。NMS 要做的就是:只保留梯度方向上的局部最大值,把其他点抑制掉。

具体怎么做?对于每个像素,沿着它的梯度方向(正反两个方向)看相邻两个像素的梯度幅值。如果当前像素不是最大的,就把它置为 0。这样边缘就变成单像素宽了。

我曾经在调试时发现,如果 NMS 这一步没处理好,车道线边缘会断断续续,像虚线一样。后来检查发现是角度量化时精度不够,把方向分得太粗了。嗯,这里建议把角度量化到 4 个方向(0°、45°、90°、135°)就够了,太细反而容易受噪声干扰。

4.1.4 双阈值检测与边缘连接

最后一步,我们要从 NMS 的结果中筛选出真正的边缘。Canny 用了两个阈值:高阈值(T_high)和低阈值(T_low)。

  • 梯度幅值 > T_high:强边缘,直接保留。
  • 梯度幅值 < T_low:非边缘,直接丢弃。
  • 介于两者之间:弱边缘,只有与强边缘相连时才保留。

这个“相连”的判断很关键。我刚开始做的时候,以为弱边缘只要挨着强边缘就行,结果发现很多噪声点也被连进来了。后来我改成用 8 邻域判断,并且要求弱边缘至少与两个强边缘像素相连,效果才稳定下来。

避坑指南:双阈值的比例通常设为 2:1 或 3:1。比如 T_high=100,T_low=50。但这不是绝对的。我曾经在一条光照不均的隧道里测试,发现高阈值设到 150 才能滤掉阴影,低阈值设到 30 才能保留远处的车道线。所以,调参一定要看实际场景。

4.2 参数调优实战

理论讲完了,咱们来点实际的。Canny 在 OpenCV 里就一行代码:cv2.Canny(img, low_threshold, high_threshold)。但参数怎么调?我总结了一套方法论。

4.2.1 高斯核大小

场景 推荐核大小 说明
白天、光照均匀 3x3 保留细节,边缘清晰
夜间、噪声大 5x5 去噪强,但边缘会变粗
雨天、雾天 7x7 需要强平滑,但后续要降低阈值

4.2.2 双阈值调优策略

我个人的调参习惯是这样的:

  1. 先固定高阈值:用 cv2.Canny(img, 50, 150) 跑一遍,看强边缘是否完整。如果车道线断断续续,说明高阈值太高了,降一点。
  2. 再调低阈值:低阈值决定了弱边缘的保留程度。如果边缘太碎、噪声点多,就把低阈值抬高。如果边缘缺失严重,就降低低阈值。
  3. 最后微调比例:我一般保持高阈值是低阈值的 2-3 倍。如果场景对比度低(比如阴天),我会把比例降到 1.5:1。
实战代码示例:
import cv2

def canny_edge_detection(image, low=50, high=150, kernel_size=3):
    # 1. 高斯平滑
    blurred = cv2.GaussianBlur(image, (kernel_size, kernel_size), 0)
    # 2. Canny 边缘检测
    edges = cv2.Canny(blurred, low, high)
    return edges

# 测试不同参数
img = cv2.imread('lane.jpg', cv2.IMREAD_GRAYSCALE)
edges1 = canny_edge_detection(img, low=30, high=90)   # 低阈值,边缘多
edges2 = canny_edge_detection(img, low=50, high=150)  # 中等阈值
edges3 = canny_edge_detection(img, low=100, high=300) # 高阈值,边缘少

4.2.3 自适应阈值

车载场景光照变化大,固定阈值肯定不行。我推荐用 Otsu 算法自动计算阈值,或者用图像均值和标准差来动态调整。

def auto_canny(image, sigma=0.33):
    # 计算图像中值
    v = np.median(image)
    # 根据中值自动设置阈值
    low = int(max(0, (1.0 - sigma) * v))
    high = int(min(255, (1.0 + sigma) * v))
    return cv2.Canny(image, low, high)

这里的 sigma 参数我一般设 0.33。如果场景对比度低,可以降到 0.2;如果对比度高,可以升到 0.5。嗯,这个值是我在多个数据集上试出来的,你可以根据自己的场景微调。

4.3 总结与思考

Canny 算法虽然经典,但也不是万能的。在车载场景下,它最大的问题是对光照敏感。比如逆光时,车道线边缘会被强光淹没;阴影下,路面的裂缝又会被误检成边缘。

我的建议是:把 Canny 作为预处理的一环,而不是终点。后面我们会讲到霍夫变换、RANSAC 拟合,这些方法能进一步过滤掉噪声边缘,提取出真正的车道线。

好了,这一章的内容就到这里。下一章我们会讲霍夫变换,看看怎么从这些边缘点中找出直线来。到时候我会分享一个我在高速公路上遇到的“幽灵车道线”案例,很有意思。

课后练习:找一张包含车道线的图片,分别用 3x3、5x5、7x7 的高斯核做 Canny 检测,观察边缘的变化。然后调整双阈值,找到一组最适合你图片的参数。试试看,调参的感觉只有亲手做才能体会。