曝光与白平衡:线性光中的三个乘数
作者: Anastasiia Butova, ComfyUI 与扩散模型工程师,贝尔格莱德 · 发布于 2026-09-23
译自英文版: English
在我正在开发的调色工作室中,曝光和白平衡是同一种运算:对线性 RGB 施加三个乘数。真正有意思的部分都围绕着这次乘法。哪些数值算作线性,增益如何归一化才能让灰色保持亮度,以及引擎在某个通道超过 1 时做什么。这是关于基础调色工具如何实现的系列文章的第一部分,包括数学、C++,以及测试所发现的错误。
引擎及其工作的色彩空间
工作室是一个本地编辑器,建立在 LUT Atelier 之上。LUT Atelier 是我早先写的一个调色编辑器,先放在我的研究 hub 仓库里,之后再通过每个源文件的 SHA-256 固定到工作室中。下文描述的若干修复是在固定之后进入该引擎的。原生部分是 C++20,只使用标准库;唯一的第三方文件是 nlohmann/json 3.12.0,为命令行协议而内置。数学的 JavaScript 版本作为一个独立的参照保留下来:对 600 个随机调色进行的带种子运行,比较了两个引擎的 196,200 个输出值,最大差异为 8.7e-14。
输入对自身的性质很坦诚。JPEG、WebP 和 8 位 PNG 通过浏览器 Canvas 以 8 位 sRGB 的形式进入。一个单独的实验室窗口读取 16 位 PNG,而不让 Canvas 对其取整。没有 RAW 解码器,没有 HDR,除 sRGB 外没有 ICC 配置文件。颜色变换以 double 计算,只在写出输出时取整。
sRGB 的数值是经过编码的,而不是线性的。引擎在做任何乘法之前,先用 IEC 61966-2-1 曲线对每个通道进行解码:
c_lin = c / 12.92 if c <= 0.04045
c_lin = ((c + 0.055) / 1.055)^2.4 otherwise
0.5 的中灰解码后是 0.214。这个数字正是本文大部分内容的原因。
引擎内部一次调色的顺序,来自 native/src/color.cpp:
- 输入阶段:可选的色卡 Levels,或导入的 LUT,由一个强度滑块混合。
- 解码到线性,然后乘以白平衡、曝光和色温/色调的增益。
- 可选的 Oklab 编辑(色相、饱和度、颜色锚点、参考匹配),然后回到编码 RGB。
- 对比度、主曲线、R/G/B 曲线。
- 照片阶段:Levels、亮度曲线、影调滑块、自然饱和度、色彩平衡轮、选择性颜色。
- 通道混合器、颜色保护、全局强度。
曝光就是乘法,所以它发生在线性光中
曝光是对线性 RGB 施加 2^EV。+1 EV 使光量加倍,−1 使其减半,滑块范围从 −3 到 +3。之所以必须在线性数值上进行,原因是物理性的:两倍的光对应两倍的线性信号。它不对应两倍的编码码值。

