2774 字
14 分钟
Gamma 矫正到底矫正了什么?从线性光走到屏幕亮度

把一个灰色像素写成 0.5,它代表“一半的光”吗?在大多数普通图片里,不代表。

如果 0.5线性光,它表示最大光强的一半;如果它是 sRGB 文件里的编码值,解码后的线性光强只有约 0.214。两个数字长得一样,物理含义却差了一倍多。许多“图片怎么突然发黑”“透明叠加为什么有黑边”“CCM 明明没写错却偏色”的问题,根源都在这里。

Gamma 矫正不是给图片随手加亮,而是在两种表示之间翻译:

  • 编码(encode):线性光 LL → 适合保存、传输的非线性数值 VV
  • 解码(decode):编码数值 VV → 可用于光照、混合和多数物理计算的线性光 LL

本文默认数值归一化到 [0,1][0,1],主要讨论 SDR 的 sRGB。显示设备、ICC 色彩管理和 HDR 会在边界部分交代,但不展开成标准手册。

同一个 0.5,为什么不是同一种亮度#

相机传感器在未饱和的理想区间里,收到两倍光,原始信号大致也变成两倍。这样的数值叫线性光。渲染器里的光照、曝光、卷积和颜色混合通常都期待这种数据。

但人眼对暗部的小变化更敏感。若把有限的 256 个 8-bit 码值均匀分给物理光强,很多码值会花在我们不太敏感的亮部,暗部却更容易看到断层。非线性编码把暗部“拉开”、亮部“压紧”,让码值分配更贴近视觉敏感度。

先看最常用来建立直觉的幂函数近似:

V=L1/γV=L^{1/\gamma}

这是编码。反方向是:

L=VγL=V^\gamma

这是解码。当 γ=2.2\gamma=2.2L=0.18L=0.18 时,编码值约为 0.4590.459;将它再做 V2.2V^{2.2},就回到 0.180.18。指数互为倒数,方向不能靠函数名猜。

Gamma 曲线实验室

先选方向:编码把线性光写成文件数值;解码把文件数值还原为线性光。

变换方向
曲线模型
011
输入0.1800
输出0.4614
当前方向L → V

在实验里切到“解码”,曲线会翻到对角线下方。输入滑块同样是 0.5,此时输入的含义已从“线性光”变成“文件编码值”。

“Gamma”为什么总是说乱#

历史上,CRT 显示器的输出光强近似服从输入电压的幂函数。后来,人们用相反方向的曲线预先编码图像。再后来,标准把拍摄、编码、显示分别定义得更精确,但“Gamma”仍被口语化地用来指其中任何一步。

更清楚的术语是:

名称方向回答的问题
OETF场景线性光 → 非线性信号相机怎样把光编码成信号?
EOTF非线性信号 → 显示光显示器怎样把信号变成光?
OOTF场景光 → 显示光整套系统最终怎样再现对比度?

sRGB 的编码函数常被叫作 OETF,严格说它是以显示参考条件为目标的编码传递函数。工程代码里最稳妥的做法仍是把函数直接命名为 srgb_encodesrgb_decode,并在参数名里写清 linearencoded

sRGB 不是一条简单的 2.2 曲线#

“sRGB 大约是 Gamma 2.2”适合心算,却不能代替准确实现。sRGB 在接近黑色处使用线性段,避免纯幂函数在零点附近出现不适合实现的斜率;其余部分使用指数 1/2.41/2.4

编码(线性光 LL → sRGB 值 VV):

