【图像处理】卷积原理与卷积核——图像处理的核心引擎

模糊、锐化、边缘检测、浮雕...... 这些看起来完全不同的效果,底层都是同一个操作:卷积。 理解了卷积,你就掌握了图像处理最核心的工具。


一、从"滑动窗口"理解卷积

想象你拿着一个 3×3 的放大镜,在图像上从左到右、从上到下滑动。每移动到一个位置:

  1. 放大镜覆盖当前像素周围的 3×3 区域(共 9 个像素)
  2. 把这 9 个像素值分别乘以放大镜上对应位置的权重
  3. 把 9 个乘积加起来,得到一个数
  4. 这个数就是输出图像在该位置的像素值

这个"放大镜"就是卷积核 (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
}

scalebias 参数的用途

某些操作需要在卷积后做额外的缩放和偏移:

  • 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 的中间结果:

  • SobelGxGy 的值范围是 [-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 加速

思考题

  1. 设计一个 3×3 卷积核,使得输出图像是原图的水平方向平均(每个像素 = 同行左中右三个像素的平均),但垂直方向不变。
  2. 一个 5×5 核可以等效为两次 3×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 个相等权重,模拟水平拖影。

如果这篇对你有一点启发:点个赞,让更多人少踩一个坑 转发给那个正在纠结的人也欢迎关注我------ 我们一起,把认知变成长期复利。

往期推荐:

一张图带你了解------卷积到底是什么?

一图了解饱和度:控制色彩鲜艳程度的关键

一图了解OCR的处理流程及相关图像处理技术

一图了解二值化与阈值,从灰度到黑白的决策

一张图了解图像处理中的亮度、对比度与实现

颜色科学与灰度化

从"图片"到"内存"------你真正理解图像处理的第一天

iPhone相册背后的图像处理知识(下)

iPhone相册背后的图像处理知识(中)

iPhone相册背后的图像处理知识(上)

一张图了解图像处理的本质

图像到底是什么

图像处理技术概要图

AI时代,软件工程师必备概念全景图

相关推荐
夜雪闻竹2 小时前
Embedding 模型选型与配置
gpt·开源·embedding·ai编程
小江的记录本2 小时前
【Java基础】核心关键字:final、static、volatile、synchronized、transient(附《思维导图》+《面试高频考点清单》)
java·前端·数据结构·后端·ai·面试·ai编程
怕浪猫3 小时前
AI 3D 大模型创作
aigc·openai·ai编程
孟健3 小时前
我把多 Agent 协作搬进 Hermes Kanban,才发现群聊派活真的不够用了
ai编程
constCpp3 小时前
大模型是怎么“思考”的?
ai编程
用户223586218203 小时前
如何在超大型的工程中使用 Claude Code?
前端·ios·claude
haibindev4 小时前
别让AI再从零写一堆优美的屎山了
c++·ai编程·claude·流媒体·codex·代码复用
小碗细面4 小时前
Agents 编排工具 - OMC 和 Ruflo,到底该怎么选?
ai编程·claude
名不经传的养虾人5 小时前
从0到1:企业级AI项目迭代日记 Vol.28|企业AI的交付不是给工具,而是给搭好的能力
大数据·人工智能·ai编程·ai工作流·企业ai·多agent协作