Levels:黑白场、伽马、通道与色卡
作者: Anastasiia Butova, ComfyUI 与扩散模型工程师,贝尔格莱德 · 发布于 2026-09-23
译自英文版: English
Levels 是最没有神秘感的影调工具:一个截取、一条直线和一个幂。在我的调色工作室中,它刻意地作用于编码后的 sRGB 数值,位于 第 1 部分 的线性光乘法之后。公式只有三行。本文讲的是公式周围的一切:主设置与通道设置的顺序,直方图实际测量的是什么,吸管求解器,从色卡取得的黑白场,以及一个在黑色附近让 LUT 损失了十六个码值的例子。
公式
对于 0…1 中的一个通道值 x,设输入黑场 b、输入白场 w、伽马 γ,以及输出黑场和白场 ob、ow:
u = clamp((x − b) / (w − b), 0, 1)
out = ob + (ow − ob) · u^(1/γ)
b 映射到 ob,w 映射到 ow,输入范围之外的一切都被截去。当 b = 0、w = 1、γ = 1、ob = 0、ow = 1 时,函数就是恒等映射。面板把输入和输出点显示为 0…255,伽马为 0.2…5。编辑器保证输入黑场和白场之间至少相距 1/255,模型本身拒绝 w − b < 1e-6。输出那一对没有这样的规则:输出黑场高于输出白场就是反相,而反相本身是一个合法的要求。
灰色手柄位于曲线输出值等于输出范围中点的位置:
m = b + (w − b) · 0.5^γ
γ = ln((m − b) / (w − b)) / ln 0.5
因此,γ 大于 1 会把手柄移向黑色,提亮中间调;γ 小于 1 则相反。