V={12.92L,L0.00313081.055L1/2.40.055,L>0.0031308V=\begin{cases} 12.92L, & L\le 0.0031308\\ 1.055L^{1/2.4}-0.055, & L>0.0031308 \end{cases}

解码(sRGB 值 VV → 线性光 LL):

L={V/12.92,V0.04045(V+0.0551.055)2.4,V>0.04045L=\begin{cases} V/12.92, & V\le 0.04045\\ \left(\dfrac{V+0.055}{1.055}\right)^{2.4}, & V>0.04045 \end{cases}

两个阈值属于不同方向,不能混用。下面是可直接测试的 Python 版本:

def srgb_encode(linear):
"""将 [0, 1] 线性光编码为 [0, 1] sRGB。"""
linear = max(0.0, min(1.0, linear))
if linear <= 0.0031308:
return 12.92 * linear
return 1.055 * linear ** (1.0 / 2.4) - 0.055
def srgb_decode(encoded):
"""将 [0, 1] sRGB 编码值解码为 [0, 1] 线性光。"""
encoded = max(0.0, min(1.0, encoded))
if encoded <= 0.04045:
return encoded / 12.92
return ((encoded + 0.055) / 1.055) ** 2.4
encoded = srgb_encode(0.18)
restored = srgb_decode(encoded)
print(encoded, restored) # 约 0.4614, 0.18

这里为普通图片输入加了 clamp。若你处理 HDR、场景参考浮点图像或需要保留负值的中间色彩空间,这个边界策略并不合适;公式外的范围必须按管线规范定义。

Gamma 在管线的哪个位置#

一条简化的相机到屏幕路径可以写成:

场景光
→ RAW 线性化 / 白平衡 / Debayer / CCM
→ Tone Mapping
→ sRGB 编码
→ 8-bit 图片文件
→ sRGB 解码 / 显示 EOTF
→ 屏幕发光

文件中的 JPEG 或 PNG 像素通常是编码值,不是可直接做物理运算的光强。读取库把字节变成浮点数 0.0–1.0,也不等于自动完成了 sRGB 解码;数据类型是 float 与是否线性,是两件事。

编码—解码管线诊断器

故意漏做或重复做一步,看最终显示光强怎样偏离原始线性光。

注入故障

编码一次、显示端解码一次,最终光强回到原值。

参考线性中灰0.1800
最终显示光强0.1800
相对误差0.0%

诊断器把 0.18 当作参考线性中灰。正确路径编码一次、显示端解码一次,来回后仍是 0.18。漏一步或多做一步时,代码可能完全没有报错,画面却整体发黑或发灰。

为什么非线性编码能更有效地使用位深#

8-bit 只能存 28=2562^8=256 个整数。线性编码相邻码值的光强间隔始终是 1/2551/255;sRGB 编码相邻码值对应的线性光间隔并不相等:暗部小,亮部大。

这不会凭空增加信息,也不会让 8-bit 变成 10-bit。它只是重新安排现有的刻度,把更多刻度放到人眼更容易看出差异的位置。

256 个码值花在哪里?

同样的位深,sRGB 会把更多码值留给暗部;切换 10-bit 观察量化阶梯变细。

编码方式
位深

放大区间:0.030.13 线性光。竖条相同不一定是组件坏了:那正是多个输入落入同一码值。

总码值256
当前 0.1 区间可用码值54
用途暗部更密

把观察区间移到暗部,sRGB 的可用码值明显多于线性 8-bit;移到亮部,关系会反过来。切到 10-bit 时总码值变成 1024,编码方式与位深的作用也就分开了:前者决定刻度放在哪里,后者决定一共有多少刻度

量化应尽量放在管线末端。在线性域和编码域之间反复转换并四舍五入,会累计误差与色带;若中间缓冲能使用浮点或更高位深,就不要每一步都压回 8-bit。

最容易看见的错误:在 sRGB 数值里混色#

黑与白各占一半,直接平均编码值会得到 0.5。它看似“数学上的正中间”,解码后的光强却只有约 0.214,不是两束光平均应得的 0.5

正确流程是:

Cout=encode((1t)decode(Ca)+tdecode(Cb))C_{out}=encode\left((1-t)\,decode(C_a)+t\,decode(C_b)\right)

线性光混色实验室

左边直接平均文件里的 sRGB 数字;右边先解码到线性光,混合后再编码。

颜色对
颜色 A · #000000
+
颜色 B · #FFFFFF

直接在 sRGB 数值中混合

#808080

在线性光中混合

#BCBCBC
sRGB 数值混合#808080
线性光混合#BCBCBC
正确顺序decode → mix → encode

在默认的黑白 50% 示例中,直接平均得到 #808080;线性光混合再编码约为 #BCBCBC。同样的问题会出现在渐变、Alpha 合成、缩放滤波、模糊、抗锯齿和多光源叠加里。

NumPy 版本可以明确写出每一步:

import numpy as np
def srgb_decode_array(encoded):
encoded = np.clip(encoded, 0.0, 1.0)
return np.where(
encoded <= 0.04045,
encoded / 12.92,
((encoded + 0.055) / 1.055) ** 2.4,
)
def srgb_encode_array(linear):
linear = np.clip(linear, 0.0, 1.0)
return np.where(
linear <= 0.0031308,
12.92 * linear,
1.055 * linear ** (1.0 / 2.4) - 0.055,
)
6 collapsed lines
def mix_srgb_correctly(color_a, color_b, amount):
a = srgb_decode_array(np.asarray(color_a, dtype=np.float32))
b = srgb_decode_array(np.asarray(color_b, dtype=np.float32))
mixed_linear = a * (1.0 - amount) + b * amount
return srgb_encode_array(mixed_linear)

透明合成还要区分 straight alpha 与 premultiplied alpha。无论使用哪种,颜色参与覆盖运算时仍应处在线性光域;否则暗色边缘尤其明显。

Gamma、曝光和 Tone Curve 不是一件事#

它们都可能让画面“看起来更亮”,数学含义却不同:

  • 曝光在线性光域乘一个比例。增加一档通常意味着 L=2LL'=2L
  • Gamma 编码/解码在两种数值表示之间转换,正常往返不应改变代表的光;
  • Tone Curve / Tone Mapping有意改变场景亮度关系,可能包含暗部 toe、高光 shoulder 和局部对比度。

例如把线性光 0.18 做 sRGB 编码得到约 0.461,不是把像素“提亮”了。只要显示端正确解码,它仍代表 0.18 的光。若把编码后更大的数字误认为曝光提升,就混淆了数值与其含义。

CCM 为什么通常不能直接作用于 sRGB#

CCM 的矩阵乘法描述的是线性通道响应的组合:

cout=Mcin\mathbf{c}_{out}=M\mathbf{c}_{in}

非线性编码后,一般有:

Mencode(c)encode(Mc)M\,encode(\mathbf{c}) \ne encode(M\mathbf{c})

因此,对普通 sRGB 图片应用一张在线性 RGB 上定义的 CCM 时,应先解码、乘矩阵,再按目标格式编码。例外是某些矩阵本来就为特定编码域定义;算法的输入约定永远优先于口号。

从画面反推常见故障#

症状常见原因先检查什么
中间调异常发黑线性数据被当作 sRGB 又解码缓冲区的 transfer function 标记
画面发灰、过亮sRGB 数据重复编码,或显示端漏解码写文件与显示各执行了几次转换
混色与缩放出现暗边直接在编码域插值滤波前是否 decode
暗部色带过早量化或反复 8-bit 往返中间缓冲位深
不同软件观感不同色彩配置缺失、被忽略或错误指定ICC profile、色彩空间与显示管理

还有一个隐蔽问题:很多 API 的纹理格式会在采样时自动把 sRGB 解码为线性值,并在写入 sRGB framebuffer 时自动编码。如果 shader 又手动转换一次,就会造成重复处理。先确认 API 和资源格式替你做了什么。

Rec.709、sRGB 与“2.2”不要混成一条曲线#

sRGB 与 Rec.709 使用相同的 RGB 原色和白点,但编码/显示约定并不完全等同。Rec.709 的相机 OETF 是另一条分段函数;电视系统还涉及观看环境与 BT.1886 等显示约定。把所有 SDR 都写成 pow(x, 1/2.2),只适合粗略预览,不适合要求一致性的交换流程。

真正落地时至少记录三件事:RGB 原色/白点、传递函数、数值范围。只写“RGB”远远不够。

HDR 为什么不能简单继续叫 Gamma#

HDR 中常见的 PQ(ST 2084)和 HLG 也都是非线性传递函数,但目标不同:PQ 以绝对显示亮度为基础,HLG 更偏向广播兼容的相对系统。它们的公式、范围和元数据都不能用 sRGB 或简单幂函数替代。

这也提醒我们:非线性不等于 Gamma 2.2。遇到 Log、PQ、HLG、sRGB 时,应按各自标准进行转换,不要因为曲线看起来相似就互换。

一份不容易出错的判断清单#

每次准备调用 pow 或传递函数之前,先回答:

  1. 当前数字代表线性光,还是某种编码值?
  2. 如果是编码值,具体是 sRGB、Rec.709、Log、PQ,还是别的曲线?
  3. 下一步算法期待哪个域?曝光、光照、卷积、混色和多数 CCM 通常期待线性光。
  4. 读取库、GPU 纹理和 framebuffer 是否已经自动转换?
  5. 中间结果会不会被过早裁剪或量化?
  6. 写出文件时,像素数据与色彩配置是否一致?

Gamma 最难的地方不是公式,而是追踪数字的语义。只要始终给变量标清 linearencoded,把每次变换的方向写出来,许多看似玄学的明暗问题就会变成一条可以逐站排查的流水线。

Gamma 矫正到底矫正了什么?从线性光走到屏幕亮度
https://cloudsir.top/posts/how-gamma-correction-works/
作者
CloudSir
发布于
2026-08-24
许可协议
CC BY-NC-SA 4.0
白平衡是怎么校正光源颜色的?从 Bayer 域增益到 RGB 域增益
LUT 是怎么改变颜色的?从一条曲线走进 RGB 立方体