【图像处理】颜色科学与灰度化——人眼看到的和数字记录的不一样

0 阅读7分钟

你有没有想过:为什么把彩色照片转成黑白, 不能直接用 (R + G + B) / 3? 答案藏在人眼的生理构造里。


一、人眼的颜色感知机制

人眼视网膜上有两种感光细胞:

  • 视锥细胞(Cone):感知颜色,分 L(长波/红)、M(中波/绿)、S(短波/蓝)三种
  • 视杆细胞(Rod):感知亮度,对颜色不敏感(暗处工作)

关键数据:三种视锥细胞的数量比约为 40:20:1(L:M:S,即红:绿:蓝)。

这意味着:

  • 人眼对绿色最敏感——绿色视锥最多
  • 人眼对红色次之
  • 人眼对蓝色最不敏感——蓝色视锥最少

这就是为什么草地比天空更"亮眼",尽管两者亮度可能相近。


二、为什么平均值灰度公式是错的

最直觉的灰度化方法:

L = (R + G + B) / 3    // ← 错误!

反例:考虑两种颜色:

  • 纯蓝 (0, 0, 255):平均值 = 85
  • 纯绿 (0, 255, 0):平均值 = 85

这两种颜色计算出相同的灰度值。但是:

  • 盯着纯绿背景看,会觉得很亮、刺眼
  • 盯着纯蓝背景看,会觉得较暗、沉稳

人眼对绿色比蓝色敏感得多,平均值灰度完全忽视了这一点,结果是失真的灰度图。


三、两个权威公式:BT.601 vs BT.709

学术界根据人眼对各颜色的感知权重,制定了标准化的灰度公式。

BT.601(1982 年,标准清晰度 SD)

L = 0.299·R + 0.587·G + 0.114·B

用于 NTSC/PAL 标准电视,针对 CRT 显示器的色域(Rec.601 色域)。

BT.709(1990 年,高清 HD,现代标准)

L = 0.2126·R + 0.7152·G + 0.0722·B

用于 HDTV(1080p)和 sRGB 色彩空间,是目前 Web 和移动端图像处理的事实标准

两者的区别与联系

维度BT.601BT.709
年代19821990
应用标清电视、JPEG高清电视、sRGB、现代 Web
绿色权重0.5870.7152
蓝色权重0.1140.0722
差异来源CRT 色域现代 LCD/OLED sRGB 色域

重要:权重来自对应色彩空间的原色定义(primary chromaticities)。sRGB 的蓝色原色比 Rec.601 更"纯蓝",因此 sRGB 下蓝色感知亮度更低,权重更小。

本框架使用 BT.709,因为我们统一使用 sRGB 颜色空间:

let l = 0.2126 * Float(r) + 0.7152 * Float(g) + 0.0722 * Float(b)

快速记忆

两个公式的共同点:权重之和 = 1,保证白色 (255, 255, 255) 灰度化后仍为 255。

BT.709 的权重近似记忆:绿七蓝一红二(0.7152 : 0.0722 : 0.2126)。


四、数值精度问题

灰度化时需要把 Float 结果转成 UInt8,这里有两个细节:

细节 1:截断 vs 四舍五入

// 错误:直接截断(Int 转换默认向零截断)
let l = UInt8(0.2126 * Float(r) + 0.7152 * Float(g) + 0.0722 * Float(b))

// 正确:先四舍五入
let l = UInt8(clamping: Int((0.2126 * Float(r) + 0.7152 * Float(g) + 0.0722 * Float(b)).rounded()))

例子:纯绿色 (0, 255, 0)

  • 精确值 = 0.7152 × 255 = 182.376
  • 截断:182
  • 四舍五入:182(这里差不多)

更极端的例子:灰色 (127, 127, 127)

  • 精确值 = (0.2126 + 0.7152 + 0.0722) × 127 = 127.0
  • 截断:127 ✅

但对于边界值,rounded() 能减少累积误差。

细节 2:溢出保护

0.2126 × 255 + 0.7152 × 255 + 0.0722 × 255 = 255.0,理论上不会溢出。但由于 Float 精度,可能出现极小的超出(如 255.00001)。UInt8(clamping:) 会将超出范围的值截断到 [0, 255]:

UInt8(clamping: 256) // → 255,而非崩溃
UInt8(clamping: -1)  // → 0,而非崩溃

五、灰度化的实现

public struct GrayscaleFilter: ImageFilter {

    public func apply(to bitmap: MLBitmap) -> MLBitmap {
        var result = bitmap

        // stride 每次跳 4 字节 = 1 像素,避免手动 i += 4
        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])
            // i + 3 = Alpha,跳过不处理

            let luminance = UInt8(clamping: Int(
                (0.2126 * r + 0.7152 * g + 0.0722 * b).rounded()
            ))

            result.pixels[i]     = luminance  // R ← L
            result.pixels[i + 1] = luminance  // G ← L
            result.pixels[i + 2] = luminance  // B ← L
            // result.pixels[i + 3] 不变(Alpha 保留)
        }

        return result
    }
}

