happyin.work / 博客

English · Русский · Español · Deutsch · Português (Brasil) · 简体中文 · 日本語

曲线:自然样条与 PCHIP、过冲、亮度与 LUT 烘焙

作者: Anastasiia Butova, ComfyUI 与扩散模型工程师,贝尔格莱德 · 发布于 2026-09-23

译自英文版: English

曲线编辑器看起来像一个绘图工具,但其底层是一个决定,它的可见后果很多:如何画出一条穿过你所放置各点的平滑线。我的调色引擎提供三种答案,并在不同的地方使用其中两种。本部分讲的是为什么这样选择,过冲会给图像带来什么,主 RGB 曲线与亮度曲线有何不同(附引擎的测量数据),以及曲线被烘焙进 LUT 之后会发生什么。

引擎中的曲线是什么

一条曲线有 2 到 16 个点,两个轴都在 0…1 之间,x 严格递增。两端的点始终保留(不能删除);点击可以添加一个点,方向键每次移动选中的点 1/255(按住 Shift 为 10/255),Delete 删除一个内部点。在 第 1 部分 的流水线中,主曲线先作用于每个编码的 RGB 通道,然后是 R、G、B 曲线,而亮度曲线稍后在照片阶段中运行。插值模式有三种:线性、smooth(PCHIP)和 spline(自然三次样条)。一种模式同时用于主曲线、R、G、B 曲线;亮度曲线始终使用 PCHIP。在模式字段出现之前保存的项目保留线性模式,因为它们当初就是用线性做的。

系数在每次调色时只编译一次。在像素循环中,只有一个在最多 15 个区间中的段查找和一次 Horner 求值,没有内存分配,也没有验证:

// native/src/color.cpp
double sample(double input) const {
  if (input <= x.front()) return y.front();
  if (input >= x.back()) return y.back();
  std::size_t index = 0;
  while (input > x[index + 1]) ++index;
  if (input == x[index + 1]) return y[index + 1];
  const double t = (input - x[index]) / (x[index + 1] - x[index]);
  if (mode == CurveMode::linear) return y[index] + (y[index + 1] - y[index]) * t;
  const double value = ((d[index] * t + c[index]) * t + b[index]) * t + y[index];
  return mode == CurveMode::spline ? clamp(value) : value;
}

最后一行就是本文的全部要点,浓缩在一个表达式里:只有样条需要钳制。

自然三次样条及其代价

样条模式在各点上求解二阶导数 M_i。设 h_i = x_(i+1) − x_i,割线斜率为 d_i,则方程组是三对角的:

h_(i−1)·M_(i−1) + 2(h_(i−1) + h_i)·M_i + h_i·M_(i+1) = 6(d_i − d_(i−1)),   M_0 = M_(n−1) = 0

当点发生变化时,引擎用 Thomas 算法求解,然后为每个段存储以局部参数 t 表示的三次系数。在每个点上,数值、一阶导数和二阶导数都是连续的,这使它成为穿过一组点时最柔和的形状。新建的 RGB 曲线默认使用它。我用 SciPy 的自然 CubicSpline 作为外部参照进行了检验:JavaScript 版本与其一致到 6.7e-16,C++ 引擎与 JavaScript 版本一致到 3e-13,包括一条强到会发生截断的曲线。

代价是过冲。自然样条不知道输出必须保持在 0…1 之内,也不知道穿过递增各点的曲线应当保持递增。

Four control points drawn by the natural spline and by PCHIP: the spline dips below 0 in the shadows and rises above 1 in the highlights, and the clamp turns both into flat areas

这是我为浏览器中样条模式检查所用的四个点:(0, 0)、(0.36, 0.2)、(0.52, 0.68)、(1, 1)。在钳制之前,样条达到 −0.037 和 1.041。在一个包含 10,001 个输入的密集斜坡上,输入到 0.235 之前它低于 0,从 0.714 开始高于 1。经过 SDR 钳制之后,这就变成了前约 60 个输入码值上的纯黑平坦区,以及最高 73 个码值上的纯白平坦区。在这些平坦区内,曲线不再是 C²,也不再是严格递增的:码值 60 以下的阴影细节变成了同一个数值。对这条曲线而言,样条与 PCHIP 在输入 0.70 处最多相差 31 个输出码值。这不是线条看起来怎样的风格差异,而是一张不同的照片。

PCHIP:不产生新的极值,少一阶导数

smooth 模式是 PCHIP,即带有 Fritsch–Butland 切线的分段三次 Hermite 插值(Fritsch 和 Butland,1984),它满足 Fritsch 和 Carlson,1980 的单调性条件。每个内部切线是两侧相邻割线的加权调和平均,而当两条割线符号不同或其中一条为零时,切线被置为零:

