普通视图

发现新文章,点击刷新页面。
昨天以前首页

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

作者 FIRE_Lee
2026年5月20日 16:33

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


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

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

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

这个"放大镜"就是卷积核(Kernel / Filter),这个操作就是卷积

输入图像(局部)        卷积核(3×3)
┌───┬───┬───┐        ┌───┬───┬───┐
│ ab │ 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 卷积

output[x, y] = Σ  Σ  input[x + i, y + j] · kernel[i, j]
               i=-r j=-r

其中 r 是核的半径(3×3 核的 r = 1),求和范围是核的所有位置。

注意:严格的数学卷积(Convolution)需要把核翻转 180°,而图像处理中实际使用的是相关(Cross-correlation)

卷积(Convolution):     kernel 翻转后再滑动
相关(Cross-correlation):kernel 直接滑动(不翻转)

对于对称核(如高斯核),两者结果相同,所以在图像处理领域,大家习惯性地把相关操作也叫做"卷积"。本框架遵循这个惯例。


三、常见卷积核的原理

均值模糊核(Box Filter)

           ┌ 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)

             ┌  0  -1   0 ┐
sharpen3×3 = │ -1   5  -1 │
             └  0  -1   0 ┘

原理

中心权重 5 = 1 + 4
周边权重 -1(共 4 个)

等价于:原图 × 1 + 拉普拉斯算子 × 1
      = 原图 + (原图中心 - 周围平均) × 4

中心像素与周围的差异被放大了,边缘对比更强,看起来更清晰。

权重之和 = 0 + (-1) + 0 + (-1) + 5 + (-1) + 0 + (-1) + 0 = 1,亮度不变。

浮雕核(Emboss)

           ┌ -2  -1   0 ┐
emboss3×3 = │ -1   1   1 │
             └  0   1   2 ┘

原理:模拟从左上角打来的平行光。

  • 左上方权重为负(被"遮挡"的一侧变暗)
  • 右下方权重为正(受光一侧变亮)
  • 结果:图像呈现出立体浮雕感

权重之和 = 1,亮度基本不变。

Sobel 核(边缘检测,见 Day 8)

      ┌ -1   0   1 ┐          ┌ -1  -2  -1 ┐
Gx = │ -2   0   2 │    Gy = │  0   0   0 │
      └ -1   0   1 ┘          └  1   2   1 ┘

权重之和 = 0,纯色区域(无梯度)输出为 0,只有像素值变化的地方有非零输出。


四、边界问题及处理策略

当卷积核处于图像边缘时,核的部分位置会超出图像范围(越界)。

图像: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 实现

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)(非整数),无法对齐像素格子!

偶数尺寸核会导致输出图像产生亚像素偏移,在实践中几乎从不使用。

// 本框架在 ConvolutionKernel init 中强制检查
precondition(values.count % 2 == 1, "Kernel height must be odd")
precondition(w % 2 == 1, "Kernel width must be odd")

六、卷积的完整实现

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],负梯度显示为暗色,正梯度显示为亮色:
Convolution.apply(bitmap, kernel: .sobelX, bias: 128.0)
  • Sobel 计算中间值:用 applyGrayscaleRaw 获取未截断的 Float,保留符号信息,外部做绝对值合并:
// 不截断,用于外部 |Gx| + |Gy| 计算
public static func applyGrayscaleRaw(_ bitmap: MLBitmap, kernel: ConvolutionKernel) -> [Float]

七、计算复杂度

每个像素:需要 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| 合并会出错
// 错误流程(截断后合并):
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时代,软件工程师必备概念全景图

【图像处理】饱和度——颜色的浓淡与灰度化

作者 FIRE_Lee
2026年5月18日 16:32

饱和度为 0,图像变成灰色。 饱和度为 1,颜色恢复原样。 看似简单的一个滑块,背后是颜色空间的混合运算—— 而"直接灰度化"并不总是最好的选择。


一、饱和度的直觉

在 HSB(或 HSL)色彩模型里,颜色由三个维度描述:

H(Hue,色相)       → 颜色的种类(红/绿/蓝/黄…)
S(Saturation,饱和度)→ 颜色的浓淡(鲜艳 ↔ 灰白)
B(Brightness,亮度) → 颜色的明暗(亮 ↔ 暗)

调整饱和度,就是在"原始颜色"和"同亮度的灰色"之间做插值:

饱和度 = 1.0:鲜艳的红色  (255, 0, 0)
饱和度 = 0.5:粉灰色      (191, 128, 128)  ← 向灰色靠近 50%
饱和度 = 0.0:纯灰色      (128, 128, 128)  ← 完全变成灰色

