4、Gamma校正基础:sRGB标准与Gamma 2.2的由来
说到Gamma校正,很多刚入行的朋友第一反应就是「调亮度」。嗯,这个理解其实有点偏。Gamma校正不是调亮度,它是在解决一个历史遗留问题——CRT显示器的物理特性。我当年刚接触图像处理时也被这个概念绕晕过,后来在做一个跨平台渲染项目时,才真正搞明白它有多重要。
4.1 为什么会有Gamma?
事情要从老式CRT显示器说起。你想想看,CRT的电子枪发射电子束,打在荧光屏上发光。但这里有个坑:输入电压和输出亮度不是线性关系。具体来说,亮度 ≈ 电压的2.2次方。也就是说,你给显示器输入0.5的电压,它不会输出50%的亮度,而是输出大约0.5^2.2 ≈ 0.22的亮度。
为什么会这样?这是CRT的物理特性决定的。电子枪的阴极电流和电压之间本身就存在非线性关系,再加上荧光粉的发光效率也不是线性的。两者叠加,就形成了大约2.2的幂次关系。
核心公式:
输出亮度 = 输入电压 ^ Gamma值
对于CRT显示器:Gamma ≈ 2.2
我在项目中遇到过这样一个问题:用线性数据直接渲染,在显示器上看总是暗部细节丢失。后来才发现,是因为没有做Gamma校正。说白了,你的数据是线性的,但显示设备是非线性的,中间必须有个转换。
4.2 sRGB标准的诞生
既然CRT有这个问题,那我们就得想办法补偿。早期各家厂商各搞各的,有的用1.8,有的用2.2,乱得很。直到1996年,微软和惠普联合推出了sRGB标准,才把这个事情统一了。
sRGB标准的核心思想很简单:在编码端做一次Gamma压缩,在解码端做一次Gamma展开。这样,数据在传输和存储时是经过压缩的,但最终显示时能还原出正确的亮度。
| 环节 | 操作 | 公式 |
|---|---|---|
| 编码(存储/传输) | Gamma压缩 | V_out = V_in ^ (1/2.2) |
| 解码(显示) | Gamma展开 | V_display = V_out ^ 2.2 |
你想想看,如果编码时做了1/2.2次方,显示时再做2.2次方,两者相乘正好等于1,完美还原。这就是sRGB的数学基础。
4.3 Gamma 2.2 为什么是标准?
你可能要问:为什么偏偏是2.2,不是2.0或者2.5?
嗯,这里有几个原因:
- 历史原因:大多数CRT显示器的物理Gamma值就在2.2左右,这是经过大量实测统计出来的。
- 视觉感知:人眼对暗部变化更敏感,对亮部变化不敏感。Gamma 2.2的曲线恰好能在暗部分配更多编码位,亮部分配更少。这其实是一种感知量化。
- 兼容性:2.2和早期电视系统的Gamma标准接近,方便内容跨平台复用。
个人经验:
我在做移动端图像处理时发现,很多手机屏幕的Gamma值其实不是精确的2.2,有的偏2.0,有的偏2.4。但sRGB标准强制要求内容按2.2编码,所以显示端必须做校正。这也是为什么同一张图片在不同手机上看起来亮度不一样——因为屏幕的Gamma校正精度不同。
4.4 线性空间 vs sRGB空间
搞清楚了Gamma的由来,接下来就是实战中最容易踩坑的地方:你到底该在哪个空间做计算?
我建议你记住一个原则:计算在线性空间,显示在sRGB空间。
为什么?因为所有物理模拟、光照计算、颜色混合,都是基于线性光学的。如果你在sRGB空间里直接做加法、乘法,结果会完全错误。举个例子:
# 错误做法:在sRGB空间做混合
color1 = 0.5 # sRGB编码值
color2 = 0.5
blend = (color1 + color2) / 2 # 结果是0.5
# 实际亮度:0.5^2.2 ≈ 0.22,混合后还是0.22
# 但正确的物理亮度应该是:0.22 + 0.22 = 0.44
# 正确做法:先解码到线性空间
linear1 = color1 ^ 2.2 # 0.5^2.2 ≈ 0.22
linear2 = color2 ^ 2.2 # 0.5^2.2 ≈ 0.22
blend_linear = (linear1 + linear2) / 2 # 0.22
# 再编码回sRGB
blend_srgb = blend_linear ^ (1/2.2) # 0.22^(1/2.2) ≈ 0.5
# 等等,结果怎么一样?
注意:
上面这个例子其实是个巧合。因为两个相同的值做平均,线性空间和sRGB空间的结果恰好一样。但如果是不同亮度的颜色混合,结果就完全不同了。我曾经在做一个渐变渲染时,直接在sRGB空间里插值,结果中间色明显偏暗——这就是典型的「Gamma错误」。
4.5 实战中的Gamma校正
好了,理论说完了,咱们看看实际代码怎么写。以Python为例,sRGB的编解码其实有标准公式:
import numpy as np
def srgb_encode(linear):
"""线性值 -> sRGB编码值"""
mask = linear <= 0.0031308
return np.where(mask,
12.92 * linear,
1.055 * (linear ** (1/2.4)) - 0.055)
def srgb_decode(srgb):
"""sRGB编码值 -> 线性值"""
mask = srgb <= 0.04045
return np.where(mask,
srgb / 12.92,
((srgb + 0.055) / 1.055) ** 2.4)
注意看,sRGB标准用的Gamma是2.4,不是2.2。为什么?因为sRGB在暗部加了一段线性段(0.0031308以下用12.92倍线性),整体曲线更接近2.2,但精确值是2.4。这个细节很多人不知道,我也是在调试一个HDR渲染管线时才发现的。
避坑指南:
我曾经在项目里直接用 V^(1/2.2) 做sRGB编码,结果在暗部区域出现了明显的色阶断层。后来查了标准才发现,sRGB在暗部用了线性段来避免噪声放大。所以,不要偷懒用近似公式,请严格按照sRGB标准实现。
4.6 总结一下
Gamma校正不是什么高深的东西,说白了就是:
- 历史原因:CRT显示器是非线性的,Gamma≈2.2
- 标准方案:sRGB用Gamma 2.4(含线性段)做编解码
- 实战原则:计算用线性空间,显示用sRGB空间
- 常见错误
嗯,这一章的内容就到这里。下一章我们会讲Gamma校正的实战调试技巧,包括如何用Python快速验证你的管线是否正确。到时候我会分享一个我踩过的坑——在移动端GPU上做Gamma校正时,精度问题导致颜色偏移,折腾了我整整两天。
公众号:蓝海资料掘金营,微信deep3321