饱和度为 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) ← 完全变成灰色
二、数学:灰度化 + 插值
CIColorControls 的 inputSaturation 本质上是两步:
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,再根据识别结果调整
如果这篇对你有一点启发:点个赞,让更多人少踩一个坑 转发给那个正在纠结的人也欢迎关注我------ 我们一起,把认知变成长期复利。
往期推荐: