3574 字
18 分钟
高光是怎么装进屏幕的?从曝光压缩到 Filmic Tone Mapping

窗外阳光比室内阴影亮几百甚至几千倍,相机的浮点处理中间结果也可能远远超过 1.0;屏幕和普通图片却只能接收一个有限范围。直接裁剪,高光会变成一片没有层次的白;把曝光整体降下来,窗外回来了,室内又沉进黑暗。

**Tone Mapping(色调映射)要解决的不是“怎样让图片亮一点”,而是怎样把很宽的场景亮度关系,重新安排进较窄的输出范围。**它需要压缩动态范围,同时尽量保住中灰、局部对比度、颜色关系和视觉意图。

本文默认输入是线性、场景参考的 HDR RGB,输出先归一化到 [0,1][0,1]。这里讲的是算法直觉和可验证的教学实现,不把某条 S 曲线冒充商业相机或电影系统的完整色彩管线。

曝光移动整把尺子,Tone Mapping 改变刻度间距#

线性曝光可以写成:

L=2EVLL'=2^{EV}L

增加一档,所有亮度都乘 2;减少一档,所有亮度都除以 2。像素之间的比例没有改变:原来相差 16 倍,调整后仍相差 16 倍。曝光能选择“哪一段亮度进入屏幕”,却不能同时把相差十几档的所有内容都塞进 [0,1][0,1]

Tone Mapping 则使用非线性函数:暗部、中间调和高光的斜率不同。它会改变亮度比例,因此也会改变画面对比度。这不是误差,而是它的任务。

HDR 亮度尺与管线

曝光移动整把尺子;Tone Mapping 改变尺子的间距,把高光压进输出范围。

输出策略

仅调曝光可以救回高光,却会让中灰与暗部一起下降;压缩曲线能重新安排亮度关系。

场景线性光0.003—11.52
曝光× 0.5
映射 / 裁剪输出 0—1
Gamma 编码稍后执行
输入动态范围12 档
到达裁剪线3 个样本
最高输出0.852

实验中的直接裁剪相当于:

Lout=min(1,Lin)L_{out}=\min(1,L_{in})

所有大于 1 的值都变成相同的白,高光内部的比例彻底丢失。注意,浮点数超过 1 并不等于已经“坏掉”;只要没有过早 clamp,后续 Tone Mapping 仍能从 1.23.010.0 中恢复不同层次。传感器在采集时已经饱和则不同:原始信息没有记录下来,任何曲线都不能凭空还原。

最简单的全局基线:Reinhard#

经典的全局 Reinhard 算子可以简化成:

Ld=L1+LL_d=\frac{L}{1+L}

LL 很小时,LdLL_d\approx L,暗部近似保持线性;当 LL 增大,输出逐渐靠近 1,却不会越过 1。例如输入 1 映射到 0.5,输入 4 映射到 0.8,输入 99 映射到 0.99

它的优点是单调、连续、没有参数也很难炸掉;缺点同样明显:输入 1 也到不了输出白,整体容易显得灰,场景白点不够可控。带白点的常见变体是:

Ld=L(1+L/Lwhite2)1+LL_d=\frac{L\left(1+L/L_{white}^2\right)}{1+L}

这样 L=LwhiteL=L_{white} 时恰好映射到 1。超过白点的值仍需明确如何处理,不要靠整数溢出“自动解决”。

Tone Curve 编辑器

Reinhard 是可推导的基线;教学 Filmic 用 toe 与 shoulder 展示暗部和高光如何分别塑形。

曲线
061
输入1.000
输出0.865
局部斜率(近似)0.045

这里的 Filmic 是为解释形状设计的教学曲线,不是 ACES 官方 RRT/ODT,也不应当用“ACES”给它命名。

拖动输入点时,实验还显示局部斜率。斜率越小,相邻输入被压得越近,局部对比度越弱;斜率越大,细微亮度差越容易被看见。读一条 tone curve,不能只看它经过哪些点,还要看各区段的斜率。

Toe、线性段和 Shoulder 在分工#

摄影与电影风格曲线常被概括为三个区域:

  • Toe 位于暗部。它决定黑位附近怎样压缩或抬起,影响阴影层次和“黑是否扎实”;
  • 近似线性段覆盖重要的中间调。它承担主要对比度,肤色和常见物体大多希望在这里稳定;
  • Shoulder 位于高光。它让曲线逐渐变平,使明亮物体柔和地靠近显示白,而不是撞墙裁剪。

“Filmic”描述的是这类设计语言,不对应唯一公式。上面的互动组件使用了便于解释的教学函数:先用有理函数形成 toe,再用指数饱和形成 shoulder。它有意让参数含义直观,不是 ACES 官方 RRT/ODT,也不是任何相机厂商的胶片模拟

这一区分很重要。互联网上经常把带肩部的 S 曲线都叫作 “ACES Filmic”,但真正的 ACES 显示流程包含色彩空间、渲染变换、输出设备变换、参考观看条件等一整套定义,不能用一条近似曲线替代。

一个完整的全局流程先要确定曝光#

即使使用同一条曲线,输入尺度不同,结果也会不同。工程中常先计算场景或图像的代表亮度,再做曝光归一化。例如用对数平均亮度:

