模糊、锐化、边缘检测、浮雕...... 这些看起来完全不同的效果,底层都是同一个操作:卷积。 理解了卷积,你就掌握了图像处理最核心的工具。
一、从"滑动窗口"理解卷积
想象你拿着一个 3×3 的放大镜,在图像上从左到右、从上到下滑动。每移动到一个位置:
- 放大镜覆盖当前像素周围的 3×3 区域(共 9 个像素)
- 把这 9 个像素值分别乘以放大镜上对应位置的权重
- 把 9 个乘积加起来,得到一个数
- 这个数就是输出图像在该位置的像素值
这个"放大镜"就是卷积核 (Kernel / Filter),这个操作就是卷积。
css
输入图像(局部) 卷积核(3×3)
┌───┬───┬───┐ ┌───┬───┬───┐
│ a │ b │ c │ │ k │ l │ m │
├───┼───┼───┤ ⊛ ├───┼───┼───┤
│ d │ e │ f │ │ n │ o │ p │
├───┼───┼───┤ ├───┼───┼───┤
│ g │ h │ i │ │ q │ r │ s │
└───┴───┴───┘ └───┴───┴───┘
输出 = a·k + b·l + c·m
+ d·n + e·o + f·p
+ g·q + h·r + i·s
二、数学定义
离散 2D 卷积
css
output[x, y] = Σ Σ input[x + i, y + j] · kernel[i, j]
i=-r j=-r
其中 r 是核的半径(3×3 核的 r = 1),求和范围是核的所有位置。
注意 :严格的数学卷积(Convolution)需要把核翻转 180°,而图像处理中实际使用的是相关(Cross-correlation)。
sql
卷积(Convolution): kernel 翻转后再滑动
相关(Cross-correlation):kernel 直接滑动(不翻转)
对于对称核(如高斯核),两者结果相同,所以在图像处理领域,大家习惯性地把相关操作也叫做"卷积"。本框架遵循这个惯例。
三、常见卷积核的原理
均值模糊核(Box Filter)
ini
┌ 1/9 1/9 1/9 ┐
blur3×3 = │ 1/9 1/9 1/9 │
└ 1/9 1/9 1/9 ┘
- 权重全部相等,每个像素值是周围 9 个像素的平均值
- 效果:模糊(每个像素被邻居"平均"掉了,细节丢失)
- 权重之和 = 1:保证亮度不变(不会整体变亮或变暗)
关键:如果权重之和 ≠ 1,输出亮度会变化:
- 权重和 > 1 → 整体变亮
- 权重和 < 1 → 整体变暗
- 权重和 = 0 → 只看差异(Sobel 就是这样)
锐化核(Sharpen)
ini
┌ 0 -1 0 ┐
sharpen3×3 = │ -1 5 -1 │
└ 0 -1 0 ┘
原理:
ini
中心权重 5 = 1 + 4
周边权重 -1(共 4 个)
等价于:原图 × 1 + 拉普拉斯算子 × 1
= 原图 + (原图中心 - 周围平均) × 4
中心像素与周围的差异被放大了,边缘对比更强,看起来更清晰。
权重之和 = 0 + (-1) + 0 + (-1) + 5 + (-1) + 0 + (-1) + 0 = 1,亮度不变。
浮雕核(Emboss)
ini
┌ -2 -1 0 ┐
emboss3×3 = │ -1 1 1 │
└ 0 1 2 ┘
原理:模拟从左上角打来的平行光。
- 左上方权重为负(被"遮挡"的一侧变暗)
- 右下方权重为正(受光一侧变亮)
- 结果:图像呈现出立体浮雕感
权重之和 = 1,亮度基本不变。
Sobel 核(边缘检测,见 Day 8)
ini
┌ -1 0 1 ┐ ┌ -1 -2 -1 ┐
Gx = │ -2 0 2 │ Gy = │ 0 0 0 │
└ -1 0 1 ┘ └ 1 2 1 ┘
权重之和 = 0,纯色区域(无梯度)输出为 0,只有像素值变化的地方有非零输出。
四、边界问题及处理策略
当卷积核处于图像边缘时,核的部分位置会超出图像范围(越界)。
scss
图像:5×5,核:3×3,核半径 = 1
当 x=0, y=0 时:
核需要访问 (-1,-1), (0,-1), (1,-1), (-1,0), (0,0)...
其中 (-1,-1) 等坐标超出图像范围!
三种常见策略
| 策略 | 做法 | 特点 |
|---|---|---|
| Zero Padding(补零) | 越界位置当作 0 | 边缘会变暗,适合某些场景 |
| Clamp(复制边缘) | 越界时取最近有效像素 | 本框架使用,简单且不引入黑边 |
| Mirror(镜像) | 越界时取镜像位置的像素 | 更自然,对称图像效果好 |
Clamp 实现
swift
for ky in 0..<kernel.height {
for kx in 0..<kernel.width {
// clamp:超出边界时取最近的有效坐标
let px = min(max(x + kx - kernel.radiusX, 0), w - 1)
let py = min(max(y + ky - kernel.radiusY, 0), h - 1)
let idx = bitmap.index(x: px, y: py)
sumR += Float(bitmap.pixels[idx]) * kernel.values[ky][kx]
...
}
}
五、卷积核必须是奇数尺寸
为什么?
核半径 r = size / 2(整数除法)。
- 3×3 核:
r = 1,中心在(1, 1),覆盖中心像素 ✅ - 5×5 核:
r = 2,中心在(2, 2)✅ - 4×4 核:
r = 2,但中心实际在(1.5, 1.5)(非整数),无法对齐像素格子!
偶数尺寸核会导致输出图像产生亚像素偏移,在实践中几乎从不使用。
swift
// 本框架在 ConvolutionKernel init 中强制检查
precondition(values.count % 2 == 1, "Kernel height must be odd")
precondition(w % 2 == 1, "Kernel width must be odd")
六、卷积的完整实现
swift
public static func apply(
_ bitmap: MLBitmap,
kernel: ConvolutionKernel,
scale: Float = 1.0,
bias: Float = 0.0
) -> MLBitmap {
let w = bitmap.width
let h = bitmap.height
var result = bitmap // CoW:写入时自动分离
for y in 0..<h {
for x in 0..<w {
var sumR: Float = 0
var sumG: Float = 0
var sumB: Float = 0
for ky in 0..<kernel.height {
for kx in 0..<kernel.width {
let px = min(max(x + kx - kernel.radiusX, 0), w - 1)
let py = min(max(y + ky - kernel.radiusY, 0), h - 1)
let weight = kernel.values[ky][kx]
let idx = bitmap.index(x: px, y: py)
// ⚠️ 始终读 bitmap(原始)而不是 result(输出)
// 避免 in-place 卷积的经典 bug:
// 如果读 result,则当前像素的计算依赖已被修改的邻域
sumR += Float(bitmap.pixels[idx]) * weight
sumG += Float(bitmap.pixels[idx + 1]) * weight
sumB += Float(bitmap.pixels[idx + 2]) * weight
}
}
let i = result.index(x: x, y: y)
result.pixels[i] = UInt8(clamping: Int((sumR * scale + bias).rounded()))
result.pixels[i + 1] = UInt8(clamping: Int((sumG * scale + bias).rounded()))
result.pixels[i + 2] = UInt8(clamping: Int((sumB * scale + bias).rounded()))
}
}
return result
}
scale 和 bias 参数的用途
某些操作需要在卷积后做额外的缩放和偏移:
- Sobel 可视化 :梯度值范围是 -255~+255,无法直接存为 UInt8。通过
bias = 128,把 [-255, 255] 映射到 [-127, 383],再 clamp 到 [0, 255],负梯度显示为暗色,正梯度显示为亮色:
swift
Convolution.apply(bitmap, kernel: .sobelX, bias: 128.0)
- Sobel 计算中间值 :用
applyGrayscaleRaw获取未截断的 Float,保留符号信息,外部做绝对值合并:
swift
// 不截断,用于外部 |Gx| + |Gy| 计算
public static func applyGrayscaleRaw(_ bitmap: MLBitmap, kernel: ConvolutionKernel) -> [Float]
七、计算复杂度
ini
每个像素:需要 kW × kH 次乘加运算
全图:W × H × kW × kH 次运算
3×3 核,100×100 图:100×100×9 = 90,000 次
3×3 核,4K 图(3840×2160):3840×2160×9 ≈ 75,000,000 次
每次运算包含:1 次读像素、1 次乘法、1 次加法
CPU 主频 3 GHz,每秒约 30 亿次简单操作
→ 4K 图单次卷积:约 25ms(纯 CPU,无优化)
对实时视频(30 fps):每帧只有 33ms,单次 3×3 卷积就快耗尽 budget。
这就是为什么后续的需要:
- vImage(SIMD 向量加速,一次处理 16 字节)
- Metal Compute Shader(GPU 并行,数千个像素同时计算)
- 高斯核的可分离性
八、applyGrayscaleRaw 的设计价值
标准 apply 把结果截断到 [0, 255] 存为 UInt8。但有些算法需要负值或超出 255 的中间结果:
- Sobel :
Gx和Gy的值范围是 [-1020, 1020](255 × 最大权重 4) - 拉普拉斯:类似,需要负值
- 如果截断,这些信息会丢失,后续的
|Gx| + |Gy|合并会出错
swift
// 错误流程(截断后合并):
let gx = Convolution.apply(gray, kernel: .sobelX) // 截断到 [0,255]
// 所有负梯度变成了 0,正梯度被截断,信息严重丢失
// 正确流程(保留浮点后合并):
let gxRaw = Convolution.applyGrayscaleRaw(gray, kernel: .sobelX) // Float 数组
let gyRaw = Convolution.applyGrayscaleRaw(gray, kernel: .sobelY) // Float 数组
let magnitude = abs(gxRaw[i]) + abs(gyRaw[i]) // 利用符号信息
九、小结
| 概念 | 核心内容 |
|---|---|
| 卷积本质 | 用权重矩阵对每个像素的邻域做加权求和 |
| 核的设计 | 权重和=1(亮度不变),=0(检测变化),>1(增强) |
| 边界处理 | Clamp(复制边缘),实现简单,无黑边 |
| 奇数尺寸 | 偶数核无法对齐像素中心,必须为奇数 |
| 读写分离 | 永远读原始 bitmap,写独立 result,避免 in-place bug |
| 计算复杂度 | O(W×H×kW×kH),大核 + 大图需要 GPU 加速 |
思考题
- 设计一个 3×3 卷积核,使得输出图像是原图的水平方向平均(每个像素 = 同行左中右三个像素的平均),但垂直方向不变。
- 一个 5×5 核可以等效为两次 3×3 卷积吗?在什么条件下等效?
- 如果要实现"运动模糊"(Motion Blur,模拟相机运动时的拖影效果),卷积核应该长什么样?
答案:1.
[[0,0,0],[1/3,1/3,1/3],[0,0,0]]--- 中间行权重相等,其他行全零;2. 不是所有 5×5 核都能分解为两个 3×3,只有可分离核(rank-1 矩阵)才能;3. 水平运动模糊:[[0,0,0,0,0],[1/5,1/5,1/5,1/5,1/5],[0,0,0,0,0]],一行中 5 个相等权重,模拟水平拖影。
如果这篇对你有一点启发:点个赞,让更多人少踩一个坑 转发给那个正在纠结的人也欢迎关注我------ 我们一起,把认知变成长期复利。
往期推荐: