按颜色范围调整色相、饱和度与明度:在 Oklab 而非 HSL 中计算
作者: Anastasiia Butova, ComfyUI 与扩散模型工程师,贝尔格莱德 · 发布于 2026-09-23
译自英文版: English
曲线 会改变所有共享同一明暗的颜色。本部分的工具只改变其中一部分颜色:把绿色推向黄色,压暗天空,让皮肤保持不动。照片编辑器通常把这一类工具称为 HSL。在我的调色工作室里,这些工具没有一个运行在 HSL 中。下文讲颜色范围如何被选取,为什么编辑发生在 Oklab 中,明度如何在不带动色相的情况下变化,肤色和灰色如何受到保护,以及第一版数学中的两个 bug。
代码来源
引擎是 LUT Atelier,它是我私有 hub 仓库中更早的一个编辑器,在提交 fd96f16 处固定到工作室中,每个文件都有 SHA-256(共 87 个文件,其中之一是内置的 nlohmann json.hpp);第 1 部分 讲述了这段历程。颜色数学是基于标准库的 C++20。在公开产品给了我界面灵感的地方,我会注明出处,但不声称其算法。
为什么不用 HSL
HSL 和 HSV 适合用作取色器,但不适合用来编辑。HSL 的明度是对编码后通道的算术运算,即 (max + min) / 2,因此纯红、黄、绿、青、蓝和品红的 HSL 明度都是 50%。而在 Oklab 中,它们的明度从蓝色的 0.45 到黄色的 0.97 不等。一个在 HSL 中标注"明度 +10"的滑块,会对不同色相产生明显不同的效果;在 HSL 明度保持不变的情况下旋转色相,会把一种浅黄色变成深蓝色。

引擎确实包含一个 rgbToHsl 函数。唯一导入它的模块是视图代码,没有任何编辑会调用它。所有范围工具都在 Oklab 中工作,这是 Björn Ottosson 设计的色彩空间:L 是明度,(a, b) 给出色度 C = √(a² + b²) 和色相 h = atan2(b, a)。Ottosson 把这项工作列为其目标之一:在提高饱和度的同时,保持感知上的色相和明度不变;他还指出,CIELAB 对蓝色色相的预测很糟。而蓝色正是色相编辑通常露出接缝的地方。
有一个性质我直接利用:Oklab 是一个矩阵、一次立方根和另一个矩阵的组合,因此把线性 RGB 乘以 k,会使 L、a、b 都乘以 k 的立方根。比值 C/L 和色相不会随曝光而改变。我在一个暖色上用 k = 2 验证过:每个坐标都乘以 1.2599,C/L 保持在 0.1531,色相保持在 53.42°。这就是为什么下面的肤色权重使用 C/L 而不是 C。
六个带有柔边的色相扇区
范围工具有六个明度滑块和六个色相滑块。它们的中心位于 Oklab 角度 0°、60°,一直到 300°。这些是角度,而不是颜色的名称:纯 sRGB 红色在 Oklab 中位于 29°,黄色位于 110°,蓝色位于 264°。因此界面会在每个滑块下面画出一条真实色相的色带,而不是把其中某一个称为"红色"。
每个扇区都有一个升余弦权重,在距中心 60° 处降为零:
w_i = 0.5 · (1 + cos(π · d_i / 60)),当 d_i ≤ 60° 时;否则为 0
其中 d_i 是像素色相到中心的圆周距离。相邻两个权重之和总是 1,因此处于两个扇区之间的颜色,恰好获得一次完整的编辑,并在两者之间分配。60° 这个截断很重要:cos(π·d/60) 每 120° 重复一次,如果没有截断,0° 扇区也会在 120° 和 240° 处以全权重起作用。

