你把一段灰蒙蒙的 Log 视频导入剪辑软件,套上一个 LUT,画面突然有了正常的明暗、鲜明的颜色,甚至还带上一点电影质感。这个过程看起来像魔法,背后其实只有一个很朴素的动作:查表。
LUT 是 Look-Up Table 的缩写,也就是“查找表”。它提前保存了一批“输入应该变成什么输出”的答案。真正有趣的问题是:表格不可能写下世界上每一种颜色,那么落在两个答案之间的颜色怎么办?
答案是插值。从一条线上的两个数字,到 RGB 立方体里的八个顶点,本文会一步步把这件事拆开。
本文中的 RGB 默认已经归一化到 。为了看清 LUT 本身,我们暂时不讨论亮度单位、显示设备和完整的色彩管理流程,最后再回来补上这些边界。
LUT:一本提前算好的“颜色字典”
假设有一张最简单的亮度表:
| 输入 | 输出 |
|---|---|
| 0.00 | 0.00 |
| 0.25 | 0.18 |
| 0.50 | 0.55 |
| 0.75 | 0.86 |
| 1.00 | 1.00 |
输入是 0.50 时,我们直接找到第三行,输出 0.55。与每次计算一条复杂曲线相比,查数组很快,而且同一张表可以在不同软件、不同设备之间传递。
真实的色彩管线通常不只查一次表。输入可能要先映射到 LUT 能理解的范围,再经过一条负责明暗分配的曲线,最后才交给负责整体色彩风格的 3D LUT。
一颗颜色,经过四个站点
切换预设,再点每个站点。先建立整条流水线的直觉,后面我们会拆开每一步。
这是进入处理管线的原始 RGB 数值。LUT 不会自动猜测它属于哪种色彩空间。
这几个阶段各有分工:
- Domain(定义域) 回答“输入数字落在哪个范围”。
- 1D LUT / Shaper LUT 擅长重新分配亮部与暗部的精度。
- 3D LUT 让三个颜色通道互相影响,完成更复杂的色彩变换。
LUT 只保存输入与输出的对应关系,不保存颜色的物理意义。0.5 可能是 sRGB 中间调,也可能是 Log 编码里的另一档曝光。给 LUT 喂错“语言”,即使查表和插值都完全正确,结果也会错。
1D LUT:先从一条亮度曲线开始
一维 LUT 可以想成一把被标了刻度的尺子。数组下标是输入位置,数组中的值是输出。
例如,下面这 5 个数字就是一张很小的 1D LUT:
lut = [0.00, 0.18, 0.55, 0.86, 1.00]它只有五个采样点,却代表了一条连续曲线。采样点越多,保存的曲线细节通常越丰富;采样点越少,查表结果越容易露出“台阶”。
1D LUT 曲线实验室
减少采样点,再切换插值方式。虚线是理想曲线,实线是查表后真正得到的曲线。
结果由第 3、4 项共同决定,右侧采样点的权重是 1%。
在组件中把 LUT 大小降到 4,再选择“最近邻”。实线会变成明显的阶梯:输入只要没有跨过某个中点,输出就一直不变。
要找到输入在数组中的位置,先把 映射到数组下标范围:
其中 是输入, 是 LUT 长度。假设 LUT 有 5 项,输入是 0.6:
value = 0.6size = 5position = value * (size - 1)
print(position) # 2.4位置 2.4 不对应一个真实的数组下标:它落在第 2 项与第 3 项之间。这正是插值要解决的问题。
一维线性插值:离谁近,就更像谁
如果上午 10 点是 18℃,11 点是 22℃,那么 10:30 可以先估成 20℃。这不是在声称温度一定匀速变化,而是在已知信息有限时,用一条直线给出合理估计。
线性插值通常写作:
表示从左端走到右端的比例:
- :完全位于左端,结果是 ;
- :正好位于中间,两边各占一半;
- :到达右端,结果是 。
用 Python 写出来只有一行核心计算:
def lerp(left, right, fraction): """在 left 和 right 之间进行线性插值。""" return left * (1.0 - fraction) + right * fraction
result = lerp(0.4, 0.8, 0.25)print(result) # 0.5完整的 1D LUT 采样还要处理索引与边界:
import math
def sample_lut_1d(lut, value): """使用线性插值采样一张输入范围为 [0, 1] 的 1D LUT。""" value = max(0.0, min(1.0, value)) position = value * (len(lut) - 1)
left_index = math.floor(position) right_index = min(left_index + 1, len(lut) - 1) fraction = position - left_index
return lerp( lut[left_index], lut[right_index], fraction, )这里有两个很容易漏掉的细节:
- 输入先被限制在 ,否则可能访问不存在的数组项;
- 当输入恰好是
1.0时,右侧索引仍然不能超过最后一项。
如果业务需要保留超出范围的高光,不能简单照抄这个 clamp。应当根据 LUT 格式和色彩管线的约定,选择裁剪、外推,或在查表前进行其他映射。
三条 1D 曲线,仍然只是三条曲线
彩色图像可以分别给红、绿、蓝三个通道配一张 1D LUT:
def apply_rgb_curves(color, red_lut, green_lut, blue_lut): red, green, blue = color return ( sample_lut_1d(red_lut, red), sample_lut_1d(green_lut, green), sample_lut_1d(blue_lut, blue), )这种方法适合 Gamma、对比度、通道增益等变换。但它有一个根本限制:红色输出只看红色输入,完全不知道绿色与蓝色是多少。
三条曲线,各管各的
每个输出通道只能看到自己的输入通道。试着改变蓝色曲线,观察所有含蓝色的色块一起变化。
想做到“只在 R 很少、G 和 B 同时很多时改变颜色”,必须同时查看三个通道——这正是 3D LUT 的工作。
假设我们只想找到“绿色和蓝色都很高、红色很低”的青色区域,再把它轻微推向蓝色。单独的蓝色曲线不知道绿色是否也很高;一旦抬起蓝色曲线,天空、紫色、肤色阴影等所有含蓝色的区域都会一起变化。
要表达“当 R、G、B 形成某种组合时才这样处理”,查表的输入就不能再是一根轴,而要变成三个维度。
3D LUT:每种颜色都是立方体里的一个位置
把 R、G、B 看成三条互相垂直的轴:
- 沿 R 轴走,红色增加;
- 沿 G 轴走,绿色增加;
- 沿 B 轴走,蓝色增加。
黑色 (0, 0, 0) 和白色 (1, 1, 1) 位于立方体的两个对角,红、绿、蓝、青、品红、黄占据其余角点。任意 RGB 颜色都能在这个立方体里找到一个位置。
把 RGB 放进立方体
从黑色原点出发,沿 R、G、B 箭头分别走向 1。彩色圆点是输入,蓝色小盒子是它所在的 LUT 网格单元。
- 输入颜色 P
- 所在网格单元
- 完整定义域 [0,1]³
P = (0.62, 0.38, 0.74)p = P × (5 − 1) = (2.48, 1.52, 2.96)5³ LUT 一共有 125 份样本;无论整个 LUT 多大,当前小单元仍然只有 8 个角。
17³ LUT 的意思不是“有 17 个颜色”,而是每条边放 17 个采样点,一共保存:
同理,常见的几个尺寸是:
| 尺寸 | 颜色样本数 |
|---|---|
| 4,913 | |
| 35,937 | |
| 274,625 |
即使有几十万个样本,输入颜色通常还是不会恰好落在网格点上。它会落进某个小立方体,而这个单元周围有 8 个顶点。接下来要做的,仍然是根据邻居估计当前位置的输出。
周围 8 个顶点究竟怎样取
先只看 R 轴。假设输入颜色的红色分量是 ,LUT 每条边有 个采样点,那么它在 R 轴上的数组位置是:
对这个位置向下取整,得到左边的网格索引 ;右边的索引就是 。G、B 两个方向也做同样的计算,于是得到三组索引:
R 方向:r0 或 r1G 方向:g0 或 g1B 方向:b0 或 b1每个方向都有“低侧”和“高侧”两种选择,组合起来正好有 个顶点:
| 名称 | R 索引 | G 索引 | B 索引 |
|---|---|---|---|
c000 | r0 | g0 | b0 |
c100 | r1 | g0 | b0 |
c010 | r0 | g1 | b0 |
c110 | r1 | g1 | b0 |
c001 | r0 | g0 | b1 |
c101 | r1 | g0 | b1 |
c011 | r0 | g1 | b1 |
c111 | r1 | g1 | b1 |
这里的 c 表示从 LUT 中取出的输出颜色;后面的三位依次对应 R、G、B。数字 0 表示使用这个方向的低侧索引,1 表示使用高侧索引。
所以,c100 不是 RGB 颜色 (1, 0, 0)。它的意思是:在当前小立方体里,R 选择高侧 r1,G 和 B 选择低侧 g0、b0,然后从 LUT 中读出那个位置保存的颜色。
亲手找到 c000 到 c111
让同一个输入点依次经过“投影 → 网格定位 → 放大单元 → 组合八角 → 读取数组”,把空间位置和代码里的 c000 连起来。
P = (0.62, 0.38, 0.74)。先只把它看成沿 R、G、B 三个方向走出的空间位置。
下面用 Python 完成从输入颜色到 8 个顶点的全过程。这里假设 .cube 数据已经展开成一维列表,并且 R 方向变化最快:
import math
def grid_coordinate(value, size): """返回低侧索引、高侧索引,以及输入在两者之间的比例。""" value = max(0.0, min(1.0, value)) position = value * (size - 1)
low = math.floor(position) high = min(low + 1, size - 1) fraction = position - low return low, high, fraction
def lut_index(red_index, green_index, blue_index, size): """将三维坐标转换为一维数组下标,R 方向变化最快。""" return (blue_index * size + green_index) * size + red_index
def read_lut(lut, size, red_index, green_index, blue_index):29 collapsed lines
index = lut_index( red_index, green_index, blue_index, size, ) return lut[index]
def get_cell_corners(lut, size, color): red, green, blue = color
r0, r1, fr = grid_coordinate(red, size) g0, g1, fg = grid_coordinate(green, size) b0, b1, fb = grid_coordinate(blue, size)
corners = { "000": read_lut(lut, size, r0, g0, b0), "100": read_lut(lut, size, r1, g0, b0), "010": read_lut(lut, size, r0, g1, b0), "110": read_lut(lut, size, r1, g1, b0), "001": read_lut(lut, size, r0, g0, b1), "101": read_lut(lut, size, r1, g0, b1), "011": read_lut(lut, size, r0, g1, b1), "111": read_lut(lut, size, r1, g1, b1), }
fractions = (fr, fg, fb) return corners, fractions例如,在 17³ LUT 中输入 (0.62, 0.38, 0.74),三个方向的位置分别是:
r0, r1, fr = grid_coordinate(0.62, 17)g0, g1, fg = grid_coordinate(0.38, 17)b0, b1, fb = grid_coordinate(0.74, 17)
print(r0, r1, fr) # 9 10 0.92print(g0, g1, fg) # 6 7 0.08print(b0, b1, fb) # 11 12 0.84因此 c000 来自 LUT 坐标 (9, 6, 11),c100 来自 (10, 6, 11),而对角的 c111 来自 (10, 7, 12)。另外五个顶点就是在这三组高低索引中依次排列组合。
三线性插值:沿三个方向做三次 lerp
“三线性”听起来像一种全新的算法,其实它只是把刚才的一维线性插值重复三轮。
设输入点在当前网格单元内的局部位置为 ,每个分量都位于 :
- 沿 R 方向对 8 个角点两两插值,得到 4 个颜色;
- 沿 G 方向把 4 个颜色变成 2 个;
- 沿 B 方向把最后 2 个颜色变成 1 个。
def lerp_color(left, right, fraction): return tuple( lerp(a, b, fraction) for a, b in zip(left, right) )
def trilinear_from_corners(corners, fr, fg, fb): """corners 使用 c000、c100……c111 命名。""" c00 = lerp_color(corners["000"], corners["100"], fr) c10 = lerp_color(corners["010"], corners["110"], fr) c01 = lerp_color(corners["001"], corners["101"], fr) c11 = lerp_color(corners["011"], corners["111"], fr)
c0 = lerp_color(c00, c10, fg) c1 = lerp_color(c01, c11, fg)
return lerp_color(c0, c1, fb)调用时,先用上一节的函数取得角点和局部位置:
corners, (fr, fg, fb) = get_cell_corners( lut, size=17, color=(0.62, 0.38, 0.74),)
output = trilinear_from_corners(corners, fr, fg, fb)print(output)三线性插值会使用全部 8 个顶点。它的结构规则、容易理解,而且 GPU 的 3D 纹理采样通常可以直接帮我们完成,因此在实时图形中非常方便。
四面体插值:把立方体切成六块
另一个常见方案是把一个立方体沿对角线切成 6 个四面体。输入点落在哪个四面体,就只使用那一块的 4 个顶点。
怎样判断点落在哪一块?比较 、、 的大小顺序即可。比如:
意味着输入沿 R 方向走得最远,沿 B 方向走得最少。此时使用的顶点是:
000 → 100 → 110 → 111对应的四个权重是:
它们都不会小于零,而且加起来恰好等于 1。下面的 Python 版本把六种顺序完整列出。代码稍长,但比过早压缩成技巧写法更容易核对:
def add_weighted(result, color, weight): for channel in range(3): result[channel] += color[channel] * weight
def tetrahedral_from_corners(corners, fr, fg, fb): if fr >= fg >= fb: names = ("000", "100", "110", "111") weights = (1 - fr, fr - fg, fg - fb, fb) elif fr >= fb >= fg: names = ("000", "100", "101", "111") weights = (1 - fr, fr - fb, fb - fg, fg) elif fb >= fr >= fg: names = ("000", "001", "101", "111") weights = (1 - fb, fb - fr, fr - fg, fg) elif fb >= fg >= fr: names = ("000", "001", "011", "111") weights = (1 - fb, fb - fg, fg - fr, fr) elif fg >= fb >= fr: names = ("000", "010", "011", "111")10 collapsed lines
weights = (1 - fg, fg - fb, fb - fr, fr) else: # fg >= fr >= fb names = ("000", "010", "110", "111") weights = (1 - fg, fg - fr, fr - fb, fb)
result = [0.0, 0.0, 0.0] for name, weight in zip(names, weights): add_weighted(result, corners[name], weight)
return tuple(result)四面体插值在每一块内部都是普通的线性组合;当输入跨过分界面时,会切换到相邻四面体。结果在边界上仍然连续,但导数不一定连续。
三线性和四面体,谁更准确
先给结论:没有脱离原始变换的“绝对准确”。 LUT 本身只是对某个连续颜色变换的离散采样。所谓误差,是插值结果与那条未知或已知的原始变换之间的差。
| 对比项 | 三线性插值 | 四面体插值 |
|---|---|---|
| 参与的单元顶点 | 8 个 | 4 个 |
| 单元内部模型 | 三线性 | 分片线性 |
| 结果连续性 | 连续 | 连续 |
| 实现特点 | 规则,容易映射到 3D 纹理 | 需要判断所在四面体 |
| 常见场景 | GPU 实时渲染 | 调色与 CPU 色彩处理 |
当 LUT 分辨率足够高、原始变换也比较平滑时,两者的结果往往非常接近。低分辨率 LUT 或变化剧烈的色彩映射会放大差异。
四面体插值经常被专业调色管线采用,但这不等于它在所有输入和所有 LUT 上都更接近原始函数。更准确的说法是:它选择了一种不同的单元内近似方式,并且在许多实际颜色变换上表现很好。
同一个输入,两种插值
切换为“差异放大”或使用低分辨率 LUT,更容易看见两种单元内模型给出的不同答案。
LUT 分辨率、位深与色带不是一回事
看到渐变出现断层时,我们很容易笼统地说“精度不够”,但这里至少有三个不同问题:
LUT 分辨率
它描述 LUT 网格有多密。17³ 到 33³ 会增加中间颜色的采样数量,让复杂变换更容易被记录。
图像或输出位深
8-bit 每个通道通常只有 256 个编码级别,10-bit 则有 1024 个。即使 LUT 插值完全准确,最终量化到较低位深仍可能出现色带。
插值方式
最近邻、三线性和四面体决定如何填补 LUT 样本之间的空白。它会产生近似误差,但不等同于输出量化。
抖动(dithering)通过加入经过设计的微小噪声,把规则的色带变成不容易察觉的颗粒。它不能恢复已经丢失的真实细节,却能改善人眼对渐变的感受。
把三种“精度不够”分开看
LUT 尺寸决定曲线采样密度,插值负责填空,输出位深则决定最后能留下多少级数值。
为了让差异在普通屏幕上可见,实验提供了夸张的 6-bit 选项。抖动不会让误差消失,而是把整齐的色带打散成更不显眼的纹理。
如果输入使用 Log 编码或覆盖很大的动态范围,常见做法是先加一张 Shaper LUT。它像一把不等距的尺子:把更多 3D LUT 网格分配给人眼或业务更关心的区域,而不是简单地把所有数值均匀塞进立方体。
.cube 文件里保存了什么
常见的 .cube 文件头可能包含:
TITLE "Example Look"DOMAIN_MIN 0.0 0.0 0.0DOMAIN_MAX 1.0 1.0 1.0LUT_3D_SIZE 33DOMAIN_MIN与DOMAIN_MAX定义输入范围;LUT_1D_SIZE或LUT_3D_SIZE定义表格尺寸;- 后续每一行通常是一个输出 RGB 三元组。
一个只处理“纯 3D LUT”的教学版简化解析器如下:
def load_cube(path): size = None domain_min = (0.0, 0.0, 0.0) domain_max = (1.0, 1.0, 1.0) values = []
with open(path, encoding="utf-8") as cube_file: for raw_line in cube_file: line = raw_line.strip()
if not line or line.startswith("#"): continue
parts = line.split() keyword = parts[0]
if keyword == "LUT_3D_SIZE": size = int(parts[1]) elif keyword == "DOMAIN_MIN": domain_min = tuple(map(float, parts[1:4]))16 collapsed lines
elif keyword == "DOMAIN_MAX": domain_max = tuple(map(float, parts[1:4])) elif keyword not in {"TITLE", "LUT_1D_SIZE"}: values.append(tuple(map(float, parts[:3])))
if size is None: raise ValueError("文件中没有 LUT_3D_SIZE") if len(values) != size ** 3: raise ValueError("3D LUT 数据量与声明的尺寸不一致")
return { "size": size, "domain_min": domain_min, "domain_max": domain_max, "values": values, }这段代码适合解释结构,不是完整的生产级解析器。实际文件可能同时包含 1D 与 3D 数据,也可能出现不同空白、注释和数值范围。更关键的是:必须确认三维数据的遍历顺序。 如果把“R 变化最快”误读成“B 变化最快”,数组长度依然正确,颜色却会完全错位。
工程里最容易踩的坑
1. 色彩空间或传递函数不匹配
LUT 预期 LogC3,输入却是 sRGB;或者 LUT 预期线性 RGB,输入仍带着 Gamma。此时插值算法没有错,查到的却是错误位置。
2. 过早裁剪
很多浮点颜色管线允许暂时出现小于 0 或大于 1 的值。过早 clamp 会直接丢掉高光或负值信息。是否裁剪,要看 LUT 定义域和后续流程。
3. 轴顺序与数组展开顺序错误
三维数组最终通常会展开成一维内存。前文的 lut_index() 假设 R 变化最快;如果文件实际采用另一种顺序,8 个数组下标仍然合法,但读出的会是另外 8 种颜色。文件规范、解析器和 GPU 纹理上传代码必须对此达成一致。
4. 把 LUT 当成完整色彩管理
LUT 很擅长保存一个固定映射,但它通常不携带完整的观察环境、显示设备、白点和场景信息。ACES、OCIO、ICC 等系统可以调用 LUT,却不等于“一张 LUT 就替代了整个系统”。
5. 只在漂亮样片上测试
至少还要检查:
- 黑、白与中性灰是否仍然合理;
- RGB 和 CMY 六个高饱和颜色;
- 接近网格边界的输入;
DOMAIN_MIN、DOMAIN_MAX与恰好等于1.0的输入;- 两种插值结果的最大误差出现在哪里。
应该怎样选择
如果目标只是亮度、Gamma 或三个通道各自的曲线,1D LUT 已经足够,而且更容易检查。
当输出红色需要同时知道输入的绿与蓝,或者要完成色相旋转、选择性色彩与复杂风格映射时,使用 3D LUT。
在实现 3D LUT 时:
- GPU 已经提供 3D 纹理采样,或者实时性能优先,可以从三线性插值开始;
- 需要匹配采用四面体插值的调色软件或处理管线,应明确实现四面体插值;
- 变换非常剧烈时,提高 LUT 分辨率或加入合适的 Shaper,往往比争论插值名称更重要;
- 需要可靠复现时,必须记录输入色彩空间、定义域、插值方法和超范围处理方式。
回到最初的问题:LUT 并不是一瓶神秘滤镜。它是一组离散答案,插值负责把答案之间的空白补起来。1D LUT 在一条线上寻找邻居;3D LUT 在 RGB 立方体里寻找邻居。三线性使用小立方体的 8 个角,四面体则先选择其中一块,再使用 4 个角。
理解了“坐标、邻居、权重”这三件事,LUT 就从魔法变成了一套可以计算、验证,也可以放心调试的工具。