这张图是直接从引擎采样得到的。在 +1 EV 时,输入码值 128 输出 175.6,从输入码值 188 开始达到白色。如果你改为把编码值乘以二,那么从码值 128 起一切都变成白色,斜坡的上半部分就没了。Adobe 把 Photoshop 的曝光调整记载为在线性色彩空间中的计算,Lightroom 则把 +1.00 描述为大约一档光圈。我不声称它们的公式;乘法只是"曝光"这个词的本义。
下面是 C++ 中完整的路径。曝光和色温/色调在每次调色时合并为一个增益向量,像素循环只做三次乘法:
// native/src/color.cpp
Vec3 linearGains(const Grade& grade) {
const Vec3 warm{1. + grade.temperature * .4, 1. + grade.tint * .25, 1. - grade.temperature * .4};
const double exposure = std::exp2(grade.exposure), y = linearLuminance(warm);
return {exposure * warm[0] / y, exposure * warm[1] / y, exposure * warm[2] / y};
}
Vec3 workLinear(Vec3 original, const Grade& grade, const std::optional<Cube>& lut, Vec3 gains) {
// ... input stage: checker Levels, then the imported LUT, blended by importedStrength ...
return {srgbToLinear(original[0]) * grade.wb[0] * gains[0],
srgbToLinear(original[1]) * grade.wb[1] * gains[1],
srgbToLinear(original[2]) * grade.wb[2] * gains[2]};
}
文档中的不变量很明确。EV 0 是精确的乘数 1,数学测试套件检查 +1 EV 使线性 RGB 精确加倍,误差为零。+1 之后再 −1,只要没有发生截断,就能还原输入:在灰阶斜坡上,我测得的往返误差为 4e-14 个码值。
白平衡:同样的乘法,但按通道进行
这里的白平衡是对线性 RGB 做对角缩放,即 von Kries 模型:每个通道一个增益。增益来自两个地方。中性色取样器写入 wb,而色温和色调滑块产生相对乘数:
w = (1 + 0.4·T, 1 + 0.25·t, 1 − 0.4·T) / Y(w)
Y(w) = 0.2126·w_R + 0.7152·w_G + 0.0722·w_B
除以 Y(w) 是我一开始就做错的部分。如果没有这个除法,色调 +1 会把绿色乘以 1.25,而灰色会变亮 log2(1.179),约 0.24 EV;色调 −1 则使其变暗 0.28 EV。于是本该只改变颜色的滑块,也在改变曝光。加上除法之后,色温和色调可以重新着色中性色,而不改变其线性亮度。我让一个中灰经过色调 +1:结果是 (118.1, 131.0, 118.1),它的线性 Y 与输入的 Y 精确到最后一位都相等。
驱动这些滑块的罗盘是两个有界数字的图示,而不是温度计。标记位于 x = cx + R·tanh(temperature)、y = cy − R·tanh(tint),拖动时用 atanh 反推,并钳制在 0.7615,即略低于 tanh(1),也就是滑块范围的端点。它上面没有开尔文刻度。Lightroom 对 RAW 文件显示开尔文,但对其他文件显示相对的 −100…+100 刻度;Capture One 显示开尔文和色调。JPEG 通常已经在相机或 RAW 转换器中做过一次白平衡,而且没有相机配置文件,就无法经由开尔文进行换算。相对刻度才是诚实的做法。
中性色取样器保留你点击处的亮度
点击一块灰色色块时,引擎把它解码为线性的 (r, g, b),并设定
g_c = Y / c, Y = 0.2126·r + 0.7152·g + 0.0722·b
乘法之后,三个通道都等于 Y,于是被点击的点就变成了一块与其自身亮度相同的灰色。我固定的那个版本则是归一化到几何平均数 (r·g·b)^(1/3)。这样可以让增益的乘积保持为 1,听起来很整洁,但被点击的点会变亮或变暗:对测试取样 (0.6, 0.45, 0.3),偏移 0.30 EV;对偏绿的取样,比如 (0.3, 0.6, 0.3),偏移约 1.06 EV。一个调色工具又一次在悄悄地移动曝光。亮度版本于 9 月 22 日落地,在检查集上,JS 与 C++ 引擎的结果逐位一致。
// native/src/color.cpp
Vec3 neutralGains(Vec3 rgb) {
if (!finite3(rgb) || rgb[0] <= 0. || rgb[1] <= 0. || rgb[2] <= 0. || rgb[0] > 1. || rgb[1] > 1. || rgb[2] > 1.) throw std::range_error("neutralGains requires finite encoded sRGB channels in (0, 1]");
const Vec3 linear{srgbToLinear(rgb[0]), srgbToLinear(rgb[1]), srgbToLinear(rgb[2])};
// g_i = Y / c_i: the pick becomes a grey of its own luminance (see color-core.mjs neutralGains).
const double y = linearLuminance(linear);
Vec3 gains{y / linear[0], y / linear[1], y / linear[2]};
for (double gain : gains) if (!std::isfinite(gain) || gain > 8.) throw std::range_error("neutralGains pick needs a gain above grade.wb limit 8: it is too dark or too far from neutral");
return gains;
}
一次成功的取样还会把色温和色调重置为零,因此屏幕上的增益就是全部的白平衡。如果某次取样需要的增益超过 8,它会被拒绝,并给出上面那条消息;如果取样中有一个通道为零,则给出另一条专门的消息。我宁可拒绝,也不愿返回一个被钳制的增益,因为被钳制的增益会得到一个看起来工具已经工作了的结果,而实际上并没有。测试套件中的用例是一个强烈偏色的样本,需要的增益为 (4.40, 1.10, 0.24);经过乘法之后,它的通道相等,离散度恰好为零。
你点击哪里,比公式更重要。单张照片无法区分光的颜色和物体的颜色:线性信号为 I_c = k·∫ E(λ)·R(λ)·S_c(λ) dλ,把光 E 乘以任何光谱函数,同时把反射率 R 除以同一个函数,得到的是同一个像素。因此取样器只应放在灰卡上,或者放在你确知是中性的表面上,绝不能放在皮肤上,也不能放在一件染料未知的"白" T 恤上。出于同样的理由,引擎中目前还没有自动白平衡。每一种自动方法都依赖某种假设,而经典的灰色世界假设会把一个确实偏绿的场景变成灰色;研究检查中特意保留了这个反例。
对于已知中性色,已经实现的是一个色卡检查面板:一个已知的中性色块,它的编码 RGB 和线性 RGB、增益,以及应用增益后的残差。对于色块本身,残差按构造为零,所以真正有用的检查是画面中其他中性色的表现,即在 RGB 分量图上读取。针对多个色块的求解器(对每个色块比值取中位数)已经写下来,但尚未实现。彼此不一致的色块不会被平均成一个隐藏的"理想"平衡;这种分歧会保留在分量图上,可见。
一个我尚未改变的顺序
在当前引擎中,导入的 LUT 运行在白平衡和曝光之前。对于一个风格 LUT 而言,这个顺序是反的:你希望先把画面归一化,再施加风格,否则同一个风格在一系列的每一帧上都会落在不同的位置。我还没有改过来,因为悄悄改动会改变每一个已保存的项目。这需要一个带版本号的配方,旧项目保留旧的顺序,新项目使用新的顺序。在那之前,中性色取样器读取的是输入阶段之后的像素,所以它的增益至少指向乘法实际接收到的东西。
超过 1.0 之后会发生什么
引擎是 SDR 的。只有曝光和白平衡时,每个通道在乘法之后立即被编码并钳制到 [0, 1];启用 Oklab 编辑时,钳制会推迟,最迟在写出结果时进行。这带来两个后果,并且都能看得见。
第一,被截断的细节无法恢复。在曝光之后降低曲线,只会使平坦的 255 变暗;它之上的数值在更早的阶段就被丢弃了,曝光工具的文档也这样说明。
第二,逐通道的截断会改变色相。当红色通道达到 1,而绿色和蓝色继续增长时,颜色会向黄色滑动。