二、数学:灰度化 + 插值

CIColorControlsinputSaturation 本质上是两步:

Step 1:计算该像素对应的灰度值

gray = 0.299 × R + 0.587 × G + 0.114 × B

这是 Rec.601 亮度公式(Day 3 详细讲过),权重来自人眼对红绿蓝的感知灵敏度差异。

Step 2:在原色和灰度之间线性插值

R' = gray × (1 - s) + R × s
G' = gray × (1 - s) + G × s
B' = gray × (1 - s) + B × s

其中 s = inputSaturation(0.0 ~ 1.0)

验证:

  • s = 1.0:输出 = 原始 RGB(完全保留颜色)
  • s = 0.0:输出 = (gray, gray, gray)(完全灰度化)
  • s = 0.5:输出 = 原始和灰度各占一半

具体计算示例

鲜红色 (255, 0, 0),计算 s = 0.3

gray = 0.299 × 255 + 0.587 × 0 + 0.114 × 0 = 76.276

R' = 76 × 0.7 + 255 × 0.3 = 53.2 + 76.5 = 129.7 ≈ 130
G' = 76 × 0.7 +   0 × 0.3 = 53.2 +  0.0 =  53.253
B' = 76 × 0.7 +   0 × 0.3 = 53.2 +  0.0 =  53.2 ≈  53

s=0.3 后的颜色:(130, 53, 53) ← 带点红色调的暗灰

三、完全灰度化(s=0.0)并不总是最好的

初看之下,OCR 场景应该直接灰度化:数字本身是无色的,颜色只是干扰。

但实测有两个副作用:

3.1 Vision 丢失颜色线索

Vision OCR 是多模态模型,训练时见过大量彩色图像。颜色是它区分相似形状的辅助信号之一:

例:深蓝背景上的白色 LOGO 文字
  彩色图:深蓝(30, 80, 180) vs 白色(255, 255, 255) → 颜色对比强,Vision 轻松分割
  灰度图:亮度 ≈ 0.25 vs 亮度 ≈ 1.0 → 亮度对比也够,没问题

例:金色数字在米白色背景上
  彩色图:金色(255, 215, 0) vs 米白(255, 250, 220) → 颜色略有差异,Vision 可感知
  灰度图:金色亮度 ≈ 0.81 vs 米白亮度 ≈ 0.97 → 亮度差仅 0.16,对比度很弱!

结论:当颜色对比强于亮度对比时,降饱和会损失有效信息。

3.2 特定颜色组合在灰度下消失

问题场景:橙色数字在青色背景上

橙色  RGB(255, 140, 0):
  gray = 0.299×255 + 0.587×140 + 0.114×0 = 76.2 + 82.2 = 158

青色  RGB(0, 200, 200):
  gray = 0.299×0 + 0.587×200 + 0.114×200 = 0 + 117.4 + 22.8 = 140

灰度对比度 = |158 - 140| / 255 ≈ 7%   ← 几乎看不见!

但彩色对比度(色相完全不同):非常鲜明

这就是为什么银行卡预处理不能无脑灰度化:不同颜色组合需要不同策略。


四、各场景的 saturation 策略

s = 1.0   完全保留颜色
  适用:彩色背景对 OCR 有益,或颜色是区分字符的关键线索
  例:深色底变体(不降饱和,颜色通道提供额外边缘信息)

s = 0.7   轻度降饱和
  适用:颜色有一定干扰,但不希望完全失去颜色线索
  例:全卡自适应预处理的基线值(正常/低对比度场景)

s = 0.3   保留少量颜色
  适用:背景颜色复杂,但担心某些卡面的颜色对比消失
  例:ROI 浅底提亮灰变体(在大 radius USM 之后,颜色已大幅弱化,保留 30% 兜底)

s = 0.0   完全灰度化
  适用:已确认亮度对比足够强,颜色只是噪声
  例:ROI 背景减除变体(大 radius USM 已经消除低频背景,颜色无意义)
       ROI 超对比灰变体(高 contrast 专门压暗处理,颜色干扰大于收益)

五、saturation 与 contrast 的联动

降低饱和度会改变有效亮度对比度,两者需要协调。

5.1 降饱和降低了可用对比度

原始颜色对比:
  橙色 (255,140,0)  亮度 0.62
  青色 (0,200,200)  亮度 0.55
  颜色对比很明显,亮度对比只有 0.07

s=0.0 灰度化后:
  橙色 灰度 0.62
  青色 灰度 0.55
  仅靠亮度,对比度只有 7%,需要 contrast 补偿
  
  contrast 需要多高?理论上需要 1/0.07 ≈ 14 才能将对比拉至全范围
  实际上 contrast 最多用到 2.0,因此这种组合用灰度化会失败

