4、Gamma 曲线数学原理:幂律函数、Gamma 编码与解码、线性光与非线性光

Gamma 校正,说白了就是一条幂律曲线在管着你的画面。很多朋友调了几年色,问起 Gamma 到底是什么,还是支支吾吾。今天咱们就把这层窗户纸捅破。

4.1 幂律函数:Gamma 的数学骨架

Gamma 校正的数学基础,就是一个极其简单的幂律函数:

V_out = (V_in) ^ γ

其中 V_in 是输入值,V_out 是输出值,γ 就是 Gamma 值。就这么一个公式,撑起了整个显示系统的亮度映射体系。

我刚开始接触时也觉得太简单了,不就是个指数函数吗?后来才发现,正是这个「简单」让它在硬件上实现起来极其高效——早期 CRT 显示器用几个电阻电容就能天然实现这个曲线。

关键点:

  • γ < 1:压缩暗部,提亮整体(解码曲线)
  • γ = 1:线性映射,不做校正
  • γ > 1:拉伸暗部,压暗整体(编码曲线)

举个例子。假设你有一个像素亮度是 0.5(归一化后),如果 γ=2.2,输出就是 0.5^2.2 ≈ 0.217。你看,原本中间灰被压到了接近暗部。这就是编码的过程。

4.2 Gamma 编码:为什么要「故意扭曲」亮度?

你想想看,人眼对暗部的变化极其敏感,对亮部的变化相对迟钝。但摄像头传感器是线性器件——它老老实实地记录光子数量。这就产生了矛盾。

Gamma 编码的目的,就是把线性传感器捕获的亮度值,按照人眼的视觉特性重新分配。说白了,把更多的数据位分配给暗部,亮部少占一点。

# Gamma 编码(模拟)
def gamma_encode(linear_value, gamma=1/2.2):
    return linear_value ** gamma

# 示例:8位系统
linear = [0, 0.1, 0.2, 0.5, 0.8, 1.0]
encoded = [gamma_encode(v) for v in linear]
print(encoded)
# 输出近似:[0, 0.35, 0.48, 0.73, 0.91, 1.0]

看到没?0.1 的线性值被编码成了 0.35,暗部细节被「放大」了。这就是为什么 8 位图像能保留那么多暗部层次——Gamma 编码在背后做了手脚。

我的经验: 做 HDR 合成时,很多人直接把线性 HDR 数据丢进 8 位显示器,结果暗部一片死黑。正确的做法是先做 Gamma 编码再显示。我曾经在这个坑里摔过两次,后来养成了习惯:任何显示操作前先确认色彩空间。

4.3 Gamma 解码:显示器必须做的事

显示器收到编码后的信号,必须做反向操作才能还原正确的亮度。这就是 Gamma 解码:

V_display = (V_encoded) ^ γ_display

有意思的是,CRT 显示器的物理特性天然就是 γ≈2.5 的幂律曲线。所以早期系统设计时,发送端做 γ=1/2.2 的编码,接收端(显示器)自然解码,正好凑出接近线性的最终效果。

环节 Gamma 值 作用
摄像头/软件编码 1/2.2 ≈ 0.4545 压缩亮部,扩展暗部
显示器物理特性 ≈ 2.5(CRT) 天然幂律响应
最终系统 Gamma ≈ 1.0(理想) 线性光输出

不过现在 LCD/OLED 显示器没有天然 Gamma 了,全靠内部 LUT 模拟。所以你会看到显示器菜单里有「Gamma 1.8、2.2、2.4」的选项——那就是在切换解码曲线。

注意: 编码 Gamma 和解码 Gamma 不是互为倒数的关系。实际系统中,编码端用 1/2.2,解码端用 2.2 或 2.4,最终系统 Gamma 大约在 1.0~1.1 之间。完全精确的倒数会导致画面发灰——我亲自验证过。

4.4 线性光 vs 非线性光:一个让无数人翻车的概念