Adobe 在 Photoshop 色阶 中记载了同样的控件,而 Capture One 讲得更多:它的 Levels 页面 给出了一个线性重映射的例子,输入 0–220 映射到输出 0–244,并把中间的滑块描述为伽马。两者都没有公布实现,所以我不声称与它们逐位一致。我所声称的是,上面的函数正是工作室中两个引擎都在运行的那个。
为什么是编码值而不是线性光?曝光和白平衡对光进行建模,因此它们对线性 RGB 做乘法。Levels 则是人们依据编码值直方图操作的影调重映射,如果在线性光中给出伽马手柄,它会出现在没人预料到的位置。这也是为什么 Levels 的灰色吸管和白平衡的中性色取样器是两个独立的操作:一个在编码值中弯曲每个通道的伽马,另一个则对线性通道做乘法。
先主设置,再逐通道
一次调色包含一组主设置和三组通道设置。主设置保存在它过去一直使用的同一组扁平字段中,因此在通道设置存在之前保存的项目,逐字节保持不变。每个通道先经过主设置,再经过它自己的设置:
// native/src/photo_color.cpp
double applyLevels(const LevelsSet& set, double x) {
const double normalized = clamp((x - set.blackPoint) / (set.whitePoint - set.blackPoint));
return set.outputBlack + (set.outputWhite - set.outputBlack) * std::pow(normalized, 1. / set.gamma);
}
// ... inside CompiledPhoto::apply
const LevelsSet master = masterLevels(grade);
for (int c = 0; c < 3; ++c) levelled[c] = applyLevels(grade.channelLevels[c], applyLevels(master, encoded[c]));
// Levels alone never needs Oklab: skipping the round trip keeps exact zeros and ones exact.
if (levelsOnly) return levelled;
y_c = f_c(f_master(x_c)) 是我为本引擎做的决定,而不是关于 Photoshop 内部顺序的一条已记载的事实。
两种设置之间的区别,就是两者并存的理由。对三个通道使用同一个函数,可以让灰色仍然是灰色,但它不能保持一种颜色的 R:G:B 比例,因此饱和度会随对比度一起变化。分通道设置则会把三个通道分开,这恰恰是去除色偏的办法,而误用时也恰恰会产生色偏。
那段代码中的注释掩盖了一个我只是从数字中才理解的陷阱。在照片阶段内,Levels 运行在任何 Oklab 工作之前;但在它前面的全局颜色变换,过去会把每个像素送进 Oklab 再转回来,而这种往返只有在精确算术中才是恒等的。随后 Levels 的伽马会放大零附近的残差:当 γ = 5 时,一个本应为 0、却回到 1e-6 的通道,离开 Levels 时变成 0.063,即十六个 8 位码值。引擎中第一个 Oklab 逆变换使用的是公开的十位小数矩阵,它只能把恒等映射还原到 1.18e-6 的精度之内。现在它是精确的逆变换(1.9e-14),全局控件不需要 Oklab 的调色会完全跳过那一步,而套件中的 γ = 5 用例显示 JS 与 C++ 之间的差异为零。
第二个陷阱则更隐蔽。有一段时间,JavaScript 引擎应用了逐通道 Levels,而 C++ 引擎(负责预览和每一次导出的渲染)却悄悄地丢弃了它们。一次独立审计发现了最多 0.1875 的差异。对像素 (158, 128, 97) 进行的灰色吸管修复,在 JS 中得到 (127.5, 127.5, 127.5),在 C++ 中则保持不变。现在两者都会应用这些设置,原生解析器会拒绝未知的通道名称,而不是跳过它们;在 257 个样本上的一致性检查显示差异为 1.1e-16。
直方图测量的是什么
Levels 控件上方的直方图有 256 个柱。R、G、B 分别计数,并落入相同的柱中;每个像素按其 alpha 加权,完全透明的像素被跳过。它按步长最多采样 32,768 个像素,绘制频数的平方根,使峰值不会把其余部分压平,并且对黑场以下和白场以上的范围加以阴影标注。
关键在于它的测量位置:正是 Levels 的实际输入,取自渲染流水线本身。第一版为直方图和曲线读数对调色做了一次被裁剪的重新计算,而这个副本保留了某些后续阶段,同时丢掉了 Levels 的一部分。一次审计发现,纯黄色 (255, 255, 0) 在"Levels 之前"的直方图中被画在了 (229, 217, 102);而在开启通道混合器的情况下,红色读数与真实输入不一致,分别为 139.2 和 158.1。现在由同一个编译后的流水线报告它自己的中间输入,两个读数都使用它。在回归套件的 21 个用例上,误差为 0。
吸管:三个拒绝的求解器
三个吸管从一个被点击的像素中求解出每个通道的参数。之所以是逐通道的,正是这一点的关键:对三个通道做同样的调整,无法让不相等的通道变得相等,因此也无法去除色偏。对于目标 t,它在输入范围内需要的位置是 u = ((t − ob)/(ow − ob))^γ,于是
black eyedropper: b = (s − u·w) / (1 − u)
white eyedropper: w = b + (s − b) / u
grey eyedropper: γ_c = ln(u_c) / ln(v_c), u_c = (s_c − b)/(w − b), v_c = (t − ob)/(ow − ob)
对于灰色求解,u_c 和 v_c 都必须严格位于 (0, 1) 之内。在端点处,不存在对应的伽马,因此被截去或压暗的像素会附带其原因被拒绝,而不会被替换为伽马 1;那样做会让人以为工具什么也没做。超出 0.2…5 的伽马同样被拒绝。默认的灰色目标是取样点自身的均值,因此像素大致保持其亮度,只失去色偏。
这些求解器在审计中精确到 6.7e-16。诚实的现状是:它们位于 levels-picker.mjs 中,尚未接入面板。面板编辑的是主设置,而逐通道设置目前只能通过已保存的项目到达引擎。下文的色卡路径在调色的输入端另有一份逐通道的 Levels 记录。
从色卡取得黑白场
吸管的第一版把被点击的像素送到纯黑和纯白,这与 Photoshop 的默认值一致,但对 ColorChecker 来说是错误的。Classic24 的公布的 sRGB 值,归于 X-Rite 名下,把白色块放在 243、黑色块放在 52。把白色块送到 255,会丢弃场景中 243 到 255 之间的一切;把黑色块送到 0,则压扁了 52 级的阴影。X-Rite 自己的曝光指南 用直白的话说了同样的内容:白色方块应当很亮,但不是纯白;黑色块不应当读作纯黑。白色块是一个约 90% 的漫反射体,所以画面中的镜面高光应当比它更亮。
Photoshop 允许你编辑其吸管的目标颜色,这说明 0 和 255 只是一种约定,而不是这个工具的固有属性。因此求解器接受一个目标,并且附有一个色卡预设,使用已公布的数值(适用于 2014 年 11 月之后制作的色卡;更早的色卡数值不同)。
先点黑色再点白色,会产生第二个问题:第二次点击会移动输入白场,而输入范围内的每个位置都随之移动,因此黑色块离开了它的目标。自由手动的取样无法避免这一点,因为每次点击只知道它自己的参照。而色卡上两个参照同时已知,因此这一对可以一起求解:
w − b = (s_w − s_b) / (u_w − u_b), b = s_b − u_b · (w − b)
端到端接通的路径,从我的色卡项目中一个独立的 UNet/VAE 模型开始。它预测在你选定的画面位置上,一块 Classic24 会呈现什么样子,从而给出 24 个虚拟色块,而不是检测到的物理色卡。随后一个 Python 求解器把预测出的色卡转换为逐通道的 Levels:
# packages/checker/vendor/checker_levels.py, nested inside solve(); docstring omitted.
# black_target and white_target are the chart's published values for patches 24 and 19.
def ends(gamma: np.ndarray):
low, high = black_target ** gamma, white_target ** gamma
span = (white_sample - black_sample) / (high - low)
return black_sample - low * span, span
伽马来自两端之间的四个灰色块。每个灰色块在每个通道上通过在 0.2…5 之内进行 60 步二分查找,各自求出所需的伽马,并取中位数的结果。最小二乘平均会让一个坏的色块拖累全局:三个灰色块都要求伽马 1,而第四个被压坏到 0.02,此时拟合会要求 2.74。单独依靠一个灰色块,又以另一种方式脆弱:在我测到的第一张真实照片上,单独的 20 号色块在五次放置中有三次无法到达,而它周围的灰阶却很正常。
求解器允许输入点位于 0…1 之外,因为低于 0 的黑场,从输入侧看正是提亮(lift)的样子。拒绝这样的点,在那张第一张照片上使五次放置中的四次失败。对 JS 吸管的一次测试,还纠正了我自己的一个断言:它断言色卡目标不会截去任何东西,而测试失败了,因为在暖光下,绿色通道的输入白场落在了 0.949。高光被允许比漫反射白色块更亮,所以这个断言现在描述的是色卡目标真正给出的结果:色块不再是天花板。JS 吸管与这个 Python 求解器位于不同的仓库中,因此一个测试固定了两者都必须遵守的夹具。
导致十六个码值误差的 LUT
色卡的结果保存为一个小的 inputLevels 记录,在调色的输入端逐通道精确求值。直到 9 月 23 日为止,它都被烘焙进一个 65³ 的输入 LUT 中。Levels 是可分离的,因此三线性和四面体采样都可以化为相距 1/64 的节点之间的线性插值,而 x^(1/γ) 在黑场旁边最为陡峭。审计测得这里有 13.8 个码值的误差,新的检查在伽马为 3 的样本上发现了 16.3 个码值。精确求值把它降到了 1.1e-16。

