你有没有想过:为什么把彩色照片转成黑白, 不能直接用
(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 通道 |
思考题
- 如果对一张纯蓝
(0, 0, 255)的图做 BT.709 灰度化,结果是多少?用平均值呢?哪个更符合你的直觉? - 有没有办法不用三通道存储灰度图(即真正的单通道)?这需要改变什么?
- 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时代,软件工程师必备概念全景图