线性光,就是物理世界真实的光子数量。你给传感器两倍的光子,它就输出两倍的电压。非线性光,就是经过 Gamma 编码后的信号——它和物理亮度不成正比。

为什么这个概念重要?因为几乎所有图像处理算法都假设你在线性空间工作。如果你在非线性空间做模糊、混合、缩放,结果全是错的。

# 错误示范:在非线性空间做混合
pixel1_nonlinear = 0.5  # 编码后的中间灰
pixel2_nonlinear = 0.5
blend_nonlinear = (pixel1_nonlinear + pixel2_nonlinear) / 2
# 结果:0.5,看起来没问题?

# 正确做法:先解码到线性空间
pixel1_linear = pixel1_nonlinear ** 2.2  # 解码
pixel2_linear = pixel2_nonlinear ** 2.2
blend_linear = (pixel1_linear + pixel2_linear) / 2
blend_nonlinear_correct = blend_linear ** (1/2.2)  # 重新编码
# 结果:≈ 0.73,完全不一样!

你看,同样的操作,线性空间和非线性空间的结果差了这么多。这就是为什么你在 Photoshop 里用「柔光」图层混合模式,有时候效果怪怪的——很可能是因为没有在正确的色彩空间里操作。

黄金法则:

  • 计算、混合、滤波 → 线性空间
  • 存储、传输、显示 → 非线性空间(Gamma 编码)
  • 永远不要在非线性空间做数学运算

4.5 一张图看懂 Gamma 全流程

下面这张 SVG 图把整个 Gamma 编码-解码链路画清楚了。我建议你仔细看看箭头上的 Gamma 值,理解每个环节的变换。

Gamma 编码与解码完整流程 物理场景 线性光(真实亮度) 传感器捕获 线性电压信号 Gamma 编码 γ = 1/2.2 存储/传输 8/10/12 bit Gamma 解码 γ = 2.2 ~ 2.4 显示器驱动 DAC + 面板响应 人眼感知 非线性视觉响应 最终视觉 接近线性亮度 核心要点 ① 编码端 γ < 1,解码端 γ > 1,系统总 Gamma ≈ 1.0 ② 所有图像处理算法必须在线性空间执行 ③ 存储和传输永远用非线性编码(节省带宽、保留暗部)

4.6 避坑指南:我踩过的 Gamma 坑

做色彩校正这些年,我在 Gamma 上翻车的次数最多。说几个典型的:

  • sRGB 不等于 Gamma 2.2:sRGB 在暗部有一段线性区(斜率 12.92),不是纯幂律。直接用 pow(x, 1/2.2) 做 sRGB 解码,暗部会有偏差。我早期一个项目因此被客户打回三次。
  • 浮点精度陷阱:Gamma 编码后的 0.0 和 1.0 是边界值,0^γ=0,1^γ=1。但中间值经过多次编解码会累积误差。我曾经在 16 位浮点流水线里反复编解码 5 次,结果中间灰漂了 3 个码值。
  • 不同标准混用:Rec.709 用 Gamma 2.4,sRGB 用近似 2.2,DCI-P3 用 2.6。混用这些标准做色彩转换,画面不是发灰就是对比度过高。我建议项目一开始就定死色彩空间标准。

我的习惯: 写任何图像处理代码,第一行就做色彩空间声明。用注释写明「此函数输入为线性 sRGB」或「此函数输出为 Gamma 编码的 Rec.709」。这样三个月后回来看代码,自己也不会懵。

4.7 小结

Gamma 曲线不是什么玄学。它就是一条幂律曲线,编码端压暗部、解码端拉回来,最终让人眼看到接近线性的亮度。记住三个数字:编码用 1/2.2,解码用 2.2~2.4,系统总 Gamma 约 1.0。下次调色时,多想想你当前在哪个色彩空间里操作——这个习惯能帮你避开 80% 的坑。