颜色空间与 Gamma
本篇位置:模块一 · 第 8 篇 | 前置:采样与滤波 | 预计阅读:25 分钟
学习目标
- 说清 gamma 存在的真实原因——它是感知编码(perceptual encoding),不是流传甚广的"CRT 显示器物理特性补偿"。
- 记住这条铁律:计算在线性空间(linear space)做,存储和显示才用 sRGB 编码。
- 会读写 sRGB 的分段式转换公式(OETF/EOTF),知道两段公式各自解决什么问题。
- 知道 YUV 与 RGB 的换算场景,以及它们在 Android 图像格式链路(
YUV_420_888、AHardwareBuffer)里出现的位置。
为什么不是所有 0.5 都一样亮
上一篇讲完了滤波核的形状与方向——盒式、高斯、锐化,该在哪个环节滤,已经搞清楚。但滤波说到底是对像素值做加权求和,这背后藏着一个从没被挑明的前提:参与加权的这些数值,到底是不是"线性"的?换句话说,把两个像素值加起来除以二,得到的真的是"这两处光量的平均值"吗?这一篇要把这个悬置的问题挑明,答案是:大多数时候不是——存在显示器、图片文件里的颜色数值,从来就不是线性的光量,这正是 gamma 编码要解决的问题,也是本篇开篇要讲清楚的第一件事。
先从一个具体的问题切入:8bit 无符号整数能表示 256 级灰度,如果直接把这 256 级线性地对应到 的线性光量(linear light,物理上真实的光子数量或辐射强度)上,会发生什么?人眼对亮度的感知不是线性的——在暗部,哪怕光量只变化很小一点,人眼也能分辨出明暗差异;但在亮部,同样大小的光量变化,人眼几乎察觉不到。如果编码时不管这个规律、每一级码值间隔的物理光量都相等,暗部会因为码值太稀疏而产生断层状的色带(banding)——人眼能分辨的暗部层次,远比这种均匀线性编码所能提供的密。
这个直觉可以直接用数字验证。lab_08_gamma.py 里的 srgb_eotf 把 256 级 8bit 码值解码成线性光量后统计发现:落在线性光量 (暗部前十分之一区间)的码值有 90 个,占全部 256 级的 35%;如果换成完全线性的编码方式(码值直接等于线性光量),同一区间只有 26 个码值,占比 10%。sRGB 编码把超过三分之一的码值都塞进了暗部,这不是巧合,而是有意为之的感知均匀编码(perceptually uniform encoding)——分配码值时优先照顾人眼分辨力更强的区间,而不是均匀地照顾物理光量的每一段。
这就是 gamma 存在的真实原因:它是一种感知编码策略,把有限的码值预算按人眼的分辨能力重新分配,而不是很多资料里流传的说法——"gamma 编码是为了补偿 CRT 显示器电子枪的非线性响应"。CRT 确实凑巧有一条接近 2.5 次方的响应曲线,sRGB 编码曲线也确实接近这条响应的反函数,这个历史巧合让"gamma 是为了迁就 CRT"的说法广为流传;但即使显示器换成了液晶、OLED,不再有 CRT 那种物理特性,gamma 编码依然是必须的,因为它解决的是有限码值下如何分配感知精度这个与显示技术无关的问题。
图:虚线是线性映射 作为参照;sRGB OETF 编码曲线整体在虚线上方——同样的线性光量输入,编码后的数值更高,尤其在暗部( 接近 0 处)抬升幅度最大,这正是把更多码值分配给暗部的直接体现;gamma 1/2.2 近似曲线形状与 sRGB OETF 接近但并不重合,两者的差异留到下一节细说。
sRGB 标准
上一节说清了"为什么要做非线性编码",这一节把公式钉死。sRGB 标准(IEC 61966-2-1 定义)是目前最主流的显示器与图像文件编码空间,它的正向编码——从线性光量到存储值——叫做光电转换函数(OETF, opto-electronic transfer function):
其中 是归一化到 的线性光量, 是编码后存进文件或帧缓冲的值。反过来,从存储值解码回线性光量的电光转换函数(EOTF, electro-optical transfer function)是:
两条公式都是分段函数,主体部分是幂函数(指数 2.4,对应上一节"照顾暗部分辨力"的动机),但在靠近 0 的一小段区间(编码值小于 0.0031308、解码值小于 0.04045)换成了纯线性段。这段线性区间不是随手加的:纯幂函数在 附近的斜率会急剧变化,用在极暗区域会让编码对传感器噪声、量化误差异常敏感;换成线性段之后,函数在 0 附近的斜率是有限值,既避免了噪声被无限放大,也保证了整条曲线严格单调、处处可逆。
落笔前我用 Python 验证了这组公式互为反函数:在 区间取 20 万个采样点,先编码再解码()的最大误差在 量级,属于机器精度;反过来先解码再编码()的最大误差约 ,略高于机器精度但依然远低于 8bit 量化步长(约 )四个数量级——这个微小偏差来自标准公布的两个阈值本身:,与解码阈值 只精确到小数点后 4 位一致,是公开标准里长年存在的已知量,不影响任何实际使用。
对比上一节图里出现的"gamma 1/2.2 近似"曲线(,不分段、没有低端线性段)——它是工程上图省事时常用的替代写法,和标准 OETF 长得很像,但差别集中在暗部:数值验证显示,输入线性光量 时两者相差 , 时只差 , 时两者重合,且近似曲线在暗部系统性偏亮。中间调和亮部两者几乎没有可见差别,但对需要精确色彩管理或暗部细节的场景(比如美术资产的最终校色),这个近似不该直接替代标准公式。
铁律:线性空间计算
前两节讲完了"是什么"和"怎么编码",这一节是全篇最重要的一条工程铁律,前面第 6、7 两篇反复出现的插值、卷积、滤波,全部要服从这条规则:计算在线性空间做,存储和显示才用 sRGB。原因很直接——sRGB 编码值不是线性的光量,对两个 sRGB 编码值做加权平均(lerp、双线性、卷积核加权求和,本质都是加权平均),得到的不是"两处光量的加权平均对应的编码值",而是一个物理意义错误的数字。
lab_08_gamma.py 的第二张图把这个错误做成了可对比的实验:红绿两色之间做一条渐变,一种做法是直接对 sRGB 存储值做 lerp(常见的、"看起来最直接"的写法),另一种做法是先把红绿两色解码到线性空间、在线性空间里 lerp、再编码回 sRGB。
图:上排是直接在 sRGB 空间对红绿两色做 lerp 的结果,中间出现一条明显发暗、发脏的过渡带;下排是先解码到线性空间再 lerp、再编码回 sRGB 的结果,过渡自然,没有暗带。两排用的是完全相同的红、绿端点和相同的插值参数 ,唯一区别是 lerp 发生在哪个空间。
暗带是这么来的:sRGB 编码曲线是凹函数(上一节的指数 ),红色分量从 1 掉到 0.5(sRGB 值)对应的线性光量降幅,和线性光量从 1 掉到 0.5 对应的 sRGB 值降幅,数值上并不对等——对编码值做线性插值,中间点的编码值确实是两端编码值的算术平均,但反解出来对应的线性光量,比"两端线性光量的算术平均"要低不少,亮度因此在过渡带中段系统性偏暗、偏脏。这不是这一张图独有的现象,而是本系列第 6 篇 lerp、第 7 篇卷积核只要作用在 sRGB 编码值上就一定会重现的系统性错误——光照计算的光量叠加、纹理 mipmap 生成时的均值滤波、抗锯齿的边缘混合,只要是加权平均类的运算,不转到线性空间就都会重复这条暗带背后的错误逻辑。
工程上不需要在每一处运算前后手写转换代码,GPU 硬件本身就支持这条铁律的自动化:纹理内部格式选 GL_SRGB8_ALPHA8(OpenGL ES)或 VK_FORMAT_*_SRGB(Vulkan,如 VK_FORMAT_R8G8B8A8_SRGB)之后,纹理单元在采样阶段——而且是在双线性/三线性过滤发生之前——就把 texel 值从 sRGB 解码成线性,shader 里拿到的已经是线性值,双线性插值天然就在正确的空间里进行,不会重现上图的暗带;写入侧同理,渲染目标(framebuffer/render target)用 sRGB 格式并开启对应开关(GL 是 GL_FRAMEBUFFER_SRGB,Vulkan 由 swapchain/attachment 的 SRGB 格式决定)之后,shader 输出的线性颜色会在写入前自动编码成 sRGB,不需要手写 pow()。这是"线性空间计算"这条铁律能落地成工程实践、而不只是纸面正确性的关键——硬件把转换嵌进了采样和写入两个必经步骤,几乎不产生额外开销。
YUV:视频与相机的世界
前三节都在 RGB 色彩空间内部打转,这一节换一个坐标系。视频编解码、摄像头传感器、屏幕采集这条链路,存的从来不是 RGB,而是 YUV(有时写作 YCbCr)——把颜色拆成一个亮度分量(luma,记 )和两个色度分量(chroma,记 、)。拆分的动机和第一节"人眼对暗部敏感"是同一类道理:人眼对亮度细节的分辨力,远高于对色彩细节的分辨力——同一张图,把亮度通道保持满分辨率、色度通道降采样到四分之一大小,肉眼几乎察觉不到差别。这个特性催生了色度子采样(chroma subsampling):4:4:4 表示亮度色度都不降采样;4:2:0(最常见)表示色度在水平和垂直方向各降采样一半——一个 2×2 像素块里,4 个亮度样本各自独立,但只共享 1 组色度样本,总采样数从 4:4:4 的 12(4 像素 × 3 分量)降到 6,存储/带宽直接省一半,这正是几乎所有视频编码格式(H.264/H.265 等)默认用 4:2:0 的原因。
RGB 与 之间的换算取决于用的是哪一代广播标准,系数不能混用。面向标清的 BT.601 定义:
面向高清的 BT.709(游戏和现代显示设备基本都在这个色域下)定义:
两组系数分别核对过:,——系数和恒为 1 是必须满足的约束,因为纯白色()换算后的亮度也必须是 1,否则换算本身就会改变画面的整体明暗。两组系数的差别集中在绿色权重(0.587 对 0.7152)和红色权重(0.299 对 0.2126)上,这是两代标准对应的显示设备荧光粉/传感器光谱响应不同导致的,色彩管理软件如果拿错了系数,转换出来的画面会整体偏色。需要澄清的是,上述两组系数在视频系统中作用于的是已经 gamma 编码的 存储值,得到的结果 (称作亮度信号或 luma)也是非线性的,与第三节强调的物理线性亮度不同。这是广播/视频标准出于带宽和成本优先的历史约定,属于第三节铁律的"有意为之的例外"。
这条链路在 Android 系统里有清晰的落点,和熟悉的 Framework/SurfaceFlinger 背景直接衔接:Camera2 API 通过 ImageReader 拿到的图像,格式常常就是 YUV_420_888(Android 定义的一种灵活 4:2:0 布局,兼容多种硬件的实际排布);MediaCodec 硬件解码出来的视频帧同样是 YUV 格式,很多情况下可以直接作为 YUV 图层交给硬件合成器(Hardware Composer)合成到屏幕上,不需要 CPU 先转成 RGB 再走一遍软件合成路径;而 AHardwareBuffer——SurfaceFlinger 的 GraphicBuffer 在 NDK 层的对应物——正是让相机传感器输出、视频解码输出、GPU 渲染结果这几种数据在不同硬件模块(ISP、编解码器、GPU、显示控制器)之间零拷贝流转的底层载体。换句话说,YUV 不是视频编解码领域一个孤立的历史遗留格式,它和色彩空间转换一起,贯穿在相机预览、视频播放、屏幕录制这几条 Android 图形链路的中段。
HDR 一瞥
最后简短提一句本篇没有展开、但迟早会碰到的方向。sRGB(以及近似它的 gamma 2.2)是为标准动态范围(SDR)设计的传递函数,目标亮度上限大致在 100 尼特(nit)左右;高动态范围(HDR)内容的峰值亮度能到 1000~10000 尼特,继续用 sRGB 那条曲线编码会在高光区严重浪费码值或直接削顶。为此出现了另一族传递函数:PQ(Perceptual Quantizer,SMPTE ST 2084)按人眼对绝对亮度的感知阈值直接编码到 10000 尼特,是一种"绝对亮度"编码;HLG(Hybrid Log-Gamma)则设计成在暗部/中间调与传统 gamma 曲线兼容、只在高光区切换成对数曲线扩展动态范围,目的是让不支持 HDR 的老设备也能"过得去"地显示同一份信号。Android 通过 Display.HdrCapabilities、色彩空间标识(DataSpace,如 BT2020_PQ/BT2020_HLG)、Vulkan 侧的 VK_EXT_hdr_metadata 等机制暴露 HDR 管线能力,HDR 游戏和 HDR 视频播放已经是现实存在的场景而非纸面特性——具体的显示管线细节留到后续模块,这里只需要知道:PQ/HLG 和本篇的 sRGB OETF/EOTF 是同一类问题(把线性光量编码成有限码值)在不同亮度范围下给出的不同答案。
🎯 岗位关联
超分辨率、插帧算法的输入输出通常是屏幕截帧或渲染中间结果,这些数据在磁盘、显存里几乎总是以 sRGB 编码值存放。如果 warp(运动补偿位移采样)或多帧混合(temporal blend/accumulation)直接在这些 sRGB 编码值上做——本质上和第三节的红绿渐变实验是同一个错误——画面会出现两类可观测的缺陷:运动物体边缘、遮挡/去遮挡区域(第 6、7 篇讲过的插帧鬼影来源)附近出现发暗发脏的过渡带,以及整体亮度随着累积帧数漂移(每一次 blend 都在错误空间里重复一次同样的错)。「进算法前把输入转成线性、算法内部全程线性、出算法前再编码回 sRGB(或目标显示空间)」是这类算法 code review 时必须逐行确认的一条——尤其当 blend/warp 逻辑分散在多个 pass、多个 shader 里时,任何一处漏转都会在最终画面上留下痕迹。
另一个岗位相关的实际坑出现在截帧分析阶段:同样是 8bit RGBA 纹理,UNORM 格式(如 VK_FORMAT_R8G8B8A8_UNORM)把存储的整数值线性地映射到 、不做任何 gamma 解码,而 SRGB 格式(VK_FORMAT_R8G8B8A8_SRGB)在采样时会自动过一遍第三节讲的 EOTF。如果分析工具或人工核对时把一张按 SRGB 语义写入的纹理当 UNORM 读(或反过来),读出来的数值和真实线性光量会有系统性偏差,由此得出"这里亮度不对""这个 pass 有问题"之类的错误结论——先确认纹理格式的 sRGB 语义,是排查颜色相关画面问题时第一步要做、却很容易被跳过的检查。
小结
公式卡
| 公式 | 定义 |
|---|---|
| sRGB OETF(编码) | ; |
| sRGB EOTF(解码) | ; |
| BT.601 亮度 | |
| BT.709 亮度 |
必须在线性空间做的操作清单
- 光照计算(光量叠加、BRDF 求值)
- 颜色/透明度混合(alpha blend、加法混合)
- 任何插值(lerp、双线性、双三次、mipmap 生成的均值滤波)
- 卷积类滤波(模糊、锐化、抗锯齿的边缘混合)
- 多帧累积(TAA、超分/插帧的 temporal blend)
延伸阅读
- John Novak,What Every Coder Should Know About Gamma:https://blog.johnnovak.net/2016/09/21/what-every-coder-should-know-about-gamma/
- NVIDIA,GPU Gems 3, Chapter 24: The Importance of Being Linear:https://developer.nvidia.com/gpugems/gpugems3/part-iv-image-effects/chapter-24-importance-being-linear
