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

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


一、人眼的颜色感知机制

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

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

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

这意味着:

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

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


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

最直觉的灰度化方法:

ini 复制代码
L = (R + G + B) / 3    // ← 错误!

反例:考虑两种颜色:

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

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

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

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


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

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

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

ini 复制代码
L = 0.299·R + 0.587·G + 0.114·B

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

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

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

相关推荐
bcbnb1 小时前
iOS开发中手动实现代码混淆的完整步骤与示例
后端·ios
2501_915909062 小时前
全面解析前端开发中常用的浏览器调试工具及其使用场景
android·ios·小程序·https·uni-app·iphone·webview
择势2 小时前
NSProxy 核心原理、消息机制、多继承、AOP、Timer 解耦、快速转发全解
ios
小橙讲编程2 小时前
agentmemory 深度解读:给 AI 编程助手装上"持久记忆"
开源·ai编程
songgeb3 小时前
iOS IAP 本地货币展示:从一个需求到搞清楚 priceLocale
ios·swift
甲维斯4 小时前
Claude Code 中文界面版成了!改了5000多行代码
人工智能·ai编程
AlexMaybeBot4 小时前
巧用 OpenClaw 为 Android 开发电脑瘦身
android·github·ai编程
ClouGence4 小时前
豆包收费之后,我找到了更好用的 AI 工具
前端·人工智能·后端·ai·ai编程·ai写作
老刘说AI4 小时前
Embedding不是魔法:把文字变成数字的底层逻辑
人工智能·python·语言模型·embedding·ai编程