一颗颜色经过 CCM、Tone Mapping 或 LUT 后,可能得到 (1.18, 0.42, -0.06)。在浮点处理中,这组数字完全合法;但普通目标 RGB 只允许每个通道位于 [0,1]。最直接的处理是把红色裁成 1、蓝色裁成 0,结果虽然能显示,通道比例和色相已经改变。
Gamut Mapping 处理的不是“数组越界”这么简单,而是:源空间能够表达的颜色,怎样映射到目标设备可复现的颜色集合中。
数值范围和色域不是同一个概念
RGB 数值只有结合基色、白点和传递函数才有颜色意义。同一个 (1,0,0) 在 sRGB、Display P3 和 Rec.2020 中代表不同的红色。一个颜色即使三个数都在 [0,1],从宽色域转换到窄色域后仍可能变成负值或大于 1。
数值合法,不等于目标屏幕能显示
浮点管线允许负值和大于 1 的值。色块预览必须裁剪,但旁边仍保留真实中间数值。
预览色块只能显示裁剪结果,所以不同的超范围 RGB 可能看起来完全一样。调试必须读数,不能只看屏幕。
预览必须裁剪到屏幕能显示的范围,因此肉眼无法区分 (1.1,0.4,-0.1) 与 (2.0,0.4,-1.0) 的真实越界程度。调试应保留浮点数值与 gamut mask,不能只看色块。
逐通道 Clip 为什么会改变颜色
逐通道裁剪是:
它简单、快速,也保证输出合法。但当只有某些通道碰到边界时,RGB 比例发生变化。两个原本具有不同高饱和程度的颜色还可能都堆到同一个边界颜色上,渐变出现扁平区。
整体缩放也不是万能答案
若所有通道除以最大值,可以保留非负 RGB 的比例:
它比逐通道 Clip 更容易保持方向,却会改变亮度;包含负值时还需要平移或其他策略。色彩映射通常需要在亮度、色度和色相之间明确取舍。
Clip、归一化和色度压缩不是一回事
三种方法都能产生 0–1 RGB,但它们对亮度和通道比例的影响不同。
这里的亮度轴压缩是教学模型。真实 gamut mapping 需要目标色域边界、白点、观察条件和更合适的感知空间。
组件里的“亮度轴色度压缩”保持加权亮度,沿 RGB 灰轴缩短颜色向量,直到通道进入范围。它是解释机制的教学模型,不是完整的感知 gamut mapper。
沿亮度轴压缩色度
令 是亮度,灰色点为 ,颜色相对灰轴的差为:
选择最大 使得:
位于目标 RGB 立方体内部。共同缩放差向量比逐通道裁剪更能保持色彩方向,但 RGB 空间并不感知均匀,所谓“保持色相”只是近似。
Python 教学实现:
import numpy as np
def compress_toward_gray(rgb): rgb = np.asarray(rgb, dtype=np.float64) y = np.dot(rgb, [0.2126, 0.7152, 0.0722]) direction = rgb - y scale = 1.0
for delta in direction: if delta > 0: scale = min(scale, (1.0 - y) / delta) elif delta < 0: scale = min(scale, (0.0 - y) / delta)
return y + max(0.0, scale) * direction如果 自身已经超出 [0,1],还需要先做 Tone Mapping 或亮度压缩。Gamut Mapping 不能替代动态范围映射。
硬裁剪与软压缩
硬裁剪在边界处突然改变导数。软压缩则从 knee 开始逐渐降低色度增量,把很宽的输入范围平滑挤进目标范围。
软压缩从 Knee 开始,而不是撞墙才裁剪
低色度保持不变,越靠近和超过目标边界,压缩逐渐增强,避免大量颜色堆在同一条边上。
Knee 太低会让普通颜色也去饱和;太高则只在边界附近急剧压缩,容易出现色度梯度断层。
一种教学曲线是:低于 knee 时保持 ;超过以后使用指数饱和:
它连续并逐渐接近上限。Knee 太低会让普通颜色也失去饱和度,太高则把压缩集中在边缘附近,产生可见的色度梯度变化。
色域边界不是一个固定圆
常见色度图上的三角形只展示二维色度,忽略亮度。真实设备色域是三维体积:某个色相在中等亮度可达到很高色度,在接近黑白两端时可用色度会缩小。
Lab、LCh、JzAzBz、ICtCp 等空间可以让亮度/色度/色相操作更直观,但没有任何空间在所有颜色和观看条件下完美感知均匀。实现还要处理白点、绝对/相对色度、HDR 亮度和目标显示特性。
映射必须知道目标设备
同一个颜色,换目标色域就可能越界
映射必须知道目标设备。Rec.2020 中合法的颜色,转换到 sRGB 后可能需要压缩。
百分比只是交互示意,不是标准色域面积数据。实际边界是三维且随亮度变化,不能只用二维三角形判断所有颜色。
“这个颜色是否越界”必须带目标:对 Display P3 合法的绿色,转换到 sRGB 可能超范围;对 Rec.2020 合法的颜色,当前实体显示器也未必真的能达到。
ICC、色彩管理系统或显示管线会提供源空间和目标空间信息。若只拿到一组三元数,却不知道基色与白点,就无法可靠做 gamut mapping。
Gamut Mapping 放在管线哪里
概念顺序通常是:
线性场景 RGB → CCM / 工作空间 → Tone Mapping / Look→ 转换到目标基色 → Gamut Mapping → OETF/Gamma → 输出实际系统可能把色域压缩与 Tone Mapping、LUT 或显示变换联合实现。关键不是函数名,而是清楚它在哪个空间、针对哪个目标、输入是否仍保留越界值。
过早在相机空间把 RGB clip 到 [0,1],会在目标转换之前丢失可恢复信息。某些负值经过矩阵转换后可能重新进入目标色域。
色相一定要绝对保持吗
多数映射希望高饱和颜色不要突然变色,但绝对保持色相并非任何情况下都最好。目标色域边界形状不规则,保持色相可能需要明显降低亮度或色度;轻微色相偏移有时能保留更多感知鲜艳度。
因此策略需要明确优先级:
- 产品颜色与标志通常优先保持色相;
- 自然图像可能优先保持亮度层次与相对饱和关系;
- 高光可能与 Tone Mapping 联合降低亮度和色度;
- 创意 Look 可以有意改变色相,但应和技术映射分开记录。
相对映射与绝对映射
逐像素映射只看当前颜色,容易让不同图像得到一致技术行为。图像自适应映射可以根据整幅图的色域占用调整压缩,让大多数未越界颜色少受影响;代价是同一输入色值会随画面内容变化,不利于严格复现。
局部映射还可能造成空间不连续:相邻像素因为局部统计不同而色度跳变。任何内容自适应方法都要检查时间稳定性和空间平滑。
与 LUT、CCM 和 Tone Mapping 的关系
- CCM 把相机基色响应转换到工作空间,可能产生负值;
- Tone Mapping 主要压缩亮度动态范围,但实现方式也会改变色度;
- 创意 LUT 可能把颜色推出目标色域;
- Gamut Mapping 面向明确目标,负责可复现范围;
- Gamma/OETF 负责编码,不负责解决色域越界。
一张输出 LUT 可以把多个步骤烘焙在一起,但生成 LUT 时仍必须知道目标色域、观察条件和越界策略。
最容易踩的坑
- 把
[0,1]数值范围等同于设备色域; - 在工作空间转换前过早 clip 负值;
- 逐通道 clip 后只检查“是否合法”,不检查色相和渐变;
- 只看二维色度三角形,忽略亮度维度;
- 在 Gamma 编码 RGB 中直接使用线性亮度公式;
- 不记录目标色域和白点;
- 把创意去饱和与技术 gamut mapping 混成不可分的 LUT;
- 静态图片正常,视频中自适应参数却不断呼吸。
验证清单
- RGB/CMY 高饱和基色和中性灰;
- 从色域内连续跨到色域外的渐变;
- 不同亮度下同一色相的最大色度;
- sRGB、Display P3、Rec.2020 等不同目标;
- 检查色相、亮度、色度和局部导数,而非只检查范围;
- 浮点越界值是否在映射前被保留;
- 映射后是否仍有负值、超过 1 或 NaN;
- 视频时间稳定性和局部空间连续性。
Gamut Mapping 的目标不是粗暴地让数组合法,而是让目标设备无法显示的颜色以可控方式进入可显示集合。Clip 选择简单,缩放选择比例,软压缩选择连续性;真正的系统还必须选择目标、颜色空间和感知优先级。把这些选择显式记录,颜色才不会在最后一步突然“莫名其妙变了”。