在接近灰色时,色相角度是噪声,因此编辑还会乘以一个色度门控 q(C) = smoothstep(0.015, 0.05, C)。带有偶然轻微色偏的灰色,不会被当成橙色那样去提亮。color.cpp 创意阶段中的循环如下:
if (hasBands) {
const double chromaWeight = smoothstep(.015, .05, chroma);
double deltaHue = 0.;
for (int i = 0; i < 6; ++i) {
const double distance = hueDistance(hue, i * 60.);
if (distance > 60.) continue;
const double weight = .5 * (1. + std::cos(kPi * distance / 60.)) * chromaWeight * amount;
out[0] += grade.bands[i] * weight;
deltaHue += grade.hueBands[i] * weight;
}
if (deltaHue != 0.) {
const double angle = deltaHue * kPi / 180., cosine = std::cos(angle), sine = std::sin(angle);
const double a = out[1], b = out[2];
out[1] = a * cosine - b * sine; out[2] = a * sine + b * cosine;
}
}
这里有两个决定清晰可见。hue 和 chroma 取自基础颜色,即在任何其他创意编辑之前,所以早先的色相旋转不会把某个像素移到另一个滑块的范围里。另外,六个扇区的色相偏移量先全部求和,然后作为一次旋转统一施加,这样在这一阶段 L 和 C 保持不变。
不带色相偏移的明度
明度滑块会把最多 ±0.2 加到 Oklab 的 L 上,而 a 和 b 保持不变。色相滑块(±30°)旋转 (a, b),而 L 保持不变。全局的色相和饱和度控件遵循同样的规则:饱和度缩放到中性轴的距离,色相则转动角度。数学检查(npm run check:math)对此进行了测量:在全局色相和色度编辑之后,Oklab L 的变化为 0,色度误差为 2.7 × 10⁻¹⁵。
这适用于创意阶段,我也不声称更多。在它之后,还有软色域压缩、对比度、曲线、照片阶段以及通道混合器。当软压缩开启时(Grid Lab 为新调色启用它),它会把超出色域的颜色,沿线性 RGB 中朝向同一 L 的灰色移动,因为固定 L、固定色相的 Oklab 射线在蓝色附近并不安全;第 1 部分解释了原因。至少从灰色到色域边缘距离的前 80% 保持精确。超过那个拐点后,压缩会使 L 和色相略有偏移,文档对此明说,而不是假装这个不变量仍然成立。
保护肤色和灰色
有三种保护,它们的强度并不相同。
柔性滑块只会减弱创意阶段。它们的权重是:
neutral = 1 − smoothstep(0.015, 0.12, C)
skin = exp(−(d(h, 35°) / 28°)²) · smoothstep(0.015, 0.06, C/L) · smoothstep(0.005, 0.025, L)
创意编辑按 1 − max(neutralStrength · neutral, skinStrength · skin) 缩放。早先的版本以 L = 0.35 为阈值来门控肤色,这悄悄地使深色皮肤失去了保护。改用 C/L 后,阈值消失了;只有紧靠黑色的区域(那里色相不可靠)会淡出。已保存的项目中,肤色保护现在对深色更强了,这一改动是有意为之的。这些数值是我的工程启发式规则,而不是公开发表的肤色模型,也不是分类器。一张暖棕色沙发位于这个瓣区之内,而在有色光照下的皮肤可能落在其外。测试覆盖的是样本颜色上的数学,而不是对一组人像照片的识别。
色相锁定更强。每个锁定都有中心、完全保护的半宽、羽化和最小色度,最多可设十二个。Grid Lab 的预设是:肤色为 50° ±25°,羽化 15°;绿色为 145°;天空为 250°;吸管则创建一个 ±12° 的窄锁定,羽化 8°。权重取自原始像素,而混合回去只在最后发生一次:
out = original + (processed − original) · strength · (1 − maxProtection)
因此,被锁定的颜色无论 LUT、白平衡或曲线做了什么,都会回到其精确的源 RGB;导入的 LUT 也无法改变哪些像素被视为受保护。
第一个 bug 就出在这里。从 sRGB 转换到 Oklab 后,纯灰色的色度很小,在白色处为 3.7 × 10⁻⁸,更暗的灰色更小,而色相为 89.9°。因此,任何最小色度为 0、且色相范围覆盖 90° 的锁定,都会保护画面中的每一块灰色。修复中的阈值取自 CSS Color 4,它把 OkLCh 的色相在 C ≤ 0.000004 时视为无效(powerless)。在此之下,像素没有色相,色相锁定也就无权认领它。检查现在对 256 个 sRGB 灰度进行转换:最大色度为 8.5 × 10⁻¹¹,每个锁定权重均为 0。
切开灰色的弦
保护和羽化的选区都会"部分地"应用编辑。第一版做了最直接的事:先算出完全旋转后的 (a, b),再按权重把它与原始 (a, b) 混合。这种混合是沿色相圆的一条弦进行的,而不是沿弧线。在权重 0.5 处,180° 的旋转恰好落在灰色上;而 90° 的旋转会损失 29% 的色度,因为半程弦的长度是 |cos(θ/2)|。