输出格式:灰度图的 R = G = B = 亮度值(三通道相等)。虽然"灰度图"理论上只需要 1 个通道,但为了与整个框架的 RGBA8888 格式兼容,仍然保留 4 通道,只是三个颜色通道值相同。


六、Alpha 通道的保护原则

灰度化不应该影响 Alpha 通道!

// 错误:灰度化时把 Alpha 也改了
result.pixels[i + 3] = luminance  // ❌ 会破坏透明效果

// 正确:Alpha 保持原值
// 直接不写 result.pixels[i + 3],它已经在 bitmap 副本中了

验证测试

func testGrayscaleAlphaUnchanged() {
    var bmp = MLBitmap(width: 1, height: 1, filling: .white)
    bmp[0, 0] = MLBitmap.Pixel(r: 255, g: 0, b: 0, a: 128)  // 半透明红

    let result = GrayscaleFilter().apply(to: bmp)

    // 灰度值:0.2126×255 + 0.7152×0 + 0.0722×0 = 54
    XCTAssertEqual(result[0, 0].r, 54)
    XCTAssertEqual(result[0, 0].a, 128)  // Alpha 不变!
}

七、颜色空间的更多维度

HSV 颜色模型

RGB 描述的是物理颜色混合,HSV 更接近人对颜色的描述方式:

  • H(Hue,色相):颜色的种类(0°~360°:红→橙→黄→绿→青→蓝→紫→红)
  • S(Saturation,饱和度):颜色的纯净程度(0 = 灰色,1 = 纯色)
  • V(Value,明度):颜色的明暗(0 = 黑,1 = 最亮)

用途:按颜色种类处理。例如只加深红色花朵的饱和度,不影响绿叶,就需要在 HSV 空间操作 H 和 S。

Lab 颜色模型

Lab 的特点是感知均匀:两个颜色在 Lab 空间的欧氏距离,与人眼感知到的颜色差异成正比。

  • L:感知亮度(0 = 黑,100 = 白)
  • a:红绿轴(正值偏红,负值偏绿)
  • b:蓝黄轴(正值偏黄,负值偏蓝)

用途:颜色差异比较(ΔE 色差计算)、颜色匹配。


八、实际效果对比

对同一张人像照片做三种灰度化:

方法效果描述
平均值 (R+G+B)/3绿色区域偏亮,蓝色区域偏亮,失真
BT.601接近自然,但对蓝色稍偏重
BT.709最自然,与人眼感知最接近,适合 sRGB 图像

测试验证

func testGrayscaleLuminanceFormula() {
    // 纯绿色 (0, 255, 0)
    // BT.709: L = 0.7152 × 255 ≈ 182.376 → 四舍五入 = 182
    var bmp = MLBitmap(width: 1, height: 1, filling: .white)
    bmp[0, 0] = .green

    let result = GrayscaleFilter().apply(to: bmp)
    let expected = UInt8((0.7152 * 255).rounded())  // = 182

    XCTAssertEqual(result[0, 0].r, expected)
}

九、小结

概念核心结论
人眼感知绿最敏感(视锥最多),蓝最不敏感
平均值灰度错误,忽视人眼感知差异
BT.601标清标准,适用于 NTSC/Rec.601 色域
BT.709高清/sRGB 标准,现代图像处理首选
数值处理先乘后 rounded(),再 UInt8(clamping:)
Alpha 原则灰度化不修改 Alpha 通道

思考题

  1. 如果对一张纯蓝 (0, 0, 255) 的图做 BT.709 灰度化,结果是多少?用平均值呢?哪个更符合你的直觉?
  2. 有没有办法不用三通道存储灰度图(即真正的单通道)?这需要改变什么?
  3. JPEG 格式内部实际上用 YCbCr 颜色空间存储,其中 Y 通道是亮度,Cb/Cr 是色差。查阅 YCbCr 的 Y 分量公式,和 BT.709 的灰度公式比较,你发现了什么?

上一期参考答案:1. 旋转信息存在 UIImage 里而非 CGImage 里,需要先把 UIImage 重新绘制到新的 Context 修正方向;2. Big endian 下字节顺序是 [R, G, B, A],Little endian 是 [A, B, G, R],我们用 Big 是因为 RGBA 字节顺序与内存顺序一致,方便理解和调试;3. 预乘后 RGB = 0 × A/255 = 0,所以透明像素的 RGB 都是 0,无论原始颜色是什么。

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

往期推荐:

颜色科学与灰度化
从"图片"到"内存"——你真正理解图像处理的第一天
iPhone相册背后的图像处理知识(下)
iPhone相册背后的图像处理知识(中)
iPhone相册背后的图像处理知识(上)
一张图了解图像处理的本质
图像到底是什么
图像处理技术概要图
AI时代,软件工程师必备概念全景图