为了写这篇文章,我用引擎重现了这一陷阱:黑场 0.10、白场 0.95、伽马 3,烘焙进 65³ 的 CUBE,重新导入,并在一条密集斜坡上进行比较。在一条 2,049 个点的斜坡上,最大误差为 21.3 个码值,出现在黑场正下方,那里精确结果为 0,而格点仍向下一个节点插值;黑场正上方的误差为 10.7。采样足够细时,两侧的峰值约为 22.7 个码值。输入高于 0.16 之后,误差保持在约四分之一个码值(最多 0.251)。当一个可分离的逐通道函数可以被精确求值时,它就不应该放进三维格点中。
自动色阶与自动色彩:已设计,未实现
引擎中还没有自动按钮。设计已经写下来:来自所选阶段与区域未改动输入的直方图,两端可见的截去比例,以及按这些分位数确定的黑白场。逐通道模式为每个通道取一对数值并改变色彩;复合模式取一对数值并保持中性轴。小于某个阈值的范围表示"未定义",而不是"恒等",所找到的参数会被保存,使重新打开项目时永远不会悄悄地重算它们。之所以必须显示这些比例,是因为参照本身也没有定论:Adobe 关于快速影调调整 的页面,在不同章节中同时提到 0.5% 和 0.1% 作为截去的默认值。
限制
- Levels 在主窗口中运行于 8 位 SDR 编码 sRGB。图像上没有截去预览,只在直方图上对两端加以阴影标注。
- 面板编辑主设置。逐通道吸管作为经过测试的求解器存在,但尚未接入面板。
- 色卡的目标是某一代色卡的已公布数值,而不是你的色卡经分光光度计读取的数值。
- 没有自动色阶,也没有自动色彩。
下一部分:曲线:样条选择、过冲、亮度与 LUT 烘焙。我的设备端修图工作,即一个带有自有模型的 C++ Photoshop 插件,见 happyin.ai。