m_k = (w1 + w2) / (w1/d_(k−1) + w2/d_k),   w1 = 2h_k + h_(k−1),   w2 = h_k + 2h_(k−1)
m_k = 0  if d_(k−1)·d_k <= 0

两端的切线使用单侧三点估计,并对符号和大小加以限制,与 SciPy 的 PchipInterpolator 使用的规则相同。结果是穿过每个点,一阶导数连续,并且在相邻点之间不会产生新的极值。如果各点递增,曲线就递增。二阶导数可以在某个点上发生跳变,这就是标题中"少一阶导数"的含义;在实际中,它表现为弯曲处稍微不那么圆润。

数学测试套件在 1,025 个输入上对一条单调 PCHIP 曲线进行采样,并检查没有任何一步是下降的;它测得的最小步长为 2.2e-6。对于图中的曲线,PCHIP 的最小步长为 1.5e-6,而经过钳制的样条的步长正好为零。在编辑器中,PCHIP 模式标注为"无过冲"。

那为什么两种都保留?因为它们回应的是不同的需求。对于在数值范围内部、平缓地穿过各点的弯曲,样条给出更柔和的形状,两者差别很小。对于接近黑白两端的强烈 S 曲线,PCHIP 能保住样条会丢掉的阴影与高光层次。这个模式只是一个开关,并随项目一起保存。

主 RGB 曲线与亮度曲线

主 RGB 曲线对编码后的 R、G、B 施加同一个函数。灰色仍然是灰色。但颜色不会保持其通道比例,因此对比度曲线也会提高饱和度,并让色相略有偏移。亮度曲线的用途是回答"改变明暗,但不动颜色"。它作用于线性亮度,使用 BT.709 权重,Y = 0.2126R + 0.7152G + 0.0722B,并重建 RGB,使新亮度恰好等于 f(Y):

// native/src/photo_color.cpp
std::array<double, 3> linear{srgbToLinear(levelled[0]), srgbToLinear(levelled[1]), srgbToLinear(levelled[2])};
// Luma curve: RGB' = RGB * (f(Y) - f(0)) / Y + f(0), so Y' = f(Y) exactly (see photo-color.mjs).
const double y = lumaOf(linear);
const double mappedY = luma.sample(y);
if (y > kLumaFloor) {
  for (double& channel : linear) channel = channel * (mappedY - lumaBlack) / y + lumaBlack;
  linear = clipToLuminance(linear, mappedY);
} else {
  linear = {mappedY, mappedY, mappedY};
}

增益两侧的两行都是修复。第一个版本把 RGB 乘以 f(Y)/Y。当黑场被提升时,f(0) > 0,这个增益在接近黑色处会爆炸:比零高一个码值的蓝色变成了 255。把提升视为中性偏移,即把 f(0) 加到三个通道上,就能让它保持为提升。第二个修复针对落在 0…1 之外的通道。逐个通道截断会让目标亮度偏差最多 0.137,并使色相偏移最多 13.6°。引擎现在采用 ISO 32000-1 §11.3.5.3 中的 ClipColor 构造,它属于 PDF 规范中的非可分混合模式:超出范围的通道会被拉向同一亮度的灰色,因此即使某个通道会被截断,也依然满足 Y' = f(Y)。数学测试套件测得亮度曲线的亮度误差低于 1e-15。

为了看看这带来什么好处,我让引擎用两种方式运行同一条 S 曲线。主曲线是在编码值上的 PCHIP S 曲线;亮度曲线的构造使灰色的结果与之相同,误差在 0.25 个码值以内。

Six colours under the same S-curve: the master RGB curve raises Oklab chroma by 15 to 31% and turns hue by up to 4 degrees; the luma curve leaves hue unchanged

在六个测试颜色上,主 RGB 曲线使 Oklab 色度提高了 15% 到 31%,色相偏移最多 4.0°。亮度曲线使色度变化在 −8% 到 +7% 之间,色相偏移为 0.0°。色度仍有少许变化,因为即使色品(chromaticity)保持不变,Oklab 色度也会随明度变化。Capture One 在其 亮度曲线 中记载了同样的意图:对比度提升,而饱和度不增加;Adobe 的 Camera Raw 点曲线则提供复合曲线和逐通道曲线。两者都没有公布公式,我的公式是自己的。

这对界面有一个结果:亮度曲线位于线性亮度刻度上,所以相同的点坐标在亮度曲线与主曲线上对应的色调不同。编辑器在自己的刻度上显示它,并且不为它绘制通道叠加线。

感知轴曲线:已设计,未实现