Lavg=exp(1Nilog(ϵ+Li))L_{avg}=\exp\left(\frac{1}{N}\sum_i\log(\epsilon+L_i)\right)

然后把它映射到某个 key value:

Li=keyLavgLiL_i'=\frac{key}{L_{avg}}L_i

对数平均不会让少量极亮像素完全支配测量,但依然不是万能测光。大面积雪景、夜景灯牌、人物逆光需要不同的视觉意图。自动曝光决定尺度,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);
  • 过度提升暗部会同时抬高噪声、固定图样和压缩伪影;
  • 帧与帧之间的局部统计变化,会在视频里造成闪烁和呼吸感。

双边滤波、导向滤波、多尺度分解和局部拉普拉斯滤波都可用于构造边缘保持的基底,但“边缘保持”不代表绝不会有 halo。算法、半径、强度、边界处理和时域稳定性必须一起验证。

为什么逐通道套曲线会改变颜色#

一个 HDR 橙色像素可能是 (4,1.25,0.2)(4,1.25,0.2)。若对每个通道独立做 Reinhard:

(R,G,B)=(R1+R,G1+G,B1+B)(R',G',B')=\left(\frac{R}{1+R},\frac{G}{1+G},\frac{B}{1+B}\right)

原本最大的红通道被压得最狠,三个通道逐渐靠近,颜色向白色偏移,RGB 比例也改变。对于霓虹灯、夕阳和舞台灯,这种去饱和可能很明显。

更稳妥的基础方法是先计算亮度 YY,只映射 YY,再对三个通道使用同一个缩放因子:

s=f(Y)Y,RGB=sRGBs=\frac{f(Y)}{Y},\qquad \mathbf{RGB}'=s\mathbf{RGB}

色相保持实验

逐通道压缩会改变 RGB 比例;按亮度求一个共同缩放因子,更容易保留高光色相。

高饱和高光

输入 HDR

3.20 : 1 : 0.16

逐 RGB 通道

1.44 : 1 : 0.30

亮度域缩放

3.20 : 1 : 0.16
输入亮度 Y1.759
逐通道输出 Y0.579
亮度域输出 Y0.638

亮度域方法也不是万能的:极端颜色可能仍超出目标色域,后续还需要 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#

幂函数 V=L1/γV=L^{1/\gamma} 会重新分配码值,但若输入 L>1L>1,输出仍可能大于 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 曲线”。

本文的 [0,1][0,1] 教学输出没有包含 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 需要的高光信息。在哪一步裁剪必须由后续算法的定义决定。

用稳定的精度做统计#

对数平均要加 epsilonepsilon,否则黑像素会产生 log(0)。极亮异常值、NaN 与 Inf 应在统计前处理。大图求和最好用足够精度,避免累计误差。

视频必须考虑时间维度#

每帧独立计算 key value 会导致曝光跳动;局部基底随运动改变也会闪烁。通常要做时间平滑、场景切换检测和参数变化限速,同时避免拖影。

预览缩略图不等于全分辨率结果#

局部算法在不同分辨率上的半径含义不同。若用缩略图估计亮度,再作用于原图,需要明确坐标、尺度、滤波与边界规则。

验证清单#

在把 Tone Mapping 接入真实管线前,可以用下面这组测试快速定位问题:

  1. 单调性:更亮的输入不应无故得到更暗的输出;
  2. 有限性:黑、极大值、NaN、Inf 输入都应有定义好的处理;
  3. 中灰锚点:确认 0.18 在既定曝光下落到预期输出;
  4. 高光层次:测试 1、2、4、8、16,不能只看 [0,1][0,1] 渐变;
  5. 负值策略:明确保留、压缩还是裁剪,不让不同模块各自猜测;
  6. 色相测试:用高饱和红、绿、蓝、青、品红、黄检查通道比例;
  7. 色域测试:区分 tone mapping 与 gamut mapping,记录越界值;
  8. 局部边缘:用明窗/暗墙、黑字/白底寻找 halo 与反差反转;
  9. 噪声测试:暗部提升后检查 shot noise、read noise 和 banding;
  10. 时域测试:视频中检查闪烁、泵动、场景切换和移动边缘;
  11. 顺序测试:确认输入在线性域,Gamma 编码没有提前执行;
  12. 参考对照:保存浮点中间结果、曲线采样表和直方图,不只凭主观观感。

最后记住三句话#

曝光改变整幅图的尺度,不能独自压缩动态范围;Tone Mapping 重新安排亮度关系,因此有意改变对比度;Gamma、PQ、HLG 等传递函数定义数值怎样表示和显示,不能与 Tone Mapping 混为一谈。

当高光需要“装进屏幕”时,先问四个问题:输入是不是线性场景值?目标显示范围是什么?要保留哪一段对比度?颜色和局部结构允许怎样变化?回答清楚,再选择 Reinhard、Filmic、局部算法或更完整的显示变换,曲线才不会只是一根凭感觉拖出来的线。

高光是怎么装进屏幕的?从曝光压缩到 Filmic Tone Mapping
https://cloudsir.top/posts/how-tone-mapping-compresses-hdr/
作者
CloudSir
发布于
2026-08-24
许可协议
CC BY-NC-SA 4.0
LUT 是怎么改变颜色的?从一条曲线走进 RGB 立方体
黑电平为什么不是零?从 Optical Black 到逐通道 BLC