在工业视觉项目里,我经常要回答这么一个问题:这个零件的边缘在哪、尺寸有多少、有没有划伤。而这一切的起点,都是边缘检测。传统计算机视觉(CV)里的 Sobel、Prewitt、Canny 这些名字,看起来已经是老古董,可直到今天,它们依然是产线上运行频率最高的代码。我会从图像梯度讲起,把边缘检测的核心原理、算子选型、参数调优和工程落地中的坑一次讲透。适合刚入门 CV 的工程师,也适合做嵌入式视觉、准备算法面试的人。
1. 边缘检测到底在检测什么:从图像梯度说起
1.1 边缘在图像里的数学本质
边缘是什么呢?直观上讲,边缘就是图像里灰度发生剧烈变化的地方。放大了看,一个物体和背景之间,像素值从一个平均亮度跳到另一个平均亮度,中间的过渡带就是我们说的边缘。对一维信号来说,突变位置的导数会很大;推广到二维图像,就需要用一个叫梯度的向量来描述每个像素附近的变化速度和方向。
具体来说,假设图像是一个连续函数 f(x,y),它在某一点的梯度可以写成 (∂f/∂x, ∂f/∂y)。梯度幅值 sqrt(Gx²+Gy²) 告诉我们这里变化有多陡,梯度方向 atan2(Gy,Gx) 指向灰度增大得最快的方向。真正有意思的是,边缘本身的方向和梯度方向是垂直的。这个关系非常重要——后续的 Sobel、Canny 都要用到,非极大值抑制更是完全依赖梯度方向来定位边缘。
数字图像不可能做真正的微分,所以只能拿像素差分来近似。水平差分就是右边的灰度减左边灰度,垂直差分就是下边灰度减上边灰度。那些 3x3 的卷积核,本质都是对差分结果再加一层平滑滤波,目的是抑制噪声的同时保留边缘响应。可以这么理解:Sobel、Prewitt、Roberts 这些算子,全是在不同权重下对梯度做近似估计,实际效果上的差异也主要来自这个“平滑”策略。
1.2 为什么传统方法至今仍不过时
很多人一听到传统 CV 就觉得过时了,尤其深度学习出来之后,HED、RCF 这类边缘检测网络效果确实惊艳。但在实际产线上,传统方法没有被淘汰,原因很简单:它不需要训练数据、不需要 GPU,一个几十块钱的嵌入式板子就能跑,而且每个输出像素的含义清楚,出了问题能快速排查。深度学习模型在边缘检测上表现好,可一旦换了光照、换了产品型号,往往要重新标注和训练,这在现场是很大的成本。
传统边缘检测更大的价值是当预处理。比如在一个 PCB 检测项目里,我并没有直接上模型,而是先用 Canny 把线路边缘提取出来,再做霍夫直线检测,找出断线和短接区域。整个流程里边缘检测是基石,如果边缘提取得干净,后面所有几何分析都会很稳定。所以这篇文章想强调的是:先把传统边缘检测玩明白,再上深度学习,你会有完全不同的理解。
2. 经典梯度算子:原理、代码与适用场景
2.1 Roberts算子:简单但不推荐单独使用
Roberts 算子是最早的一批边缘检测算子之一,它只用 2x2 邻域做对角差分。两个卷积核分别是 [1,0;0,-1] 和 [0,1;-1,0],实际效果是求 45 度和 135 度方向的灰度差。因为核比较小,计算量非常低,在当时的硬件条件下是很有吸引力的选择。但它的缺点也很致命:没有平滑项,对噪声极度敏感,一个孤立噪点就会产生很强的伪边缘,而且轮廓定位也很粗糙。
我在实际项目中很少单独用它,除非是做一些计算资源被压到极限的简单定位任务。多数情况下,它更适合用来理解“差分”这个概念。如果你真要在 OpenCV 里跑 Roberts,直接用 filter2D 就行,代码也就几行:
import cv2 import numpy as np img = cv2.imread('part.png', cv2.IMREAD_GRAYSCALE) kernel1 = np.array([[1, 0], [0, -1]], dtype=np.float32) kernel2 = np.array([[0, 1], [-1, 0]], dtype=np.float32) g1 = cv2.filter2D(img, cv2.CV_32F, kernel1) g2 = cv2.filter2D(img, cv2.CV_32F, kernel2) mag = cv2.magnitude(g1, g2)2.2 Prewitt算子:均值平滑的代价
Prewitt 算子升级成了 3x3 核。水平方向的核是把左右两列灰度差后,再沿垂直方向做三行均值;垂直核类似。这个均值操作带来了一点抗噪能力,代价是边缘被平滑,定位精度受到影响。OpenCV 没有直接提供 Prewitt,但可以通过 filter2D 调用,分别设置 x 一阶导、y 一阶导。
kernel_x = np.array([[-1, 0, 1], [-1, 0, 1], [-1, 0, 1]], dtype=np.float32) kernel_y = np.array([[-1, -1, -1], [0, 0, 0], [1, 1, 1]], dtype=np.float32) gx = cv2.filter2D(img, cv2.CV_32F, kernel_x) gy = cv2.filter2D(img, cv2.CV_32F, kernel_y) mag = cv2.magnitude(gx, gy)代码里我用滤波器做卷积,输入灰度图,输出 CV_32F,最后用 magnitude 求梯度幅值。Prewitt 对方向性较强的横竖边缘表现尚可,但对斜向边缘响应不均匀。如果你的图片纹理细密,Prewitt 很容易把纹理也一起响应出来,因为它的平滑太弱。我之前处理晶圆表面杂质图像时,用 Prewitt 能检测划痕,但背景颗粒噪声也很多,最后还是换成了 Sobel。
2.3 Sobel/Scharr:更稳的梯度估计
Sobel 是目前最常用的梯度算子。它的水平核是 [-1,0,1;-2,0,2;-1,0,1],垂直核是 [-1,-2,-1;0,0,0;1,2,1]。和 Prewitt 相比,中间行或列的权重是 2,这个权重近似于高斯平滑的系数。权重越高,对行方向的噪声抑制越强,同时不会像均值那样把边缘完全抹平,这是 Sobel 在实际项目中更稳的原因。
OpenCV 里可以直接用 cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3) 得到水平梯度,cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3) 得到垂直梯度,然后求幅值:
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3) gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3) mag = cv2.magnitude(gx, gy)如果对实时性要求很高,可以用 abs(gx)+abs(gy) 近似平方根,嵌入式上能省不少时间。另外,Sobel 的旋转对称性不算好,45 度斜边响应会弱一些。OpenCV 还提供了 Scharr 算子,本质是对 Sobel 的进一步加权,在 3x3 核下比 Sobel 更对称、响应更强。如果检测场景没有固定方向,用 Scharr 通常比 Sobel 更稳。
| 算子 | 核大小 | 抗噪能力 | 定位精度 | 计算量 | OpenCV支持 |
|---|---|---|---|---|---|
| Roberts | 2x2 | 差 | 一般 | 最小 | 需filter2D |
| Prewitt | 3x3 | 一般 | 一般 | 低 | 需filter2D |
| Sobel | 3x3/5x5 | 较好 | 较好 | 低 | 内置 |
| Scharr | 3x3 | 较好 | 好 | 低 | 内置 |
2.4 方向性边缘检测的选型心得
看到这里你可能已经明白了:不同算子其实就是不同卷积核的选择,对边缘的响应各有侧重。如果业务场景是检测水平的划痕,直接取 Gy 的绝对值就能出结果;如果是检测垂直方向的裂缝,取 Gx 更直接。但大多数场景我们不知道边缘方向,那就要用梯度幅值,而不是只取单方向的响应。
我个人的选型习惯是:快速原型用 Sobel,需要更好方向鲁棒性换 Scharr;Prewitt 只有在论文复现或者需要让学生理解均值平滑时才会用;Roberts 几乎不用于正式项目。在评审方案时,光靠梯度幅值往往不够,还要看梯度幅值的直方图分布,判断边缘响应和噪声响应的可分性。如果两者混在一起,就说明图像质量或光照有问题,需要先做预处理,而不是盲目调算子。
3. 二阶导数与 Laplacian/LoG:边缘在零点上
3.1 为什么需要二阶导数
一阶梯度找的是变化率最大的位置,也就是梯度幅值的峰值。峰值虽然直观,但在受噪声影响时很难定阈值——到底多高的幅值才算边缘?换个角度看,二阶导数在边缘处表现为过零(zero crossing),也就是从正到负或从负到正的零点。用零交叉判定边缘,理论上不依赖全局阈值,所以有它独特的优势。
最简单的二阶导数是 Laplacian 算子,核就是上下左右中心权重 4 或者 -4,有些版本还带上对角项。它对图像中的孤立点响应非常强,一个亮点或者灰尘就能产生巨大的响应,所以很少有人直接拿 Laplacian 当边缘图用。通常会把高斯平滑和 Laplacian 合在一起,这就是 LoG,也就是 Laplacian of Gaussian。
3.2 LoG 算子和 DoG 近似
LoG 的原理是:先对图像做高斯滤波,再做拉普拉斯运算。因为卷积操作可以交换顺序,所以可以预先计算一个 LoG 卷积核来做一次滤波。实际写代码时,我更倾向于直接调用高斯模糊加 Laplacian,方便调参。对于大尺度边缘,可以直接用两张不同 sigma 的高斯图相减,得到 DoG,效果和 LoG 非常接近,但计算更快。
# LoG方式 blur = cv2.GaussianBlur(gray, (0, 0), sigma) log = cv2.Laplacian(blur, cv2.CV_32F) # DoG方式 blur1 = cv2.GaussianBlur(gray, (0, 0), sigma) blur2 = cv2.GaussianBlur(gray, (0, 0), sigma * 1.6) dog = blur1 - blur2sigma 的选择直接影响边缘尺度:小 sigma 能捕捉细边缘,但噪声多;大 sigma 平滑强,边缘变粗,定位也会偏移。实际项目中我常用 sigma=1.0 到 1.5。要得到最终单像素边缘,还需要对结果找零交叉,这一步比阈值复杂,通常可以用符号变化来定位:遍历每个像素,如果相邻像素的 LoG 响应从正跳到负,就认为这里存在边缘。
3.3 什么时候优先用 LoG
Canny 很强,但 LoG 有一个独特优势:它对闭合边缘的提取更自然,因为零交叉天然会生成闭环,不需要额外的滞后连接。所以在细胞检测、气泡检测这类需要完整区域边界的场景,LoG 往往比 Canny 更能避免边缘断裂。代价是 LoG 输出的是双边缘,也就是在真实边界两侧各有一条响应线,需要结合符号变化和连通域处理才能变成单线。
我的经验是,自然图像先试 Canny;医学图像、材料断口、蜂窝结构这类目标边界封闭性要求高的场景,再试 LoG。如果 LoG 结果还是不连续,多半是 sigma 取得太小,或者图像本身纹理太杂,不要继续在算子层面抠,应该回到图像采集端改善光照。
4. Canny算子全流程解析与参数调优实战
4.1 Canny为什么是标准答案
Canny 是经典边缘检测里综合表现最好的算法,也是我在项目里最常用的。它并不是单一算子,而是一整套流程:先用高斯平滑减少噪声,用 Sobel 计算梯度幅值和方向;然后做非极大值抑制,把宽边缘压成单像素;再用双阈值把像素分成强、弱两类;最后通过滞后连接让弱边缘只有在与强边缘连通时才被保留。
这里的核心思想是:边缘应该是细的、连续的,而且真正的边缘周围通常有强响应。非极大值抑制保证边缘宽度为 1 像素;双阈值加滞后连接能够过滤掉孤立的弱响应,同时保留真实但较弱的边缘。这也是 Canny 在复杂场景下比单纯阈值好用的原因。很多初学者只看 OpenCV 的一行调用,不知道它内部在做什么,结果调参时一头雾水。
4.2 关键参数怎么选
参数是 Canny 最容易翻车的地方。第一个是高斯 sigma。sigma 越大,平滑越强,细小纹理被抹掉,但真正的边缘也会被磨圆,定位精度下降。一般我取 0.8 到 1.5,噪声明显的场景取 2.0,但不要超过 3.0,否则边缘偏移就很明显了。第二个是高低阈值。OpenCV 的 Canny 接收 low 和 high 两个参数,high 决定哪些像素一定是边缘,low 决定哪些像素可能是边缘并参与连通。经验上 high 取梯度幅值直方图的 80 分位附近,low 取 high 的 0.4 到 0.6 倍。
如果图像整体对比度均匀,我会直接用固定阈值,比如 50/120 或 60/150。如果光照经常波动,最好根据图像自动估计。一个简单的方法是用 Otsu 计算全局阈值,把它作为 high,然后 low=high*0.5。虽然 Otsu 不是专门给边缘设计的,但在多数光照变化场景下足够用。还有一些项目会统计梯度幅值的均值和标准差,用均值的固定倍数作阈值,效果也还行。
def auto_canny(gray, sigma=0.33): v = np.median(gray) low = int(max(0, (1.0 - sigma) * v)) high = int(min(255, (1.0 + sigma) * v)) return cv2.Canny(gray, low, high)这个 median 法在很多开源项目里流传很广,但它默认背景的灰度中值接近噪声水平。如果图像背景大且暗,high 可能不够,需要根据实际边缘图再调整。我一般只把它当作初始值,不会直接上线。
4.3 实战:芯片引脚边缘提取
拿一个手机主板的芯片引脚检测来举例。原图光照不均匀,背部有阴影。如果直接 Canny(50,150),引脚边缘是出来了,但阴影区域出现一大片伪边缘。我先裁剪 ROI,只保留芯片区域,然后用 GaussianBlur sigma=1.2,再用 Canny(50,120)。这时候边缘还有一些断点,尤其是引脚拐角处。我用 cv2.morphologyEx(edges, MORPH_CLOSE, kernel) 做一个闭运算,把断裂连接的断点补上。
随后 cv2.findContours 找到所有轮廓,用 cv2.minAreaRect 得到每个引脚的旋转外接矩形,再根据矩形的长宽比过滤掉杂碎边缘。整个流程跑下来,从图像输入到输出引脚间距,单张图耗时不到 20 毫秒,放在产线上完全够用。这个方法没有使用任何深度学习模型,稳定性和可解释性都很好。Canny 参数也写进了配置文件,方便不同机型切换。
4.4 参数调试的具体技巧
调试 Canny 时,我的建议是不要凭感觉。先把灰度图显示出来,看直方图判断对比度;然后固定 sigma 和 low/high 的比例,只调 high;每改一次参数就把边缘图叠加在原图上截图,用速度测试确认是否过了头。可以写一个简单的 trackbar 窗口,实时调 sigma、low、high 三个参数,这样能快速找到当前场景的合适范围。
另外一个经验:如果想要的边缘是长直线,调参的目标是“连续”;如果想要的边缘是细小划痕,调参的目标是“不误检”。这两种目标的逻辑相反。连续优先就降低 low,做闭运算;误检优先就提高 high,增大 sigma。这个取舍在项目评审时最好提前确认好,不然会被反复要求调来调去。
5. 工程落地中的常见坑与排查技巧
5.1 噪声让边缘图变成“芝麻饼”
最常见的现象是边缘图里面一堆小碎片,像是撒了一把芝麻。原因一般不是 Canny 阈值太低,而是输入噪声太大。很多相机在低照度下亮部和暗部噪声差异很大,一个 sigma=1 的高斯根本压不住。这时候先别急着提高 Canny 阈值,应该先观察灰度图本身的信噪比。我会拍一张纯色标定板,看灰度标准差;如果标准差超过 5 个灰度级,就要考虑改曝光或者在算法里加强平滑。
对于椒盐噪声这类极值点,高斯滤波不如中值滤波。中值滤波在去除孤立噪点的同时能保留边缘锐度,缺点是计算量稍大,而且会稍微破坏图像细节。我一般会做一个判断:如果边缘图中出现大量 1-2 像素的孤立点,优先试用中值滤波而不是高斯,往往效果立竿见影。
5.2 边缘断裂与双线问题
边缘断裂是最常见的问题,明明眼睛能看到的轮廓,算法输出就是一段一段的。处理顺序是:先降低低阈值,看看是不是弱边缘被过滤;如果还断,再加大高斯 sigma 让边缘响应更连续;最后用形态学闭运算。但要注意,闭运算会把相邻很近的两条边缘连成一块,所以核不能大,一般 3x3 就够。
另一种问题是边缘变成了两条平行线。这通常发生在梯度变化是一个缓坡而不是陡变时,Sobel 响应会有两个峰。Canny 的非极大值抑制已经能处理部分情况,但如果平滑太大,双峰之间的距离被拉大,非极大值抑制也无法完全合并。解决办法是适当地减小 sigma,或者考虑换用一阶梯度算子的绝对值中心位置来定位边缘。
5.3 光照不均匀下的伪影
比较难处理的是明暗不均。比如一块金属表面一半亮一半暗,直接 Sobel 阈值化后,暗区的真实边缘响应可能和亮区的噪声差不多。对这种场景,我会先做预处理:用顶帽和底帽操作估计光照背影,然后从原图减去,得到校正后的图;或者使用 CLAHE 做局部对比度增强。预处理后再做边缘检测,稳定性会好很多。
另外,如果一个图里同时有强边缘和弱边缘,阈值很难同时满足两个区域。这时可以把图像分成多个 ROI,分别设置不同的阈值参数。分开处理虽然代码多一点,但在工业现场这个小技巧非常好用,尤其适合手机外观检测这类要求很高的场景。
5.4 性能优化:从几十毫秒压到几毫秒
边缘检测本身计算量不大,但为了跑在一堆算法前面,还是要抠。第一,能降采样就降采样。很多项目不需要 4K 全分辨率,直接降到 1/2 或 1/4 后边缘质量依旧达标。第二,避免重复分配内存。OpenCV 的 Canny 内部有分配,可以在循环外创建 edges 图,或者用 cv::UMat 减少 PC 上的拷贝。第三,能用 Sobel 的 ksize=1 或 3,就不要用 5,ksize 越大卷积耗时越高。
在嵌入式端,梯度幅值计算最费时间的是平方根和 atan2。我常用 abs(gx)+abs(gy) 代替 magnitude,如果还要用方向做非极大值抑制,就把 atan2 结果量化到 0-3 四个方向区间。Canny 里梯度方向其实只需要四个邻居区域,完全可以用整数比较实现。这些优化做好了,边缘检测在一张 VGA 图上能跑到 1ms 以内。
6. 边缘检测的组合拳与个人经验
6.1 边缘 + 轮廓:从像素到几何的桥
边缘检测输出的是二值图,还不是工程能用的几何信息。所以我在实际项目里几乎不会只看边缘图,而是会把 Canny 结果交给轮廓分析。OpenCV 的 cv::findContours 能提取所有连通区域的边界,返回的是点集。之后我会用 approxPolyDP 做多边形逼近,用 minAreaRect 求旋转外接矩形,用 minEnclosingCircle 拟合圆。需要判断某个点是否落在目标区域内时,直接调 cv::pointPolygonTest,返回值为正表示在内部,负表示在外部,0 表示在边界上。
这一套组合拳在零件定位项目里非常常用。比如螺丝垫片检测,Canny 出边缘,findContours 得到外轮廓,然后用 minEnclosingCircle 求出圆心和半径,再根据半径筛选。整个过程可解释、可调试,现场工程师只要看着轮廓叠加图就能判断算法哪里出了问题。
6.2 我常备的调参套路
做边缘检测项目多了,我自己总结了一套快速套路。第一步,在原始灰度图上做直方图,确认对比度是否足够。如果对比度很低,先做线性拉伸或者 CLAHE 再去检测。第二步,用一个固定组合,比如 sigma=1.0、low=50、high=150,快速看看边缘图。第三步,根据问题做最小调整:噪声多就加 sigma;断裂就降 low;边缘粗就减 sigma 或提高 high。不要同时动三个参数,否则你根本不知道是哪个变量起到了作用。
另外,每改一个参数都记录环境图和截图,最好用脚本把参数、截图、耗时一起存下来。因为现场环境一变,你可能需要回看之前哪组参数最稳。这不是形式化,小项目用 Excel 表也行,关键时候能救命。
6.3 亚像素边缘检测:测量场景的下一步
如果要做的不是看边缘形状,而是精密测量,像素级边界是不够的。一个边缘跨越若干像素,真正的物理边界可能在亚像素位置。传统 CV 里有专门做这个的技术:取梯度幅值峰附近几个点的灰度,然后用抛物线拟合或者灰度矩估计峰值的位置,就能把边缘定位到 0.1 像素甚至更高。Halcon 里的亚像素轮廓就是这么做的。这种方法不需要 GPU,只要在 ROI 附近逐行处理,速度也很快。
我踩过的坑是,亚像素拟合之前必须保证边缘没有靠太近,两条边缘距离小于 5 个像素时拟合结果会出现偏移。这种场景下我会先用 Canny 检测间距,再做亚像素细分,而不是对所有边缘都做拟合。
6.4 传统与深度学习协同
最后分享一个我最近常用的思路:不要非此即彼。在标注数据不足时,先用 Canny 生成粗边缘,叠加在原图上,帮助标注人员快速勾勒;再用标注好的结果训练一个轻量语义分割模型。模型的 mask 输出之后,再用 Canny 提取边界,送入后续几何测量模块。这样传统算法的稳定性和深度模型的泛化能力各取所长,项目上线速度也会更快。
我个人的体会是,传统边缘检测不是一个过时的知识点,而是很多视觉系统的地基。先把地基打牢,再上深度学习,你会发现在定义损失函数、处理 mask 边界时都更有底气。如果你正在做视觉项目,不管最后用不用深度学习,先把 Sobel、Canny 和 LoG 的原理吃透,绝对是值得投入的功课。