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

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

不能直接用 (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时代,软件工程师必备概念全景图

相关推荐
火山引擎开发者社区5 小时前
技术速递|使用 GitHub Copilot CLI 构建 Emoji 列表生成器
人工智能
codefan※5 小时前
干掉“幻觉“实战:如何构建企业级知识图谱增强 RAG
人工智能·知识图谱
wukangjupingbb6 小时前
传统基于药物 SMILES 序列和蛋白质氨基酸序列的 DTI(Drug-Target Interaction)预测方法的缺陷
人工智能
沪漂阿龙6 小时前
Codex 额度重置周期变化:AI 编程免费试玩时代正在结束
人工智能
TickDB6 小时前
美股行情 API 接入避坑:REST 快照、WebSocket 推送、盘前盘后数据的边界
人工智能·python·websocket·行情数据 api
装不满的克莱因瓶6 小时前
深入理解卷积神经网络(CNN)——从原理到代码实践
人工智能·神经网络·cnn
完成大叔6 小时前
模块二,Agent知识图谱的工具链思考
人工智能
lauo6 小时前
ibbot手机发布:搭载poplang技术 + token节点经济,革新AI手机体验
人工智能·智能手机
咖啡星人k7 小时前
云端开发环境技术架构深度解析:从容器隔离到AI Agent集成
人工智能·架构
袋鼠云数栈7 小时前
从前端到基础设施,ACOS 如何打通企业全链路可观测
运维·前端·人工智能·数据治理·数据智能