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

你有没有想过:为什么把彩色照片转成黑白, 不能直接用 (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时代,软件工程师必备概念全景图

相关推荐
协享科技2 小时前
Vue 3 实现抖音式卡片滑动交互:从零到完整方案
前端·vue.js·交互·ai编程·英语·自考英语
2601_955767422 小时前
圆偏振光AR膜实测:反射率≤0.5%+96%透光率,iPhone17 Pro贴膜久看不累——观复盾上手
人工智能·科技·ios·ar·iphone·圆偏振光
折哥的程序人生 · 物流技术专研2 小时前
AI 编程与行业赋能|专栏总目录(持续更新)
开发语言·人工智能·软件工程·ai编程
布局呆星3 小时前
Claude Code :核心工作流 —— 与AI共舞的六种模式
ai编程
winlife_3 小时前
让 AI 写敌人状态机,并用脚本化场景验证状态转换正确:funplay-unity-mcp 实战
人工智能·unity·游戏引擎·ai编程·状态机·mcp
糯米导航3 小时前
飙算工具箱|AI编程工具赋能多模态 AIGC 架构实战
架构·aigc·ai编程
yumgpkpm3 小时前
华为HUAWEI昇腾910B下千问Qwen3.6-27B在的推理加速实践
sql·华为·langchain·json·ai编程·ai写作·gpu算力
AI原来如此3 小时前
工具篇 Writesonic:AI写作自带事实核查
ai·大模型·ai编程·ai写作
wangruofeng3 小时前
Build 2026 看完,我觉得微软这次是真急了
ai编程
木雷坞3 小时前
Open WebUI 连不上 Ollama:Docker Compose 排查记录
人工智能·docker·ai编程