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

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


一、饱和度的直觉

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

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

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

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

二、数学:灰度化 + 插值

CIColorControlsinputSaturation 本质上是两步:

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

ini 复制代码
gray = 0.299 × R + 0.587 × G + 0.114 × B

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

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

python 复制代码
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

python 复制代码
gray = 0.299 × 255 + 0.587 × 0 + 0.114 × 0 = 76.2 ≈ 76

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.2 ≈  53
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 是多模态模型,训练时见过大量彩色图像。颜色是它区分相似形状的辅助信号之一:

scss 复制代码
例:深蓝背景上的白色 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 特定颜色组合在灰度下消失

ini 复制代码
问题场景:橙色数字在青色背景上

橙色  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 策略

ini 复制代码
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 降饱和降低了可用对比度

ini 复制代码
原始颜色对比:
  橙色 (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 依赖饱和度处理后的亮度分布

objectivec 复制代码
推荐处理顺序:
  CIColorControls(saturation → contrast → brightness)

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

5.3 选择 saturation 值的决策树

ini 复制代码
开始
  │
  ├─ 背景是否为复杂图案(风景/纹理/渐变)?
  │   ├─ 是 → 用 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)

swift 复制代码
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 管线)

swift 复制代码
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 上运行,大图时更快。


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

之前讲的灰度化:

ini 复制代码
gray = 0.299R + 0.587G + 0.114B
输出像素 = (gray, gray, gray)

CIColorControls(saturation=0.0) 的结果:

python 复制代码
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 操作,更灵活但更慢

八、小结

ini 复制代码
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时代,软件工程师必备概念全景图

相关推荐
小林学AI1 小时前
以前查Bug要切5个工具,现在Claude Code MCP一句话搞定,降维打击!
ai编程
王飞飞不会飞2 小时前
iOS卡顿查找和定位-ProFile
ios·性能优化
敲代码的鱼2 小时前
NFC读卡能力 支持安卓/iOS/鸿蒙 UTS插件
android·ios·uni-app
鹏多多3 小时前
Trae cn里使用Pencil来制作设计图的手把手教程
前端·ai编程·trae
FEF前端团队4 小时前
AI 编程 Agent 全景解读:从 Chat 到 Agent,你的代码助手进化到了哪一步?
ai编程·cursor·trae
玹外之音4 小时前
【无标题】
人工智能·ai·ai编程
lili00124 小时前
CC GUI 插件架构剖析:如何为 JetBrains IDE 打造完整的 AI 编程工作台
java·ide·人工智能·python·架构·ai编程
一念杂记6 小时前
195+AI大模型免费用,无限Toke,亲测有效,赶紧用起来吧~
ai编程