一个候选的曲线工具已经写下来,但尚未进入面板或引擎。它把三条曲线放在 Oklab 轴上,即 L2 = fL(L)、a2 = fa(a)、b2 = fb(b),中心被锁定,fa(0) = 0 且 fb(0) = 0,因此任何中性色都保持中性;只有通过显式操作才能解除锁定,并且会给出中性色将会偏移的警告。结果通过蒙版权重 m 和强度 s 混合进来,out = (1 − s·m)·source + s·m·OklabToRgb(L2, a2, b2),应用之前会显示两项检查:中性色锁定是否被破坏,以及颜色是否超出色域。那里的色域压缩必须是一个显式模式,绝不能是悄悄的钳制。

将曲线烘焙进 LUT

调色中所有全局的部分(明暗、颜色、曲线、输入 LUT、颜色保护)都只依赖输入 RGB,因此可以导出为 3D CUBE 或 Hald 图像。清晰度或去雾等空间编辑依赖邻域和位置,引擎拒绝把它们写入 LUT;编辑器只有在你明确确认空间编辑将被省略之后,才会导出此类调色中仅限颜色的部分。烘焙在 N³ 个格点上对完整的变换进行采样,N 从 2 到 65,红色变化最快。

在格点上,CUBE 与引擎一致:对于仅有样条的调色,33³ 时误差为 3e-8,这是浮点精度;对于使用了所有全局控件的调色,误差为 1.7e-6,在审查的 2e-6 阈值之内。在格点之间,那就是插值,诚实地说明其大小很重要。对于那个完整的调色,4,096 个非格点样本在 33³ 时给出的 RMS 误差为 0.010(约 2.6 个码值),在 65³ 时为 0.0054。最差的单个样本要差得多:在 33³ 时,一个接近黄色的输入,其蓝色通道高出 0.53;在 65³ 时,最差样本仍偏差 0.27。审查将导出标记为 HOLD,即 CUBE 只是这个调色的近似,而不是副本;原生图像或项目才是忠实的输出。引擎有一个验证调用,对当前正在导出的任何调色,返回这两个数字,即 4,096 个样本上的最大误差与 RMS 误差。第 2 部分 在单一的 Levels 设置上展示了同样的效应:在黑场附近,65³ 格点偏差约 23 个码值。

精度

颜色变换以 double 运行,只在写出 8 位或 16 位输出时取整一次。曲线是按像素解析计算的,而不是通过内部的一维表。主窗口使用浏览器的 8 位 sRGB Canvas,陡峭的曲线作用于 8 位源图像时,会把 256 个输入级别拉伸到更宽的范围上,这正是色带产生的原因。实验室窗口读取 16 位 PNG,不经过 Canvas,可以导出 16 位 PNG,并且在必须降到 8 位时可以施加有序的 8×8 抖动。抖动依赖于像素位置,因此它永远不会成为 LUT 的一部分。这些都无法恢复 JPEG 原本就没有的层次;它们只是保证编辑不会破坏更多。

曲线拖动本身也有问题,只是衡量的单位是时间而非码值。在第一个版本中,一次包含 61 个事件的拖动,只在第一次移动之后的 3.56 秒才绘制一次曲线。现在,曲线图会在每个动画帧上独立于处理过程进行重绘;编辑器一次只向 C++ 引擎发送一个预览,并且只保留最新的等待参数;曲线直方图按源图像缓存。同样的拖动现在会绘制 61 帧曲线,第一帧在 33.9 毫秒时出现,第一张处理后的图像在 267 毫秒时出现。一次撤销仍然可以撤回整个拖动。

限制

  • 自然样条是 RGB 曲线的默认模式,它可能在两端发生截断;对于接近黑白两端的强烈曲线,应使用 PCHIP 模式。
  • 亮度曲线始终是 PCHIP,作用于线性亮度,不声称与 Capture One 或 Adobe 一致。
  • 感知轴曲线是一项设计,而不是一项功能。
  • 复杂调色的烘焙 CUBE 只是近似;在发送到任何地方之前,请检查其最大误差和 RMS 误差。

下一部分:按颜色分别调整色相、饱和度与明度。如果比起调色,你对原生端更感兴趣,本站 happyin.ai 上有设备端的 C++ 修图插件。

Colour tools, built from scratch

  1. 曝光与白平衡:线性光中的三个乘数
  2. Levels:黑白场、伽马、通道与色卡
  3. 曲线:自然样条与 PCHIP、过冲、亮度与 LUT 烘焙
  4. 按颜色范围调整色相、饱和度与明度:在 Oklab 而非 HSL 中计算
  5. 通道混合器,一个 3×3 矩阵:它保留什么、在哪里截断、在哪里运行
  6. 3D LUT:把调色烘焙进 .cube 与 Hald,以及节点之间会丢失什么

更多文章

本站其他内容

Anastasiia Butova - ML engineer, Belgrade, Serbia. Email [email protected] · LinkedIn · GitHub · Telegram · Hugging Face