09. 色域、YUV 与 HDR
09. 色域、YUV 与 HDR
本篇位置:模块一 · 第 9 篇(终篇) | 前置:Gamma 与线性空间 | 预计阅读:50 分钟
你已经在用它了
Dataspace 你天天在设、在传、在查。它不是一个枚举,是三个独立位域拼起来的(frameworks/native/libs/nativewindow/include/android/data_space.h):
/**
* Standard aspect
*
* Defines the chromaticity coordinates of the source primaries in terms of
* the CIE 1931 definition of x and y specified in ISO 11664-1.
*/
ADATASPACE_STANDARD_MASK = 63 << 16,
/**
* Primaries: x y
* green 0.300 0.600
* blue 0.150 0.060
* red 0.640 0.330
* white (D65) 0.3127 0.3290
*
* Use the unadjusted KR = 0.2126, KB = 0.0722 luminance interpretation
* for RGB conversion.
*/
ADATASPACE_STANDARD_BT709 = 1 << 16,
// ...
ADATASPACE_STANDARD_BT2020 = 6 << 16,
ADATASPACE_TRANSFER_ST2084 = 7 << 22, // PQ
ADATASPACE_TRANSFER_HLG = 8 << 22,
ADATASPACE_RANGE_FULL = 1 << 27,
ADATASPACE_RANGE_LIMITED = 2 << 27,所以 ADATASPACE_SRGB 其实是三段拼起来的:STANDARD_BT709 | TRANSFER_SRGB | RANGE_FULL。
三个问题:
- 为什么要拆成三个独立字段? 它们各管什么,为什么能自由组合?
BT709注释里那六个数(0.640/0.330、0.300/0.600、0.150/0.060)是什么? 六个数怎么就"定义"了一个色域?RANGE_LIMITED为什么要浪费码值,8 位只用 16~235?
第 8 篇已经讲完了中间那个字段(TRANSFER)。这一篇补齐另外两个,并且把 HDR 讲清楚。
学完你会什么
- 说清"色域"到底是什么,以及六个数为什么足以定义它。
- 说清 XYZ 是什么、为什么色域转换都要绕道它,并手推一次转换矩阵。
- 说清
0.2126 / 0.7152 / 0.0722这三个熟悉的数字是从哪来的。 - 说清 YUV 为什么存在、4:2:0 省了多少、limited range 的历史原因。
- 说清 PQ 和 HLG 的根本区别,以及它们各自适合什么场景。
- 拿到一个
Dataspace值,能说出它意味着什么、和另一个之间要做哪些转换。
1. 三个字段,一张地图
先把整篇的骨架摆出来。一份图像数据要被正确解释,必须知道三件事:
| 字段 | 回答的问题 | 本系列在哪讲 |
|---|---|---|
| STANDARD | 这里的"红"到底有多红?(色域) | 本篇第 2~4 节 |
| TRANSFER | 码值和光量之间怎么换算?(传递函数) | 第 8 篇,本篇第 7 节补 HDR |
| RANGE | 0~255 全用,还是只用 16~235? | 本篇第 6 节 |
三者正交,所以才拆成独立位域——这就是问题 1 的答案。BT.2020 的色域可以配 PQ 曲线,也可以配 HLG;BT.709 的色域可以是 full range 的 RGB,也可以是 limited range 的视频。任何一个字段搞错,画面都会不对,但错法各不相同:
- STANDARD 错 → 颜色偏(红的不够红、绿的发荧光)
- TRANSFER 错 → 明暗关系整体不对(发灰或死黑)
- RANGE 错 → 对比度不对(黑的不够黑、白的发灰)
2. 色域:六个数为什么够
先问:颜色是什么
人眼里有三种感知颜色的细胞(视锥细胞),分别对长、中、短波长敏感。任何进入眼睛的光,最终都被压缩成三个数——这三种细胞各自的响应强度。
这件事有个重要推论:两束光谱完全不同的光,只要让三种细胞产生相同的响应,人眼就分辨不出。屏幕之所以能用红绿蓝三个灯模拟出各种颜色,全靠这一点。
CIE 在 1931 年把"三个响应"标准化成了三个数 ,再归一化成两个坐标:
把所有能看到的颜色画在 平面上,就是那张著名的马蹄形图。
色域 = 图上的一个三角形
图:黑色马蹄形边界上是最纯的单色光,内部是人眼能看到的全部颜色。三个三角形是三套标准各自能表达的范围。
一块屏幕只有红绿蓝三个原色(primaries)。 它能显示的颜色,是这三个原色按不同比例混合出来的——几何上就是以这三个点为顶点的三角形内部。三角形之外的颜色,这块屏幕物理上做不出来。
所以定义一个色域,只需要说清三个顶点在哪:
这就是问题 2 的答案——六个数就是三角形的三个顶点坐标。
白点:第七个数
还差一件事:"R=G=B=1 时应该是什么白"。这叫白点(white point),也是一个 坐标。上面三个色域都用 D65(),大致对应正午日光。
这也解释了 ColorSpace::sRGB() 那个构造参数的结构——三个原色 + 一个白点 + 传递函数参数,一个色彩空间就定死了:
return {
"sRGB IEC61966-2.1",
{{float2{0.640f, 0.330f}, {0.300f, 0.600f}, {0.150f, 0.060f}}}, // 三个原色
{0.3127f, 0.3290f}, // 白点 D65
{2.4f, 1 / 1.055f, 0.055f / 1.055f, 1 / 12.92f, 0.04045f, 0.0f, 0.0f}
};3. 色域转换:绕道 XYZ
现在的问题是:一张 sRGB 的图,怎么在 Display P3 的屏幕上正确显示?
关键认识:(R, G, B) = (1, 0, 0) 这组数字本身没有颜色含义。它的意思是"把这块屏幕的红色原色开到最亮"——而 sRGB 的红和 P3 的红是不同的颜色(三角形顶点位置不一样)。
所以转换的思路是:
sRGB 的 (R,G,B) → XYZ(绝对的、跟设备无关的颜色) → P3 的 (R',G',B')XYZ 是通用中间语言。 任何色域都能转进 XYZ,也都能从 XYZ 转出来。
矩阵怎么来
ColorSpace::computeXYZMatrix(primaries, whitePoint) 干的就是这件事:从三个原色和白点,解出 RGB→XYZ 的 3×3 矩阵。
它的思路不复杂:矩阵的每一列应该是"某个原色开到最亮时的 XYZ 值"(这正是第 2 篇讲的"矩阵的列 = 基向量的去处"!)。原色的 已知,缺的只是各自的亮度权重,而白点给了三个约束(R=G=B=1 时必须得到白点的 XYZ)——三个未知数三个方程,解出来就完了。
用 AOSP 那套算法算 sRGB,得到:
盯住中间那一行。
这三个数你见过无数次——它们是 BT.709 的亮度权重,Dataspace 的注释里就写着 "KR = 0.2126, KB = 0.0722",你写过的每个 luma = dot(rgb, vec3(0.2126, 0.7152, 0.0722)) 用的都是它。
它们不是拍脑袋定的经验值,就是 RGB→XYZ 矩阵的第二行。 因为 这个分量在 CIE 的定义里就代表亮度——所以"算亮度"和"算 XYZ 的 Y 分量"本来就是同一件事。绿色权重最大(0.7152),是因为人眼对绿光最敏感。
两个矩阵一乘就完事
第 2 篇讲过的"多个变换预先乘成一个",在这里又用了一次——运行时只需要一次 3×3 乘法。SurfaceFlinger 里那个 mat4 colorTransform 就是这类矩阵的容身之处。
对角线接近 1、非对角接近 0,说明 sRGB 和 P3 挺接近;非对角项越大,转换时颜色改动越多。
⚠️ 这个矩阵必须作用在线性值上。 第 8 篇的铁律在这里同样成立:完整流程是 EOTF 解码 → 乘矩阵 → OETF 编码。直接拿编码值乘这个矩阵是错的,而且错得不明显——颜色会有细微偏差,很难靠肉眼定位。
4. 色域外的颜色怎么办
P3 的图放到 sRGB 屏幕上,会有一部分颜色落在 sRGB 三角形之外——物理上显示不出来。
处理办法有两类:
- 裁剪(clip):超出的分量直接钳到边界。简单、快,但饱和区域会丢层次(几种不同的饱和红全变成同一个红)。
- 色域映射(gamut mapping):整体压缩一下,牺牲一点饱和度换取层次保留。
裁剪还有个隐蔽后果:转换后的分量可能变成负数。上面那个矩阵有 这样的项,别的色域转换会有明显的负数——负的光量没有物理意义,必须处理,ColorSpace 里那个 mClamper 就是干这个的。
5. YUV:把亮度和颜色拆开
Dataspace 的第三个字段和 YUV 直接相关,先说 YUV 本身。
动机:人眼对亮度变化的分辨力,远高于对颜色变化的分辨力。既然如此,颜色信息就可以少存一点。
RGB 里三个通道各自都混着亮度,没法单独砍。所以先换个表示法:
是亮度,、 是"蓝色差"和"红色差"。
图: 平面几乎包含了全部可辨认的内容,两个色度平面看起来平淡得多。最右边是 按 4:2:0 抽样(横竖各减半)之后再放大回去的样子——几乎看不出差别。
4:2:0 就是"色度横竖各减半"。省了多少?
| 每像素字节 | |
|---|---|
| RGB888 | 3 |
| YUV 4:4:4 | 3 |
| YUV 4:2:0 |
省一半。 而且几乎看不出画质损失。所有视频编解码器、相机输出、硬件合成器都在用它——你在 libgui 里见到的 HAL_PIXEL_FORMAT_YCBCR_420_888 就是这个。
顺带解释一个混淆:YUV、YCbCr、YPbPr 严格说是不同场景下的名字(模拟/数字、分量视频),日常代码里基本混用。真正要小心的是系数:BT.601(标清,)和 BT.709(高清,)用错会让画面明显偏色。这也是
Dataspace一定要带 STANDARD 字段的原因之一——光看像素数据,你无法知道它当初是用哪套系数转的。
6. RANGE:为什么只用 16~235
Dataspace 的注释写得很直白:
Limited range uses values 16/256*2^b to 235/256*2^b for Y, and
1/16*2^b to 15/16*2^b for Cb, Cr, R, G and B ...
E.g. For 8-bit-depth formats:
Luma (Y) samples should range from 16 to 235, inclusive
Chroma (Cb, Cr) samples should range from 16 to 240, inclusive这就是问题 3 的答案:这是模拟电视时代留下的遗产。当年的信号需要在有效画面之外留出余量(headroom / footroom),用来放同步信号,也用来容纳滤波后可能轻微过冲的值。数字化的时候这个约定被原样继承了下来,视频领域至今如此。
结果就是一个极其常见的 bug:
| 情况 | 症状 |
|---|---|
| limited range 的数据被当成 full range | 对比度过高——黑的死黑,白的过曝,暗部亮部细节被削平 |
| full range 的数据被当成 limited range | 对比度不足——黑的发灰,白的不够白,整体像蒙了层雾 |
这是"半懂地带"最典型的一个坑:不会崩溃、不会报错,只是"这个视频看起来怪怪的"。而且它常常出现在 RGB↔YUV 转换、或者视频图层送进合成器的边界上——只要 Dataspace 在某一层被丢掉或写错,就会发生。
7. HDR:PQ 与 HLG
最后一块。
SDR 的隐含假设
第 8 篇讲的 sRGB 有个从不明说的前提:编码值 1.0 对应的屏幕峰值亮度,大约是 100 尼特(nit,)。这是当年 CRT 显示器的典型亮度。
现实世界的亮度范围远不止如此:夜空约 0.001 尼特,室内照明几百尼特,晴天的云和阳光下的反光可以到几千甚至上万尼特。SDR 把这么大的范围硬压进了 0~100 尼特——所以画面里的"太阳"和"白纸"是同一个白。
HDR 要做的事:让编码值覆盖更大的真实亮度范围。这需要一条新的传递函数。
图:纵轴是对数刻度的真实亮度。同一个编码值,在三条曲线下对应的亮度差了两个数量级。
PQ 与 HLG 的根本区别
PQ(ST 2084,TRANSFER_ST2084) | HLG(TRANSFER_HLG) | |
|---|---|---|
| 编码的是 | 绝对亮度 | 相对亮度 |
| 码值 0.5 的含义 | "请发出约 92 尼特的光",跟屏幕无关 | "发出你峰值亮度的某个百分比" |
| 范围 | 最高 10000 尼特 | 由屏幕峰值决定 |
| 曲线设计依据 | 人眼在各亮度下的最小可分辨差异 | 暗部与传统 gamma 兼容,亮部转对数 |
| 向下兼容 SDR 屏 | ❌ 不兼容,必须做 tone mapping | ✅ 基本兼容,SDR 屏直接播放大致能看 |
| 典型场景 | 流媒体、蓝光、游戏 HDR | 广播电视直播 |
HLG 的名字就说明了设计:Hybrid Log-Gamma——混合了 Gamma(暗部)和 Log(亮部)。暗部沿用传统 gamma 曲线,所以老设备直接播放不会错得离谱;亮部切换成对数扩展动态范围。这个"向下兼容"的特性,对必须同时服务新旧电视的广播是刚需。
PQ 不做这个妥协,换来的是绝对性:内容制作时说"这里要 1000 尼特",任何 PQ 屏幕都会尽力发出 1000 尼特。代价是遇到峰值不够的屏幕,必须做一次 tone mapping 把超出的部分压回来——这是 M2 第 8 篇的主题。
绝对 vs 相对,是理解 HDR 的关键分水岭。 很多 HDR 相关的困惑("为什么同一个片子在两台电视上亮度差这么多""为什么 HDR 内容截图之后变得灰蒙蒙"),追到底都是这条线两侧的东西被混着处理了。
回到源码
问题 1:为什么拆成三个独立字段?
因为色域、传递函数、码值范围三者正交,可以自由组合:BT.2020 色域可配 PQ 也可配 HLG,BT.709 色域可以是 full range 的 RGB 也可以是 limited range 的视频。拆成位域才能表达所有合法组合。三者各自错了的症状不同:色域错→偏色,传递函数错→明暗关系错,range 错→对比度错。
问题 2:那六个数是什么?
CIE 1931 色度图上三个原色的 坐标——也就是这套标准能表达的那个三角形的三个顶点。加上白点,一个色域就定死了。computeXYZMatrix() 从这七个数解出 RGB→XYZ 矩阵,而那个矩阵的第二行,就是你天天在用的 0.2126 / 0.7152 / 0.0722。
问题 3:RANGE_LIMITED 为什么浪费码值?
模拟电视时代的遗产:有效画面之外要留余量放同步信号、容纳滤波过冲。数字化时原样继承,视频领域沿用至今。搞错的症状是对比度整体不对——不崩溃、不报错,只是"看起来怪怪的"。
以后再看到一个 Dataspace,你应该能把它拆成三段读:色域是什么形状的三角形、传递函数是哪条曲线、码值范围是哪一段。而两个 Dataspace 之间的转换,就是这三件事各做一次换算——每一件都不能漏,漏了任何一件都会得到一张"说不上哪不对"的画面。
自测
1. sRGB 和 Display P3 的白点相同,红原色不同。同样的 (1, 1, 1),两块屏幕显示的白一样吗?(1, 0, 0) 呢?
(1,1,1) 一样(白点相同就是这个意思);(1,0,0) 不一样——P3 的红原色在色度图上更靠外,是一个更饱和的红。
这正是"RGB 数值本身没有颜色含义"的直接体现:它只是"把这块屏幕的原色开到多亮",而不同标准的原色本来就是不同的颜色。
2. 0.2126 / 0.7152 / 0.0722 这三个数从哪来?
是 sRGB(BT.709)RGB→XYZ 矩阵的第二行。
CIE 定义里 分量就代表亮度,所以"算亮度"和"算 XYZ 的 Y 分量"是同一件事。绿色权重最大,是因为人眼对绿光最敏感。
它们不是经验值,是从三个原色和白点解出来的。换一套原色(比如 BT.601 的),这三个数就变成 。
3. YUV 4:2:0 相比 RGB888 省了多少?为什么几乎看不出画质损失?
省一半:RGB888 每像素 3 字节,4:2:0 是 字节。
看不出损失,是因为人眼对颜色变化的空间分辨力远低于对亮度的。 平面保持原分辨率,被抽掉的只是色度——而色度本来就"看不太清"。
4. 一个视频图层画面发灰、黑色不够黑。最可能是哪个字段错了?
RANGE。
full range 的数据被当成 limited range 处理时,本该是 0 的黑被映射到 16 附近、255 的白被映射到 235 附近,对比度被压缩——黑发灰、白不够白。
反过来(limited 当成 full)症状是对比度过高、暗部亮部细节被削平。
对照另外两个字段:色域错会偏色,传递函数错会让明暗关系整体不对,症状不一样。
5. PQ 和 HLG 最根本的区别是什么?为什么广播电视偏爱 HLG?
PQ 编码绝对亮度,HLG 编码相对亮度。
PQ 的码值 0.5 意味着"发出约 92 尼特",跟屏幕无关;HLG 的码值意味着"发出你峰值的某个百分比"。
广播偏爱 HLG,是因为它向下兼容:暗部沿用传统 gamma 形状,同一路信号送到老的 SDR 电视上直接播放大致能看。广播必须同时服务新旧设备,不能为 HDR 单开一路信号。
PQ 不做这个妥协,遇到峰值不足的屏幕必须靠 tone mapping 压缩。
6. 做 sRGB → Display P3 转换时,直接拿 8 位码值乘那个 3x3 矩阵,对吗?
不对。 色域转换矩阵必须作用在线性值上。
完整流程是三步:EOTF 解码到线性 → 乘色域转换矩阵 → OETF 编码回码值。
直接乘编码值不会崩溃,只会产生细微的颜色偏差——这是第 8 篇那条铁律在色域转换上的又一次体现,也是最难靠肉眼定位的一类错误。
小结
| 概念 | 一句话 | 在源码里 |
|---|---|---|
Dataspace 三段 | 色域 / 传递函数 / 码值范围,三者正交 | STANDARD | TRANSFER | RANGE |
| 色域 | 色度图上的一个三角形 | 三个原色的 + 白点 |
| XYZ | 跟设备无关的通用中间语言 | getRGBtoXYZ() / getXYZtoRGB() |
| 转换矩阵怎么来 | 矩阵的列 = 原色的 XYZ;白点给出约束 | computeXYZMatrix() |
| 亮度权重的来历 | RGB→XYZ 矩阵的第二行 | 0.2126 / 0.7152 / 0.0722 |
| 色域转换 | 两个矩阵预乘成一个 3×3 | SF 的 mat4 colorTransform |
| ⚠️ 前提 | 矩阵必须作用在线性值上 | EOTF → 矩阵 → OETF |
| 色域外 | 裁剪(丢层次)或色域映射(丢饱和度) | mClamper |
| YUV | 亮度和色度分开,色度可以少存 | 4:2:0 省一半 |
| 系数陷阱 | BT.601 与 BT.709 用错会偏色 | 所以必须带 STANDARD 字段 |
| limited range | 模拟电视时代的余量遗产 | 16~235;错了对比度整体不对 |
| PQ | 绝对亮度,最高 10000 尼特,不兼容 SDR | TRANSFER_ST2084 |
| HLG | 相对亮度,暗部兼容传统 gamma | TRANSFER_HLG,广播首选 |
延伸阅读
- GAMES101 Lecture 20:Color and Perception:https://games-cn.org/intro-graphics/
- Charles Poynton《Color FAQ》(色彩工程的经典问答):https://poynton.ca/ColorFAQ.html
- ITU-R BT.2100(PQ 与 HLG 的正式定义):https://www.itu.int/rec/R-REC-BT.2100
- Android 开发者文档《HDR video playback》:https://developer.android.com/media/grow/hdr-playback
- AOSP 源码:
frameworks/native/libs/nativewindow/include/android/data_space.h、frameworks/native/libs/ui/ColorSpace.cpp

觉得有用?关注公众号「阿豪讲Framework」
Android 系统开发,新文章第一时间推送。