窗外阳光比室内阴影亮几百甚至几千倍,相机的浮点处理中间结果也可能远远超过 1.0;屏幕和普通图片却只能接收一个有限范围。直接裁剪,高光会变成一片没有层次的白;把曝光整体降下来,窗外回来了,室内又沉进黑暗。
**Tone Mapping(色调映射)要解决的不是“怎样让图片亮一点”,而是怎样把很宽的场景亮度关系,重新安排进较窄的输出范围。**它需要压缩动态范围,同时尽量保住中灰、局部对比度、颜色关系和视觉意图。
本文默认输入是线性、场景参考的 HDR RGB,输出先归一化到 。这里讲的是算法直觉和可验证的教学实现,不把某条 S 曲线冒充商业相机或电影系统的完整色彩管线。
曝光移动整把尺子,Tone Mapping 改变刻度间距
线性曝光可以写成:
增加一档,所有亮度都乘 2;减少一档,所有亮度都除以 2。像素之间的比例没有改变:原来相差 16 倍,调整后仍相差 16 倍。曝光能选择“哪一段亮度进入屏幕”,却不能同时把相差十几档的所有内容都塞进 。
Tone Mapping 则使用非线性函数:暗部、中间调和高光的斜率不同。它会改变亮度比例,因此也会改变画面对比度。这不是误差,而是它的任务。
HDR 亮度尺与管线
曝光移动整把尺子;Tone Mapping 改变尺子的间距,把高光压进输出范围。
仅调曝光可以救回高光,却会让中灰与暗部一起下降;压缩曲线能重新安排亮度关系。
实验中的直接裁剪相当于:
所有大于 1 的值都变成相同的白,高光内部的比例彻底丢失。注意,浮点数超过 1 并不等于已经“坏掉”;只要没有过早 clamp,后续 Tone Mapping 仍能从 1.2、3.0 和 10.0 中恢复不同层次。传感器在采集时已经饱和则不同:原始信息没有记录下来,任何曲线都不能凭空还原。
最简单的全局基线:Reinhard
经典的全局 Reinhard 算子可以简化成:
当 很小时,,暗部近似保持线性;当 增大,输出逐渐靠近 1,却不会越过 1。例如输入 1 映射到 0.5,输入 4 映射到 0.8,输入 99 映射到 0.99。
它的优点是单调、连续、没有参数也很难炸掉;缺点同样明显:输入 1 也到不了输出白,整体容易显得灰,场景白点不够可控。带白点的常见变体是:
这样 时恰好映射到 1。超过白点的值仍需明确如何处理,不要靠整数溢出“自动解决”。
Tone Curve 编辑器
Reinhard 是可推导的基线;教学 Filmic 用 toe 与 shoulder 展示暗部和高光如何分别塑形。
这里的 Filmic 是为解释形状设计的教学曲线,不是 ACES 官方 RRT/ODT,也不应当用“ACES”给它命名。
拖动输入点时,实验还显示局部斜率。斜率越小,相邻输入被压得越近,局部对比度越弱;斜率越大,细微亮度差越容易被看见。读一条 tone curve,不能只看它经过哪些点,还要看各区段的斜率。
Toe、线性段和 Shoulder 在分工
摄影与电影风格曲线常被概括为三个区域:
- Toe 位于暗部。它决定黑位附近怎样压缩或抬起,影响阴影层次和“黑是否扎实”;
- 近似线性段覆盖重要的中间调。它承担主要对比度,肤色和常见物体大多希望在这里稳定;
- Shoulder 位于高光。它让曲线逐渐变平,使明亮物体柔和地靠近显示白,而不是撞墙裁剪。
“Filmic”描述的是这类设计语言,不对应唯一公式。上面的互动组件使用了便于解释的教学函数:先用有理函数形成 toe,再用指数饱和形成 shoulder。它有意让参数含义直观,不是 ACES 官方 RRT/ODT,也不是任何相机厂商的胶片模拟。
这一区分很重要。互联网上经常把带肩部的 S 曲线都叫作 “ACES Filmic”,但真正的 ACES 显示流程包含色彩空间、渲染变换、输出设备变换、参考观看条件等一整套定义,不能用一条近似曲线替代。
一个完整的全局流程先要确定曝光
即使使用同一条曲线,输入尺度不同,结果也会不同。工程中常先计算场景或图像的代表亮度,再做曝光归一化。例如用对数平均亮度:
然后把它映射到某个 key value:
对数平均不会让少量极亮像素完全支配测量,但依然不是万能测光。大面积雪景、夜景灯牌、人物逆光需要不同的视觉意图。自动曝光决定尺度,Tone Mapping 决定压缩形状;两者相关,却不是同一个模块。
下面是一个最小 NumPy 实现,明确把亮度映射和颜色缩放分开:
import numpy as np
def luminance(rgb): return ( 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2] )
def reinhard_luminance(rgb, key=0.18, eps=1e-6): """输入为线性 HDR RGB;返回仍是线性、显示参考的 RGB。""" rgb = np.asarray(rgb, dtype=np.float32) y = luminance(rgb) log_average = np.exp(np.mean(np.log(eps + y))) scaled_y = key * y / max(log_average, eps) mapped_y = scaled_y / (1.0 + scaled_y) scale = mapped_y / np.maximum(y, eps) return rgb * scale[..., None]系数 0.2126/0.7152/0.0722 对应特定 RGB 基色与白点下的线性亮度关系;换工作色彩空间时要换相应系数。拿 Gamma 编码后的 sRGB 直接套这些系数,不再代表物理亮度。
全局算法为什么救不回所有局部对比度
全局 Tone Mapping 对同一个输入值总给出同一个输出,与像素在哪无关。它稳定、快速,不容易产生空间伪影,也适合实时渲染。但当室内和窗外同时存在时,为压住极亮窗户,曲线会压平一大段中高亮;暗室里的细节可能仍挤在狭小范围。
局部 Tone Mapping 会先估计一个缓慢变化的局部亮度基底,再单独处理基底与细节。直觉上,算法不只问“这个像素有多亮”,还问“它比周围亮多少”。这类似人眼在不同区域进行局部适应。
全局与局部 Tone Mapping
全局曲线只看像素值;局部算法还参考周围亮度,能提取暗部,却也可能制造光晕。
尝试提高强度并缩小半径:窗框附近的局部反差上升,但亮暗交界处也更容易出现 halo。
局部算法的代价是空间行为更难控制:
- 邻域太大,效果逐渐退化为全局压缩;
- 邻域太小,强边缘两侧容易出现明暗光环(halo);
- 过度提升暗部会同时抬高噪声、固定图样和压缩伪影;
- 帧与帧之间的局部统计变化,会在视频里造成闪烁和呼吸感。
双边滤波、导向滤波、多尺度分解和局部拉普拉斯滤波都可用于构造边缘保持的基底,但“边缘保持”不代表绝不会有 halo。算法、半径、强度、边界处理和时域稳定性必须一起验证。
为什么逐通道套曲线会改变颜色
一个 HDR 橙色像素可能是 。若对每个通道独立做 Reinhard:
原本最大的红通道被压得最狠,三个通道逐渐靠近,颜色向白色偏移,RGB 比例也改变。对于霓虹灯、夕阳和舞台灯,这种去饱和可能很明显。
更稳妥的基础方法是先计算亮度 ,只映射 ,再对三个通道使用同一个缩放因子:
色相保持实验
逐通道压缩会改变 RGB 比例;按亮度求一个共同缩放因子,更容易保留高光色相。
输入 HDR
3.20 : 1 : 0.16逐 RGB 通道
1.44 : 1 : 0.30亮度域缩放
3.20 : 1 : 0.16亮度域方法也不是万能的:极端颜色可能仍超出目标色域,后续还需要 gamut mapping,而不是简单逐通道 clamp。
共同缩放能保持线性 RGB 的通道比例,因此通常更能保持色相。但它也可能让某个输出通道仍超过 1。此时不应简单逐通道裁剪,因为裁剪再次破坏比例;更完整的系统会在目标色域中做 gamut mapping,在亮度、饱和度和色相之间作可控取舍。
另一条路线是在具有亮度/色度分离的感知空间里处理亮度,再重建颜色。它能提供更符合视觉的控制,但会引入色彩空间转换、负值、色域边界和数值稳定性问题。教学代码采用亮度缩放,是清楚的基线,不是最终制作级答案。
与 CCM、Gamma、LUT 的顺序
一条简化的相机管线可以写成:
线性 RAW → 黑电平 / WB / Debayer → CCM(线性颜色校正) → 曝光与 Tone Mapping → 创意 Look / 3D LUT → sRGB 或目标传递函数编码 → 文件 / 显示这个顺序表达的是算法约定,不是所有产品的唯一实现。
CCM 通常描述线性通道混合,放在 Tone Mapping 前更符合其标定含义。Tone Mapping 有意改变场景亮度关系。Gamma 或 sRGB 编码负责把线性输出变成存储/显示信号;它不是动态范围压缩器。创意 LUT 可能放在 tone curve 前后,取决于 LUT 是在哪个色彩空间和动态范围中制作的。输入约定不匹配,再漂亮的 LUT 也会得到错误结果。
千万不要因为 float32 就断言数据在线性域,也不要因为范围是 0–1 就断言它是 SDR。数值类型、数值范围、色彩空间和传递函数是四个不同属性。
Gamma 为什么不能代替 Tone Mapping
幂函数 会重新分配码值,但若输入 ,输出仍可能大于 1。把结果裁到 1,高光层次照样消失。sRGB 编码的目标是高效表示 SDR 线性光,不是把任意宽的场景动态范围塞进 SDR。
正常的 sRGB 编码再解码,代表的光应该回到原值;Tone Mapping 的往返却通常不可逆,因为多个很亮的输入被压到相近输出。这是判断两者职责的最简方法:编码改变表示,Tone Mapping 改变再现。
HDR 显示、PQ 和 HLG 的边界
“HDR 输入需要 Tone Mapping”不等于“所有 HDR 最后都要压成 SDR”。若目标是 HDR 显示,可以把场景或母版亮度映射到更高的目标峰值,再使用 PQ 或 HLG 等传递函数编码。
- PQ以绝对显示亮度为核心,信号码值与目标尼特值有定义关系;
- HLG更偏向相对的广播系统设计,可结合显示环境适配;
- HDR 到 HDR 仍可能需要显示映射,因为母版峰值、显示峰值和环境不一定相同;
- PQ/HLG 是传递函数与系统的一部分,不应被当作一条通用“Filmic 曲线”。
本文的 教学输出没有包含 HDR 元数据、峰值亮度、黑位、观看环境和色容积映射。进入实际 HDR 交付时,应按目标标准和显示管理链验证,不能只看普通浏览器里的截图。
一个可验证的教学 Filmic 实现
如果目标是学习 toe 与 shoulder,而不是复刻特定标准,可以把每一步写得透明:
import numpy as np
def teaching_filmic(x, toe=0.20, shoulder=2.4): """仅用于教学曲线形状,不是 ACES 变换。""" x = np.maximum(np.asarray(x, dtype=np.float32), 0.0) toe_shaped = x / (x + toe) return 1.0 - np.exp(-shoulder * toe_shaped)
samples = np.array([0.0, 0.18, 1.0, 4.0, 16.0])print(teaching_filmic(samples))实际项目还需要定义负值怎么处理、参考中灰落在哪里、白点是多少、输出是否归一化、曲线是否逐通道应用,以及后续色域映射如何衔接。曲线“看起来顺滑”不是完整规范。
工程实现中的几个陷阱
不要过早裁剪
CCM、白平衡和曝光之后出现负值或大于 1 的值很常见。负值可能来自矩阵变换与噪声,大于 1 的值是 Tone Mapping 需要的高光信息。在哪一步裁剪必须由后续算法的定义决定。
用稳定的精度做统计
对数平均要加 ,否则黑像素会产生 log(0)。极亮异常值、NaN 与 Inf 应在统计前处理。大图求和最好用足够精度,避免累计误差。
视频必须考虑时间维度
每帧独立计算 key value 会导致曝光跳动;局部基底随运动改变也会闪烁。通常要做时间平滑、场景切换检测和参数变化限速,同时避免拖影。
预览缩略图不等于全分辨率结果
局部算法在不同分辨率上的半径含义不同。若用缩略图估计亮度,再作用于原图,需要明确坐标、尺度、滤波与边界规则。
验证清单
在把 Tone Mapping 接入真实管线前,可以用下面这组测试快速定位问题:
- 单调性:更亮的输入不应无故得到更暗的输出;
- 有限性:黑、极大值、NaN、Inf 输入都应有定义好的处理;
- 中灰锚点:确认
0.18在既定曝光下落到预期输出; - 高光层次:测试
1、2、4、8、16,不能只看 渐变; - 负值策略:明确保留、压缩还是裁剪,不让不同模块各自猜测;
- 色相测试:用高饱和红、绿、蓝、青、品红、黄检查通道比例;
- 色域测试:区分 tone mapping 与 gamut mapping,记录越界值;
- 局部边缘:用明窗/暗墙、黑字/白底寻找 halo 与反差反转;
- 噪声测试:暗部提升后检查 shot noise、read noise 和 banding;
- 时域测试:视频中检查闪烁、泵动、场景切换和移动边缘;
- 顺序测试:确认输入在线性域,Gamma 编码没有提前执行;
- 参考对照:保存浮点中间结果、曲线采样表和直方图,不只凭主观观感。
最后记住三句话
曝光改变整幅图的尺度,不能独自压缩动态范围;Tone Mapping 重新安排亮度关系,因此有意改变对比度;Gamma、PQ、HLG 等传递函数定义数值怎样表示和显示,不能与 Tone Mapping 混为一谈。
当高光需要“装进屏幕”时,先问四个问题:输入是不是线性场景值?目标显示范围是什么?要保留哪一段对比度?颜色和局部结构允许怎样变化?回答清楚,再选择 Reinhard、Filmic、局部算法或更完整的显示变换,曲线才不会只是一根凭感觉拖出来的线。