数字来自引擎。一个暖色 sRGB (204, 133, 87) 的 Oklab 色相为 52.9°。它的红色通道在 +0.73 EV 时达到 255。在 +1 EV 时输出为 (255, 182, 121),色相为 60.4°;在 +2 EV 时为 (255, 248, 166),色相 103.9°。一个橙色变成了淡黄色。
有一个可选的替代方案:软色域压缩。它不是切掉每个通道,而是把超出范围的颜色沿线性 sRGB 中的一条直线,朝着相同 Oklab 明度的灰色移动,直到它落入立方体之内。在一次创意性的 Oklab 编辑之后,如果一个颜色最初就在立方体之内,那么它到边界距离的至少前 80% 保持精确,只有剩下的部分用指数肩部进行压缩。仅有曝光时,参考点是已经曝光的颜色本身,它本身就已超出范围,因此在那里它就是投影到表面上的简单操作。对同一个暖色,+1 EV 得到 (255, 192, 159),色相为 48.5°:色相偏移较小,但色度从 0.115(硬截断的结果;源颜色的色度为 0.107)降到 0.085;而从大约 +1.67 EV 起,Oklab 明度达到 1,结果就是纯白。两种选项都不是高光恢复。
第一个版本是教科书式的:保持 Oklab 明度和色相不变,沿该射线降低色度。它在蓝色一侧失效。在蓝色一侧,一条固定明度、固定色相的射线可能离开色域又重新进入,而一个必须连续的映射无法跨越这个缺口。靠近 264° 的那个小小的重新进入,是我用引擎的矩阵自己测得的,并非来自他人;Björn Ottosson 的 Oklab 文章 提到,早期的拟合把蓝色向内折叠,他因此对最终的 Oklab 加了约束,使蓝色不会被折叠。软选项的 Oklab 框架,以及它对硬 RGB 截断的警告,来自他的 色域裁剪笔记。线性 RGB 中朝向同明度灰色的线段只有一个交点,因为 RGB 立方体是凸的,所以连续性是白送的。它有一个我测量过的代价:在 +0.8 EV 的明亮暖色斜坡上,相邻的 8 位输入码值可能落在相距 19 个输出码值的位置上。这是陡峭的响应,而不是跳变;测试允许最多 20,在 21 时失败。
Capture One 记载的高光恢复可以使用未截断的通道。这在带有余量的 RAW 数据上可行。对于 8 位 sRGB 输入,255 之上并没有任何可以恢复的东西,所以我宁可诚实地展示截断,也不去编造细节。
限制,以及我会再次这样做的部分
- 主窗口为 8 位 SDR sRGB,16 位 PNG 只在实验室窗口中可用。没有 RAW,没有开尔文,没有 ICC。
- 白平衡来自一个已知的色块;多个色块和自动估计已经设计,但尚未实现。
- 导入的 LUT 仍然运行在白平衡之前。修复需要一个带版本号的配方。
如果让我从第一天就重新来过,我会做这些:把色温和色调除以各自的亮度;保留第二份数学实现作为参照;让取样器拒绝一个样本并给出理由,而不是钳制它的增益。
下一部分:Levels、直方图,以及用色卡设定黑白场,那里同一个引擎刻意在编码值上工作。我为 Photoshop 发布的设备端 C++ 修图插件,见 happyin.ai。