把一个灰色像素写成 0.5,它代表“一半的光”吗?在大多数普通图片里,不代表。
如果 0.5 是线性光,它表示最大光强的一半;如果它是 sRGB 文件里的编码值,解码后的线性光强只有约 0.214。两个数字长得一样,物理含义却差了一倍多。许多“图片怎么突然发黑”“透明叠加为什么有黑边”“CCM 明明没写错却偏色”的问题,根源都在这里。
Gamma 矫正不是给图片随手加亮,而是在两种表示之间翻译:
- 编码(encode):线性光 → 适合保存、传输的非线性数值 ;
- 解码(decode):编码数值 → 可用于光照、混合和多数物理计算的线性光 。
本文默认数值归一化到 ,主要讨论 SDR 的 sRGB。显示设备、ICC 色彩管理和 HDR 会在边界部分交代,但不展开成标准手册。
同一个 0.5,为什么不是同一种亮度
相机传感器在未饱和的理想区间里,收到两倍光,原始信号大致也变成两倍。这样的数值叫线性光。渲染器里的光照、曝光、卷积和颜色混合通常都期待这种数据。
但人眼对暗部的小变化更敏感。若把有限的 256 个 8-bit 码值均匀分给物理光强,很多码值会花在我们不太敏感的亮部,暗部却更容易看到断层。非线性编码把暗部“拉开”、亮部“压紧”,让码值分配更贴近视觉敏感度。
先看最常用来建立直觉的幂函数近似:
这是编码。反方向是:
这是解码。当 、 时,编码值约为 ;将它再做 ,就回到 。指数互为倒数,方向不能靠函数名猜。
Gamma 曲线实验室
先选方向:编码把线性光写成文件数值;解码把文件数值还原为线性光。
在实验里切到“解码”,曲线会翻到对角线下方。输入滑块同样是 0.5,此时输入的含义已从“线性光”变成“文件编码值”。
“Gamma”为什么总是说乱
历史上,CRT 显示器的输出光强近似服从输入电压的幂函数。后来,人们用相反方向的曲线预先编码图像。再后来,标准把拍摄、编码、显示分别定义得更精确,但“Gamma”仍被口语化地用来指其中任何一步。
更清楚的术语是:
| 名称 | 方向 | 回答的问题 |
|---|---|---|
| OETF | 场景线性光 → 非线性信号 | 相机怎样把光编码成信号? |
| EOTF | 非线性信号 → 显示光 | 显示器怎样把信号变成光? |
| OOTF | 场景光 → 显示光 | 整套系统最终怎样再现对比度? |
sRGB 的编码函数常被叫作 OETF,严格说它是以显示参考条件为目标的编码传递函数。工程代码里最稳妥的做法仍是把函数直接命名为 srgb_encode 和 srgb_decode,并在参数名里写清 linear 或 encoded。
sRGB 不是一条简单的 2.2 曲线
“sRGB 大约是 Gamma 2.2”适合心算,却不能代替准确实现。sRGB 在接近黑色处使用线性段,避免纯幂函数在零点附近出现不适合实现的斜率;其余部分使用指数 。
编码(线性光 → sRGB 值 ):
解码(sRGB 值 → 线性光 ):
两个阈值属于不同方向,不能混用。下面是可直接测试的 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.18 当作参考线性中灰。正确路径编码一次、显示端解码一次,来回后仍是 0.18。漏一步或多做一步时,代码可能完全没有报错,画面却整体发黑或发灰。
为什么非线性编码能更有效地使用位深
8-bit 只能存 个整数。线性编码相邻码值的光强间隔始终是 ;sRGB 编码相邻码值对应的线性光间隔并不相等:暗部小,亮部大。
这不会凭空增加信息,也不会让 8-bit 变成 10-bit。它只是重新安排现有的刻度,把更多刻度放到人眼更容易看出差异的位置。
256 个码值花在哪里?
同样的位深,sRGB 会把更多码值留给暗部;切换 10-bit 观察量化阶梯变细。
放大区间:0.03–0.13 线性光。竖条相同不一定是组件坏了:那正是多个输入落入同一码值。
把观察区间移到暗部,sRGB 的可用码值明显多于线性 8-bit;移到亮部,关系会反过来。切到 10-bit 时总码值变成 1024,编码方式与位深的作用也就分开了:前者决定刻度放在哪里,后者决定一共有多少刻度。
量化应尽量放在管线末端。在线性域和编码域之间反复转换并四舍五入,会累计误差与色带;若中间缓冲能使用浮点或更高位深,就不要每一步都压回 8-bit。
最容易看见的错误:在 sRGB 数值里混色
黑与白各占一半,直接平均编码值会得到 0.5。它看似“数学上的正中间”,解码后的光强却只有约 0.214,不是两束光平均应得的 0.5。
正确流程是:
线性光混色实验室
左边直接平均文件里的 sRGB 数字;右边先解码到线性光,混合后再编码。
直接在 sRGB 数值中混合
#808080在线性光中混合
#BCBCBC在默认的黑白 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 不是一件事
它们都可能让画面“看起来更亮”,数学含义却不同:
- 曝光在线性光域乘一个比例。增加一档通常意味着 ;
- Gamma 编码/解码在两种数值表示之间转换,正常往返不应改变代表的光;
- Tone Curve / Tone Mapping有意改变场景亮度关系,可能包含暗部 toe、高光 shoulder 和局部对比度。
例如把线性光 0.18 做 sRGB 编码得到约 0.461,不是把像素“提亮”了。只要显示端正确解码,它仍代表 0.18 的光。若把编码后更大的数字误认为曝光提升,就混淆了数值与其含义。
CCM 为什么通常不能直接作用于 sRGB
CCM 的矩阵乘法描述的是线性通道响应的组合:
非线性编码后,一般有:
因此,对普通 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 或传递函数之前,先回答:
- 当前数字代表线性光,还是某种编码值?
- 如果是编码值,具体是 sRGB、Rec.709、Log、PQ,还是别的曲线?
- 下一步算法期待哪个域?曝光、光照、卷积、混色和多数 CCM 通常期待线性光。
- 读取库、GPU 纹理和 framebuffer 是否已经自动转换?
- 中间结果会不会被过早裁剪或量化?
- 写出文件时,像素数据与色彩配置是否一致?
Gamma 最难的地方不是公式,而是追踪数字的语义。只要始终给变量标清 linear 与 encoded,把每次变换的方向写出来,许多看似玄学的明暗问题就会变成一条可以逐站排查的流水线。