3D LUT:把调色烘焙进 .cube 与 Hald,以及节点之间会丢失什么
作者: Anastasiia Butova, ComfyUI 与扩散模型工程师,贝尔格莱德 · 发布于 2026-09-23
译自英文版: English
前面几部分的每个工具,从曝光到 通道混合器,都只根据旧的颜色计算新颜色,而不依赖其他东西。这样的函数可以存成一张表:在 RGB 输入的网格上采样,保存输出,中间的值通过插值得到。这张表就是 3D LUT,也是调色从我的调色工作室进入另一个程序的方式。本部分讲两种格式、插值、表格大小、导出时拒绝携带的内容,以及我把一次真实烘焙与引擎进行核对时测到的结果。
LUT 能承载什么
3D LUT 是一个采样得到的函数 T:[0,1]³ → [0,1]³。它对相同的 RGB 给出相同的答案,无论该像素位于画面的哪个位置:在一张脸上、一面墙上,或在另一张照片里。因此它可以承载曝光、白平衡、曲线、Levels、Oklab 范围工具、色相锁定、保护色和混合器。它无法承载任何依赖像素位置或邻域的东西:纹理、清晰度、锐化、去雾、坐标蒙版。
引擎不会指望界面替它记住这一点。main.cpp 中的导出处理程序自己会拒绝:
if (type == "cube" || type == "hald" || type == "verify") {
if (atelier::hasSpatialEdits(spatial)) fail("spatial edits cannot be exported as a 3D LUT");
当空间编辑处于激活状态时,导出对话框会列出将被省略的内容,并且保持"仅颜色"选项未勾选;只有显式勾选,才会发送一个恒等的空间上下文。原生端的拒绝是最后一道防线,这样即使对话框出现漏洞,也不会导出一个悄悄丢掉锐化的 LUT。
严格读取 .cube 文件
该格式遵循 Adobe 2013 年的 Cube LUT Specification 1.0(Adobe PDF 的存档副本):一个文本文件,包含 TITLE、LUT_3D_SIZE、可选的 DOMAIN_MIN 和 DOMAIN_MAX,然后是 N³ 行,每行三个数,红色变化最快,因此行索引为 r + N·g + N²·b。规范允许 N 从 2 到 256。我的解析器接受 2 到 65,只接受三维表。它会拒绝一维和 shaper 文件、未知指令、非有限数字,以及行数不对的文件,并给出指明问题的错误信息;它把输入当作编码后的 sRGB,而不是根据文件名去猜测 Log 或相机配置。
9 月 22 日,五次独立的只读审查发现了三个解析器 bug,其中第一个是工作室读回自己的文件时暴露出来的:
- 引号包围的
TITLE中的#被当作注释删掉了,因此工作室无法重新导入它自己导出的某些文件。 - JavaScript 与 C++ 解析器在一个测试语料库的 50 个文件中,有 9 个结果不一致。现在两者共享一套语法,用两种语言按相同的正则定义数字来编写。
- 写入器把非 ASCII 字节放进了
TITLE。规范只允许从空格到波浪号之间的字符,并且不允许引号。现在写入器会保留这些字符,并把其余的全部转换为空格。
Hald:伪装成图片的 LUT
Hald CLUT 把同样的立方体存成一张图像。在级别 L 下,立方体每个轴有 N = L² 个值,图像的边长为 L³ 像素;把它读成一整行像素时,红色变化最快,然后是绿色,再是蓝色。我遵循的首要描述是 ImageMagick 的 Hald 一节:级别 8 是一张 512 × 512 的图像,包含 64³ 种颜色。

工作流很简单:保存一张中性的 Hald,用任何编辑器对其做仅限颜色的操作,保持其尺寸不变,然后作为 LUT 重新打开。工作室也会把当前调色写成 Hald。这种格式无法证明它就是 Hald,所以导入器只检查形状:一个边长为完全立方数的方形 PNG,级别为 2 到 8,且完全不透明。任何 512 × 512 的普通方形照片都能通过这个检查,并变成无意义的东西,这就是文档明确说明不要导入普通照片的原因。
图像路径中有两个缺陷,但它们都不在数学上。Hald 的导入经过浏览器 Canvas,而当 PNG 的 gAMA、cHRM 或 iCCP 数据块描述的不是 sRGB 时,Canvas 会对其进行颜色转换。一个带标签的 Hald 在读取任何表值之前,就最多偏移了 136 个码值。现在导入会自己解码 PNG,并把它的 8 位或 16 位采样当作数字来读取。导出原本只有 8 位,现在是 16 位。差异在调色之前就能看到:64 个级别并不对应整数的 8 位码值,因此一张 8 位的恒等 Hald 在其节点上已经有最多 0.0019 的偏差(以 0..1 为单位)。在 16 位下,同样的误差是 7.3 × 10⁻⁶。
三线性还是四面体
在节点之间,引擎用两种方式之一进行插值。三线性插值混合立方体单元的全部八个角。四面体插值则对单元内的三个小数坐标进行排序,选取六个四面体之一,并混合其中四个角。来自 cube.cpp:
if (fr >= fg && fg >= fb) { p0=c000; p1=c100; p2=c110; p3=c111; t1=fr; t2=fg; t3=fb; }
else if (fr >= fg && fr >= fb) { p0=c000; p1=c100; p2=c101; p3=c111; t1=fr; t2=fb; t3=fg; }
else if (fr >= fg) { p0=c000; p1=c001; p2=c101; p3=c111; t1=fb; t2=fr; t3=fg; }
else if (fr >= fb) { p0=c000; p1=c010; p2=c110; p3=c111; t1=fg; t2=fr; t3=fb; }
else if (fg >= fb) { p0=c000; p1=c010; p2=c011; p3=c111; t1=fg; t2=fb; t3=fr; }
else { p0=c000; p1=c001; p2=c011; p3=c111; t1=fb; t2=fg; t3=fr; }
return {p0[0] + (p1[0] - p0[0]) * t1 + (p2[0] - p1[0]) * t2 + (p3[0] - p2[0]) * t3,
p0[1] + (p1[1] - p0[1]) * t1 + (p2[1] - p1[1]) * t2 + (p3[1] - p2[1]) * t3,
p0[2] + (p1[2] - p0[2]) * t1 + (p2[2] - p1[2]) * t2 + (p3[2] - p2[2]) * t3};
两者都能精确再现任何仿射函数。在非线性的表上,它们结果不同,而且两者都不是处处更好。在一个 2³ 的表上,对 f = r·g·b 在点 (0.2, 0.5, 0.8) 处,三线性插值给出精确的 0.08,而四面体插值给出 0.2。所以"四面体更精确"并不是一条普遍规律。
四面体仍是默认值,而支持它的理由并不在于精度。Adobe 规范要求读取者对三维表使用四面体插值,OpenColorIO 的插值枚举 也把三维 LUT 的 "best" 映射为四面体,因此工作室采样文件的方式,与该格式期望的方式一致。第二个理由是几何上的。六个四面体中的每一个都以单元的对角线(从 c000 到 c111)为一条棱,因此灰色输入只从灰色节点插值得到。如果调色在节点上保持灰色中性,四面体也会在节点之间保持灰色中性。三线性插值则会混入彩色的角。
为了看清这一效应的大小,我把一个合成的风格(对 Oklab L 做 S 曲线,色度乘以 1.5,色相 +12°,并截断到 sRGB)烘焙进一张 17³ 的表中,然后用引擎采样器的 Python 移植版进行采样。这只是一个示意图,而不是引擎的测量结果。

三线性插值把灰阶斜坡染色最多 1.74 个码值;四面体插值则保持在 1.4 × 10⁻⁵ 个码值,而那一点残留来自十位小数的 Oklab 矩阵,而不是插值本身。沿一个彩色渐变,两者相差不大,最坏情况分别为 15.1 和 13.9 个码值,而且两者的峰值都恰好出现在风格的某个通道穿过零、开始截断的地方。
表需要多大
导出提供 17³、33³ 和 65³ 三种选项,分别标注为紧凑、标准和密集。65³ 的选项过去写的是"精确";一次烘焙审查测得的结果并非如此,于是标签改了。
引擎自己的检查"验证导出"会烘焙这张表,把它序列化再重新解析,然后在 4096 个不在格点上的确定性颜色上,将其与直接变换的结果进行比较:
for (int index = 0; index < 4096; ++index) {
const atelier::Vec3 rgb{((index * 73 + 17) % 4099) / 4098., ((index * 193 + 47) % 4093) / 4092., ((index * 503 + 29) % 4091) / 4090.};
const atelier::Vec3 direct = transform(rgb);
const atelier::Vec3 sampled = atelier::sampleCube(decoded, rgb, grade.interpolation);
for (std::size_t channel = 0; channel < 3; ++channel) {
const double error = std::abs(direct[channel] - sampled[channel]);
maximum = std::max(maximum, error);
sum += error * error;
}
}
它报告最大误差和 RMS 误差。这是一项采样测试,不是误差界,也不是感知上的 ΔE。
9 月 12 日,我在一个刻意加重的调色上运行了它:所有全局控件同时开启,从一个导入的 LUT 和一个 pin,到一个暖色的色相锁定和选择性颜色。当时是引擎 0.3.0,早于第 4 部分所述的 9 月 22 日和 23 日的修复。
| 表 | 文件大小 | 节点上的误差 | 格点外最大误差 | 格点外 RMS 误差 |
|---|---|---|---|---|
| 33³ | 1.16 MB | 1.7 × 10⁻⁶ | 0.529 | 0.0102 |
| 65³ | 9.13 MB | 1.7 × 10⁻⁶ | 0.267 | 0.0054 |
在节点上(33³ 时全部 35,937 个节点,65³ 时每个轴上每隔四个节点取一个),文件能以浮点精度再现引擎。在节点之间则不然。最差的探测点是色域边缘的一个接近黄色的颜色,其蓝色通道应为 0.002;33³ 的表却返回了 0.531。节点间距为 1/32 的表无法表示比该间距更窄的变化,而保护边缘或截断恰恰就是这样的变化。把分辨率翻倍,误差减半,但并没有消除。验证回执把节点标记为 PASS,把节点之间标记为 HOLD,并记录了我现在用于导出的结论:它是一个全局的 RGB 近似;当这样的调色需要被精确复现时,载体应当是 PNG 或已保存的项目。
合成的风格表现出同样的特征。它的 RMS 误差随 N 每翻一倍大约减半,而由截断驱动的最大误差下降得更慢。

烘焙整条链路
烘焙本身是三层嵌套循环。cube.cpp 中的 bakeCube 接收一个已编译的 Transform(其中的 pin 系统已求解、曲线已编译、调色已校验),然后在 N³ 个节点上以红色优先的顺序求值,并存储为浮点数。同一个 Transform 类渲染预览、全尺寸 PNG 以及表格,因此预览、全尺寸 PNG 和表格共享同一份 C++ 调色实现。JavaScript 副本(compileTransform、bakeCube)仍然为缩略图、立方体视图和 Grid Lab 提供服务。
有一条教训是反向的:并非所有东西都应该在引擎内部穿过一张表。色卡求解器算出的 Levels 过去是通过一张采样的 65³ LUT 应用的,这给阴影带来了最多 16.3 个码值的误差。现在它们在输入端被精确求值,检查测得的差异为 1.1 × 10⁻¹⁶。
导出不做什么:色彩管理
工作室的 LUT 是编码 sRGB 到编码 sRGB,SDR,别无其他。主窗口通过浏览器 Canvas 将 JPEG、WebP 和 8 位 PNG 解码为浏览器 sRGB。16 位 PNG 路径只存在于 Grid Lab 中:它把没有标签的 PNG 当作 sRGB,并明说如此;遇到不支持的配置文件则拒绝,而不是悄悄转换。这里不读取 ICC,PNG 中也没有写入 ICC 配置文件(16 位导出只包含 sRGB 和 gAMA 数据块);也没有 Log、HDR 或 RAW 输入;导出提示也说明,显示器配置文件不包含在内。
支持配置文件的输入与导出,被记录为一个候选工具,附有验收约定,但尚未实现。合同规定:读取嵌入的 ICC 配置文件,否则停下来询问,绝不悄悄地假设为 sRGB;将其一次性转换到一个声明过的线性工作空间;把所选的输出配置文件写入 PNG,并在测试中读回;把显示变换与工作像素分开;绝不让显示器校准 LUT 漏进 CUBE、Hald 或项目。Photoshop 除了 CUBE、3DL 和 CSP 之外,还能把查找表导出为 ICC 配置文件(Adobe:导出颜色查找表)。我的导出器只写 CUBE 和 Hald。
把一种风格搬到另一帧
LUT 搬运的是函数,而不是意图。如果第二帧是在更暖的光线下拍摄的,那么同样的肤色会落在不同的 RGB 上,LUT 的回答也就不同。我为下一版准备的研究笔记把解决办法描述为两个阶段:先是每帧的归一化 N_i,在匹配色块上拟合并冻结;然后是共享的风格 T,使第 i 帧得到 T(N_i(x))。在当前引擎中,导入的 LUT 排在最前,白平衡和曝光排在它之后,因此这种顺序目前还不可用。
从单张前后对比照片中恢复 LUT 有一个硬性的限制。如果照片中每种颜色的 R 都低于 0.6,那么 T 和 T + δ·max(R − 0.6, 0)² 在每个观察到的像素上结果都相同,而在其他地方则不同。照片对它未包含的那部分立方体一无所知。因此 Hald 才是可靠的载体:它包含每一个节点。这也解释了对它进行调色的规则:不做缩放、不做 JPEG 压缩、不加局部蒙版或锐化,也不做会在 Hald 上自行重算的自动校正;应当在照片上冻结自动参数,然后把同一个函数应用到 Hald 上。另一个编辑器中隐藏的颜色配置文件转换,也会成为所提取的 LUT 的一部分。
LUT 停在哪里
所有依赖位置的内容都按设计排除在表格之外,而大量真实的修图工作恰恰就是这类:减淡与加深、体积感、皮肤。这是我所做工作的另一半。Happyin.ai 背后的模型在修图师自己的机器上生成逐像素的图(maps),而 C++ Photoshop 插件则把它们作为图层合成。这些都无法作为 LUT 传递。
本文结束了整个系列。它从 曝光与白平衡 开始,那是同一条链路的第一阶段,而本部分把这条链路变成了一张表。