5.2 contrast 依赖饱和度处理后的亮度分布

推荐处理顺序:
  CIColorControls(saturation → contrast → brightness)

这三个参数在同一个 CIFilter 里,同时生效,内部顺序由 Core Image 决定。
实际等效于:先降饱和(得到亮度均等的图),再拉伸亮度分布。

5.3 选择 saturation 值的决策树

开始
  │
  ├─ 背景是否为复杂图案(风景/纹理/渐变)?
  │   ├─ 是 → 用 CIUnsharpMask 大 radius(25px)消背景 → s=0.0 灰度化
  │   └─ 否 ↓
  │
  ├─ 数字颜色是否与背景亮度接近(亮度差 < 0.2)?
  │   ├─ 是 → 颜色对比是主要线索 → s=0.7~1.0,同时提高 contrast
  │   └─ 否 ↓
  │
  ├─ 背景颜色是否鲜艳(高饱和)?
  │   ├─ 是 → 颜色干扰 Vision → s=0.0~0.3 降饱和
  │   └─ 否 ↓
  │
  └─ 默认:s=0.7(保留颜色线索,轻度降低颜色干扰)

六、Swift 实现

手动实现(适合 MLBitmap)

public struct SaturationFilter: ImageFilter {

    /// 饱和度系数,0.0 = 完全灰度化,1.0 = 原色
    public let saturation: Float

    public func apply(to bitmap: MLBitmap) -> MLBitmap {
        var result = bitmap
        for i in stride(from: 0, to: result.pixels.count, by: 4) {
            let r = Float(result.pixels[i])
            let g = Float(result.pixels[i + 1])
            let b = Float(result.pixels[i + 2])
            // Rec.601 亮度
            let gray = 0.299 * r + 0.587 * g + 0.114 * b
            // 在原色和灰度之间插值
            result.pixels[i]     = UInt8(clamping: Int(gray * (1 - saturation) + r * saturation))
            result.pixels[i + 1] = UInt8(clamping: Int(gray * (1 - saturation) + g * saturation))
            result.pixels[i + 2] = UInt8(clamping: Int(gray * (1 - saturation) + b * saturation))
            // i + 3 = Alpha,不变
        }
        return result
    }
}

通过 CIColorControls(适合 CIImage 管线)

func adjustSaturation(_ cgImage: CGImage, saturation: Float) -> CGImage? {
    let input = CIImage(cgImage: cgImage)
    guard let filter = CIFilter(name: "CIColorControls") else { return nil }
    filter.setValue(input,      forKey: kCIInputImageKey)
    filter.setValue(saturation, forKey: kCIInputSaturationKey)
    // contrast / brightness 不传时保持默认(1.0 / 0.0)
    return filter.outputImage.flatMap { context.createCGImage($0, from: $0.extent) }
}

两种方式等效,CIColorControls 在 GPU 上运行,大图时更快。


七、"降饱和"不等于"灰度化"的证明

之前讲的灰度化:

gray = 0.299R + 0.587G + 0.114B
输出像素 = (gray, gray, gray)

CIColorControls(saturation=0.0) 的结果:

R' = gray × 1 + R × 0 = gray
G' = gray × 1 + G × 0 = gray
B' = gray × 1 + B × 0 = gray
输出像素 = (gray, gray, gray)

两者数学上完全等价,都是 Rec.601 加权灰度化。区别在于:

  • saturation=0.0 是完整 CIColorControls 管线的一个参数,可以同时调整 contrast/brightness
  • 手动灰度化(Day 3 的方法)是独立的 CPU 操作,更灵活但更慢

八、小结

saturation(s)的本质:
  s=1.0 → 原色
  s=0.0 → Rec.601 加权灰度
  中间值 → 两者线性插值

何时降饱和:
  ✅ 背景颜色是噪声(复杂图案、鲜艳彩色背景)
  ✅ 已用 CIUnsharpMask 消除背景,颜色不再有意义
  ✅ 处于"超对比"模式,需要纯亮度信息

何时保留颜色(s=0.5~1.0):
  ✅ 数字颜色与背景亮度接近(颜色对比 > 亮度对比)
  ✅ Vision 需要颜色线索区分相似字形
  ✅ 保底措施:不确定时先用 s=0.7,再根据识别结果调整

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

往期推荐:

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

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

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

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

颜色科学与灰度化

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

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

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

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

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

图像到底是什么

图像处理技术概要图

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

❌
❌