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

你有没有想过:为什么把彩色照片转成黑白,

不能直接用 (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.601 BT.709
年代 1982 1990
应用 标清电视、JPEG 高清电视、sRGB、现代 Web
绿色权重 0.587 0.7152
蓝色权重 0.114 0.0722
差异来源 CRT 色域 现代 LCD/OLED sRGB 色域

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

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

swift 复制代码
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 四舍五入

swift 复制代码
// 错误:直接截断(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

swift 复制代码
UInt8(clamping: 256) // → 255,而非崩溃
UInt8(clamping: -1)  // → 0,而非崩溃

五、灰度化的实现

swift 复制代码
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 通道!

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

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

验证测试

swift 复制代码
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 图像

测试验证

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

相关推荐
新新技术迷1 小时前
AI聊天自动跟随滚动,附回到底部按钮
人工智能
先锋部队1 小时前
用Web Worker解析AI返回的大文本不卡UI
人工智能
把你拉进白名单1 小时前
8.OpenClaw源码解析——三层洋葱重试
人工智能·llm·agent
用户632415031781 小时前
拖文档进AI对话框解析,前端要处理哪些脏活
人工智能
姗姗来迟了1 小时前
AI回答里的引用来源卡片,前端怎么做
人工智能
用户7106207733401 小时前
Codex-端口配置错误排查案例(stream disconnected before completion)
人工智能
IT_陈寒2 小时前
JavaScript的默认参数挖坑实录,我掉进去了
前端·人工智能·后端
米小虾3 小时前
多Agent系统编排详解:从架构设计到代码实现
人工智能·agent
米小虾3 小时前
多Agent系统的编排:架构、协议与企业级应用
人工智能·agent
To_OC12 小时前
搞懂 Token 和 Embedding 后,我终于明白大模型是怎么 "读" 文字的
人工智能·llm·agent