2026 年 9 月 22 日,对引擎进行的五次独立只读审查在两处发现了这个问题:一次在全局色相和饱和度的保护加权中,另一次在选择性颜色的羽化边缘中。现在,两者都以极坐标形式缩放编辑本身:角度为 A·θ,色度因子为 1 + A·(s − 1)。以下是 photo_color.cpp 中选择性颜色的版本:
// The feather scales the edit in polar form; a Cartesian mix cut across the hue circle.
const double newChroma = std::hypot(lab[1], lab[2]) * (1. + membership * selection.saturation);
const double angle = std::atan2(lab[2], lab[1]) + membership * selection.hue * kPi / 180.;
lab = {lab[0] + membership * selection.lightness, newChroma * std::cos(angle), newChroma * std::sin(angle)};
检查断言:在隶属度 0.5 时,色度误差为 3.9 × 10⁻¹⁶,色相恰好转动所请求量的一半。JavaScript 实现与 C++ 引擎在相同输入上进行比较:极坐标保护一致到 6.1 × 10⁻¹⁵,带有极坐标选择性颜色的照片阶段一致到 1.1 × 10⁻¹⁴。
取一个颜色,而不是一个扇区
扇区是有意做得粗糙的。若要表达"这种绿色,而不是所有绿色",有两个基于取样的工具。
选择性颜色最多接受八个取样。每个取样在 Oklab 中有一个半径,计算时 L 也计入,在由羽化滑块设定的内半径(默认为半径的一半)之内隶属度为完整的 1,并在半径处以 smoothstep 淡出为零。隶属度只从未经修改的源颜色一次性算出,因此早先的编辑无法改变哪些像素属于它。编辑形式为:L 加上 m·ΔL,C 乘以 1 + m·Δs,h 加上 m·Δh,并可选择向一个目标颜色拉近。受保护的颜色是类似的取样,但没有编辑:在其内半径之内,原始 RGB 会被原样返回。
A/B 网格中的颜色锚点走得更远:在 a/b 平面上最多可设 24 个锚点,每个要么被移动,要么被锁定。为工作室页面设计的交互式原型,为每个锚点加入了一个紧支撑核函数并求和。以两个相距半个半径的锚点为例:A 请求 Δa = 0.10,B 请求保持不动,那么 B 被移动 0.10 × φ(0.5) = 0.019。引擎改为求解核函数方程组:K c = d,其中 K_ij = φ(‖p_i − p_j‖ / R),核函数为 Wendland C²:φ(t) = (1 − t)⁴ (4t + 1)。于是位移场精确地通过每个请求的位移,而锁定就是位移为零。求解的复杂度为 O(n³)(n ≤ 24),每次编辑运行一次,而不是每个像素运行一次。锚点之间距离小于 0.001 R,或系统中主元小于 10⁻⁵,都会被以错误拒绝,而不是硬解。
限制
- 这里的一切都是在颜色空间中选取,而不是在画面中选取。米色的墙和米色的脸颊会得到同样的处理。同样的事实也使其可以导出:本部分的每个工具都只是输入 RGB 的函数,可以烘焙进
.cube,即第 6 部分的内容。 - 没有语义上的肤色蒙版,也没有商业编辑器那种八段 HSL 面板及其颜色范围边界。Lightroom Classic 的颜色混合器与点颜色 让我看到,在滑块旁边显示受影响的范围是多么有用。我借鉴的是这种界面思路,而不是它的公式。
- 路径是 SDR sRGB,Oklab 在 D65 下从线性 sRGB 计算。不支持 Log、HDR 和任意 ICC 输入。
- 大幅移动的颜色锚点场仍可能把两种颜色合并到一起。Grid Lab 的限制器会检验该场的饱和度和匹配情况,但尚未针对保护权重进行检验;这种情况是一个待办问题,而不是已解决的问题。
下一部分是 通道混合器:对 RGB 通道做 3×3 矩阵运算,这里的感知机制都不适用,数学也更简单、更粗暴。