边缘,是图像中最有信息量的地方。
一张人像,你闭上眼想象,最先浮现的是什么?轮廓。
Sobel 算子用数学模拟了"快速变化"这件事,让计算机也能找到这些轮廓。
一、什么是图像中的"边缘"
边缘(Edge) = 图像亮度发生急剧变化的地方。
从数学角度看:亮度是关于位置的函数,边缘就是该函数的一阶导数(梯度)大的地方。
均匀区域: ────────────── 亮度变化缓慢,梯度 ≈ 0
阶跃边缘: ──────╔══════ 亮度突变,梯度很大
渐变边缘: ──────╱══════ 亮度线性变化,梯度为常数
例子:
- 白纸上的黑字:纸(亮)→ 字(暗),亮度突变,梯度大 → 边缘
- 平滑的天空:亮度变化缓慢,梯度小 → 无边缘
- 物体的轮廓线:前景(亮)→ 背景(暗),梯度大 → 边缘
二、梯度的数学定义
对于二维图像亮度函数 I(x, y),在某点的梯度是一个向量:
∇I = (∂I/∂x, ∂I/∂y)
梯度大小(边缘强度):|∇I| = √((∂I/∂x)² + (∂I/∂y)²)
梯度方向(边缘方向):θ = arctan(∂I/∂y / ∂I/∂x)
- ∂I/∂x :亮度在 x 方向(水平)的变化率,用于检测竖向边缘
- ∂I/∂y :亮度在 y 方向(垂直)的变化率,用于检测横向边缘
离散图像中的导数近似
连续函数的导数是 lim(Δx→0) [f(x+Δx) - f(x)] / Δx。
在离散像素中,最小的 Δx = 1(一个像素),所以:
一阶差分(最简单的近似):
∂I/∂x ≈ I[x+1, y] - I[x, y]
∂I/∂y ≈ I[x, y+1] - I[x, y]
但这只用了 2 个点,精度不高,对噪声敏感。Sobel 算子用 3×3 的窗口做更鲁棒的近似。
三、Sobel 算子的设计
Sobel 算子用两个 3×3 卷积核分别计算水平和垂直方向的梯度:
Gx 核(检测竖向边缘,测量 x 方向变化)
Gx = ┌ -1 0 1 ┐
│ -2 0 2 │
└ -1 0 1 ┘
解读:
- 左列权重为 -1(或 -2),右列为 +1(或 +2),中列为 0
- 右边亮左边暗:右侧像素 × 正权重 - 左侧像素 × 负权重 = 正值(检测到边缘)
- 右边暗左边亮:结果为负值
- 中列权重为 0:忽略中心列,只看左右差异
- 中间行权重 ×2:给垂直方向中间的像素更大权重(减少方向偏差)
为什么中间行 ×2?
设想只用上下两行:[-1, 0, 1; -1, 0, 1]。这个核对 45° 方向的边缘响应会不准确。Sobel 给中间行双倍权重,等效于先做水平差分再做垂直平均,综合考虑了上下像素的贡献,对任意方向的边缘更鲁棒。
Gy 核(检测横向边缘,测量 y 方向变化)
Gy = ┌ -1 -2 -1 ┐
│ 0 0 0 │
└ 1 2 1 ┘
Gy 是 Gx 的转置:上行负权重,下行正权重,检测上下方向的亮度变化。
四、梯度幅度的计算
计算出 Gx 和 Gy 后,合并为边缘强度:
精确公式(欧氏距离):
G = √(Gx² + Gy²)
快速近似(L1 范数):
G ≈ |Gx| + |Gy|
对比:
| 方法 | 计算量 | 与精确值的误差 |
|---|---|---|
√(Gx² + Gy²) |
平方 + 开方,较慢 | 0% |
| ` | Gx | + |
| `max( | Gx | , |
本框架使用 |Gx| + |Gy| 的快速近似。
五、Sobel 为什么要用 applyGrayscaleRaw
Gx 和 Gy 的值范围是 [-1020, +1020](最大:4 × 255 = 1020)。
如果用标准 Convolution.apply()(截断到 [0, 255]):
- 负梯度(如 Gx = -800)会被截断为 0
- 正梯度(如 Gx = 800)会被截断为 255
取绝对值后:
|截断后的 Gx|=|0|= 0(错误,原本应该是 800)- 所有负梯度信息丢失
正确做法:保留 Float 原始值,再取绝对值:
swift
let gxValues = Convolution.applyGrayscaleRaw(gray, kernel: .sobelX) // Float[]
let gyValues = Convolution.applyGrayscaleRaw(gray, kernel: .sobelY) // Float[]
for i in 0..<total {
let magnitude = min(abs(gxValues[i]) + abs(gyValues[i]), 255)
let m = UInt8(magnitude.rounded()) // 最后才截断到 UInt8
// ...
}
六、完整实现
swift
public struct SobelEdgeFilter: ImageFilter {
public let preBlur: Bool // 是否预先高斯模糊
public func apply(to bitmap: MLBitmap) -> MLBitmap {
// Step 1:灰度化(Sobel 只对亮度操作)
var gray = GrayscaleFilter().apply(to: bitmap)
// Step 2:高斯预模糊(可选)
// 降低噪点对梯度计算的干扰:
// 没有模糊时,单个噪点像素会产生很强的梯度响应
if preBlur {
gray = GaussianBlurFilter(radius: .slight).apply(to: gray)
}
// Step 3:计算 Sobel 梯度
return applySobel(to: gray)
}
private func applySobel(to gray: MLBitmap) -> MLBitmap {
// 获取未截断的浮点梯度(保留负值)
let gxValues = Convolution.applyGrayscaleRaw(gray, kernel: .sobelX)
let gyValues = Convolution.applyGrayscaleRaw(gray, kernel: .sobelY)
var result = gray
let w = gray.width
for y in 0..<gray.height {
for x in 0..<gray.width {
let idx = y * w + x
let magnitude = min(abs(gxValues[idx]) + abs(gyValues[idx]), 255)
let m = UInt8(magnitude.rounded())
let pixelIdx = result.index(x: x, y: y)
result.pixels[pixelIdx] = m
result.pixels[pixelIdx + 1] = m
result.pixels[pixelIdx + 2] = m
// Alpha 不变
}
}
return result
}
}
七、预模糊的作用:噪声 vs 细节
不加预模糊:
- 优点:细节保留更多,细小边缘也能检测到
- 缺点:噪点也会被检测为"边缘",输出有很多噪点
加高斯预模糊(σ≈1.0):
-
优点:抑制噪点,主要边缘更干净
-
缺点:细小边缘可能被模糊掉,漏检
测试验证:
func testPreBlurAffectsOutput() {
// 有噪点的图像,预模糊后边缘更干净
let withBlur = SobelEdgeFilter(preBlur: true).apply(to: noisyBitmap)
let withoutBlur = SobelEdgeFilter(preBlur: false).apply(to: noisyBitmap)
// 两者输出不同,证明 preBlur 确实生效
}
八、Sobel vs 其他边缘检测算法
| 算法 | 核尺寸 | 特点 | 适用场景 |
|---|---|---|---|
| Sobel | 3×3 | 快速,对噪声中等鲁棒 | 通用,本框架实现 |
| Prewitt | 3×3 | 比 Sobel 简单(权重均等),噪声更敏感 | 历史意义,少用 |
| Roberts | 2×2 | 非常快,噪声敏感 | 对角边缘检测 |
| Scharr | 3×3 | 比 Sobel 更精确的旋转不变性 | 需要精确方向的场景 |
| Canny | 多步骤 | 最精确,单像素宽边缘,方向感知 | 工业界黄金标准 |
| Laplacian | 3×3 | 二阶导数,同时检测两个方向 | 但对噪声非常敏感 |
Sobel vs Laplacian(一阶 vs 二阶导数)
Sobel(一阶):
- 计算亮度变化率(梯度)
- 有方向信息(Gx, Gy 分别对应不同方向)
- 对噪声有一定鲁棒性
Laplacian(二阶):
┌ 0 -1 0 ┐
│ -1 4 -1 │ (或 8-connectivity 版本)
└ 0 -1 0 ┘
- 计算变化率的变化率
- 无方向,各向同性
- 对噪声极度敏感(二阶运算放大噪点)
- 实际使用时通常配合高斯 LoG(Laplacian of Gaussian)
Canny 边缘检测(简介)
Canny 是 Sobel 的进化版,包含 4 个步骤:
- 高斯模糊:降噪
- Sobel 梯度:计算每点的梯度大小和方向
- 非极大值抑制(NMS):沿梯度方向,只保留局部极大值点,使边缘变细到 1px 宽
- 双阈值(Hysteresis):用两个阈值 T_high 和 T_low,强边缘(>T_high)保留,弱边缘(T_low~T_high)只有连接到强边缘时才保留
Canny 的结果是单像素宽、精确、完整的边缘线,是 Phase 2 可实现的目标。
九、梯度方向的应用
Gx 和 Gy 不仅给出强度,还给出边缘方向:
方向角 θ = arctan(Gy / Gx)
θ ≈ 0° → 水平边缘(亮度在竖直方向变化)
θ ≈ 90° → 竖向边缘(亮度在水平方向变化)
θ ≈ 45° → 斜向边缘
应用场景:
- Canny 的非极大值抑制需要方向信息
- 方向直方图(HOG,Histogram of Oriented Gradients)是人脸/行人检测的经典特征
十、验证测试
swift
func testSobelDetectsVerticalEdge() {
// 左半黑右半白,垂直边界在 x=10
var bmp = MLBitmap(width: 20, height: 20, filling: .black)
for y in 0..<20 { for x in 10..<20 { bmp[x, y] = .white } }
let result = SobelEdgeFilter(preBlur: false).apply(to: bmp)
// 分界线处(x=9)梯度应明显大于 0
let edgeStrength = Int(result[9, 10].r)
XCTAssertGreaterThan(edgeStrength, 50) // 实际约 255×4=1020,截断为 255
// 纯色区域梯度为 0
XCTAssertEqual(result[2, 10].r, 0) // 黑区,无梯度
XCTAssertEqual(result[17, 10].r, 0) // 白区,无梯度
}
十一、小结
| 概念 | 核心内容 |
|---|---|
| 边缘定义 | 亮度急剧变化的地方 = 梯度大的地方 |
| Sobel 核 | Gx 检测竖向边缘,Gy 检测横向边缘,权重和 = 0 |
| 梯度合并 | `G ≈ |
| applyGrayscaleRaw | 保留 Float 不截断,避免负梯度信息丢失 |
| 预模糊 | 高斯模糊 → Sobel,降噪效果更好 |
| 处理流程 | 原图 → 灰度化 → (模糊)→ Sobel → 边缘图 |
| 进阶算法 | Canny = Sobel + NMS + 双阈值,结果更精确 |
思考题
- 对一张纯色图像(所有像素相同),Sobel 的输出是什么?为什么?(提示:从公式角度思考)
- 如果把 Sobel 核旋转 45°,可以检测斜向边缘吗?试着设计这个核。
- Sobel 输出的边缘图中,白色表示强边缘,黑色表示无边缘。如果要让结果反过来(黑色表示边缘,白色表示背景,更像手绘轮廓线),如何处理?
答案:1. 纯色图所有像素值相同,卷积结果 = 像素值 × 权重和 = 像素值 × 0 = 0,全黑,符合预期;2. 斜向 Sobel(Gabor 滤波器的特例):
[[-2,-1,0],[-1,0,1],[0,1,2]];3. 对输出做颜色反转:pixel = 255 - pixel,这正是ContrastFilter(factor: -1)做的事,或者直接BrightnessFilter(adjustment: -255)后加上 bias。
♥️喜欢我的内容,欢迎大家点赞、转发、关注。
♥️本人专注于技术+投资+认知三位一体的内容分享。
往期推荐: