4585 字
23 分钟
LUT 是怎么改变颜色的?从一条曲线走进 RGB 立方体

你把一段灰蒙蒙的 Log 视频导入剪辑软件,套上一个 LUT,画面突然有了正常的明暗、鲜明的颜色,甚至还带上一点电影质感。这个过程看起来像魔法,背后其实只有一个很朴素的动作:查表

LUT 是 Look-Up Table 的缩写,也就是“查找表”。它提前保存了一批“输入应该变成什么输出”的答案。真正有趣的问题是:表格不可能写下世界上每一种颜色,那么落在两个答案之间的颜色怎么办?

答案是插值。从一条线上的两个数字,到 RGB 立方体里的八个顶点,本文会一步步把这件事拆开。

本文中的 RGB 默认已经归一化到 [0,1][0,1]。为了看清 LUT 本身,我们暂时不讨论亮度单位、显示设备和完整的色彩管理流程,最后再回来补上这些边界。

LUT:一本提前算好的“颜色字典”#

假设有一张最简单的亮度表:

输入输出
0.000.00
0.250.18
0.500.55
0.750.86
1.001.00

输入是 0.50 时,我们直接找到第三行,输出 0.55。与每次计算一条复杂曲线相比,查数组很快,而且同一张表可以在不同软件、不同设备之间传递。

真实的色彩管线通常不只查一次表。输入可能要先映射到 LUT 能理解的范围,再经过一条负责明暗分配的曲线,最后才交给负责整体色彩风格的 3D LUT。

一颗颜色,经过四个站点

切换预设,再点每个站点。先建立整条流水线的直觉,后面我们会拆开每一步。

处理预设
输入颜色#ad7540

这是进入处理管线的原始 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 曲线实验室

减少采样点,再切换插值方式。虚线是理想曲线,实线是查表后真正得到的曲线。

输入 → 输出8 个采样点
输入输出
数组位置3.01
相邻索引3 / 4
小数 t0.01
LUT 大小
采样方法
输入0.430
输出0.626
查表方式线性

结果由第 3、4 项共同决定,右侧采样点的权重是 1%。

在组件中把 LUT 大小降到 4,再选择“最近邻”。实线会变成明显的阶梯:输入只要没有跨过某个中点,输出就一直不变。

要找到输入在数组中的位置,先把 [0,1][0,1] 映射到数组下标范围:

p=x(N1)p=x(N-1)

其中 xx 是输入,NN 是 LUT 长度。假设 LUT 有 5 项,输入是 0.6

value = 0.6
size = 5
position = value * (size - 1)
print(position) # 2.4

位置 2.4 不对应一个真实的数组下标:它落在第 2 项与第 3 项之间。这正是插值要解决的问题。

一维线性插值:离谁近,就更像谁#

如果上午 10 点是 18℃,11 点是 22℃,那么 10:30 可以先估成 20℃。这不是在声称温度一定匀速变化,而是在已知信息有限时,用一条直线给出合理估计。

线性插值通常写作:

y=(1t)y0+ty1y=(1-t)y_0+ty_1

tt 表示从左端走到右端的比例:

  • t=0t=0:完全位于左端,结果是 y0y_0
  • t=0.5t=0.5:正好位于中间,两边各占一半;
  • t=1t=1:到达右端,结果是 y1y_1

用 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,1][0,1],否则可能访问不存在的数组项;
  2. 当输入恰好是 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、对比度、通道增益等变换。但它有一个根本限制:红色输出只看红色输入,完全不知道绿色与蓝色是多少。

三条曲线,各管各的

每个输出通道只能看到自己的输入通道。试着改变蓝色曲线,观察所有含蓝色的色块一起变化。

测试色卡独立通道映射
肤色
绿
青色输入#1ab8c2
青色输出#27b3d2

想做到“只在 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 网格单元。

观察方向
当前网格单元 · 从完整 LUT 中放大R: 0 → 1G: 0 → 1B: 0 → 1c000c100c010c110c001c101c011c111(0,0,0) · (1,0,0) · (0,1,0) · 绿(0,0,1) · (1,1,1) · P (0.62, 0.38, 0.74)
  • 输入颜色 P
  • 所在网格单元
  • 完整定义域 [0,1]³
每条边的采样点
输入颜色#9e61bd
坐标计算N = 5
P = (0.62, 0.38, 0.74)p = P × (5 − 1) = (2.48, 1.52, 2.96)
R[2, 3]G[1, 2]B[2, 3]

5³ LUT 一共有 125 份样本;无论整个 LUT 多大,当前小单元仍然只有 8 个角。

17³ LUT 的意思不是“有 17 个颜色”,而是每条边放 17 个采样点,一共保存:

173=491317^3=4913

同理,常见的几个尺寸是:

尺寸颜色样本数
17317^34,913
33333^335,937
65365^3274,625

即使有几十万个样本,输入颜色通常还是不会恰好落在网格点上。它会落进某个小立方体,而这个单元周围有 8 个顶点。接下来要做的,仍然是根据邻居估计当前位置的输出。

周围 8 个顶点究竟怎样取#

先只看 R 轴。假设输入颜色的红色分量是 rr,LUT 每条边有 NN 个采样点,那么它在 R 轴上的数组位置是:

pr=r(N1)p_r=r(N-1)

对这个位置向下取整,得到左边的网格索引 r0r_0;右边的索引就是 r1=r0+1r_1=r_0+1。G、B 两个方向也做同样的计算,于是得到三组索引:

R 方向:r0 或 r1
G 方向:g0 或 g1
B 方向:b0 或 b1

每个方向都有“低侧”和“高侧”两种选择,组合起来正好有 23=82^3=8 个顶点:

名称R 索引G 索引B 索引
c000r0g0b0
c100r1g0b0
c010r0g1b0
c110r1g1b0
c001r0g0b1
c101r1g0b1
c011r0g1b1
c111r1g1b1

这里的 c 表示从 LUT 中取出的输出颜色;后面的三位依次对应 R、G、B。数字 0 表示使用这个方向的低侧索引,1 表示使用高侧索引。

所以,c100 不是 RGB 颜色 (1, 0, 0)。它的意思是:在当前小立方体里,R 选择高侧 r1,G 和 B 选择低侧 g0b0,然后从 LUT 中读出那个位置保存的颜色。

亲手找到 c000 到 c111

让同一个输入点依次经过“投影 → 网格定位 → 放大单元 → 组合八角 → 读取数组”,把空间位置和代码里的 c000 连起来。

LUT 尺寸
计算步骤
当前网格单元 · 从完整 LUT 中放大R: 0 → 1G: 0 → 1B: 0 → 1(0,0,0) · (1,0,0) · (0,1,0) · 绿(0,0,1) · (1,1,1) · P (0.62, 0.38, 0.74)
① 输入是立方体里的一个点[0,1]³

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.92
print(g0, g1, fg) # 6 7 0.08
print(b0, b1, fb) # 11 12 0.84

因此 c000 来自 LUT 坐标 (9, 6, 11)c100 来自 (10, 6, 11),而对角的 c111 来自 (10, 7, 12)。另外五个顶点就是在这三组高低索引中依次排列组合。

三线性插值:沿三个方向做三次 lerp#

“三线性”听起来像一种全新的算法,其实它只是把刚才的一维线性插值重复三轮。

设输入点在当前网格单元内的局部位置为 (fr,fg,fb)(f_r,f_g,f_b),每个分量都位于 [0,1][0,1]

  1. 沿 R 方向对 8 个角点两两插值,得到 4 个颜色;
  2. 沿 G 方向把 4 个颜色变成 2 个;
  3. 沿 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 个顶点。

怎样判断点落在哪一块?比较 frf_rfgf_gfbf_b 的大小顺序即可。比如:

frfgfbf_r\ge f_g\ge f_b

意味着输入沿 R 方向走得最远,沿 B 方向走得最少。此时使用的顶点是:

000 → 100 → 110 → 111

对应的四个权重是:

1fr,frfg,fgfb,fb1-f_r,\quad f_r-f_g,\quad f_g-f_b,\quad f_b

它们都不会小于零,而且加起来恰好等于 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,更容易看见两种单元内模型给出的不同答案。

三线性 · 8 个顶点#96938f
0.54RGB 距离 × 255
四面体 · 4 个顶点#96948e
四面体权重R ≥ G ≥ B
00016.0%
10032.0%
11028.0%
11124.0%
测试 LUT
LUT 尺寸
局部 R0.84
局部 G0.52
局部 B0.24

LUT 分辨率、位深与色带不是一回事#

看到渐变出现断层时,我们很容易笼统地说“精度不够”,但这里至少有三个不同问题:

LUT 分辨率#

它描述 LUT 网格有多密。17³33³ 会增加中间颜色的采样数量,让复杂变换更容易被记录。

图像或输出位深#

8-bit 每个通道通常只有 256 个编码级别,10-bit 则有 1024 个。即使 LUT 插值完全准确,最终量化到较低位深仍可能出现色带。

插值方式#

最近邻、三线性和四面体决定如何填补 LUT 样本之间的空白。它会产生近似误差,但不等同于输出量化。

抖动(dithering)通过加入经过设计的微小噪声,把规则的色带变成不容易察觉的颗粒。它不能恢复已经丢失的真实细节,却能改善人眼对渐变的感受。

把三种“精度不够”分开看

LUT 尺寸决定曲线采样密度,插值负责填空,输出位深则决定最后能留下多少级数值。

理想连续变换
LUT 采样并量化后的输出
LUT 样本数9
平均 RGB 误差2.06
最大 RGB 误差12.71
1D LUT 尺寸
插值
输出位深
有序抖动

为了让差异在普通屏幕上可见,实验提供了夸张的 6-bit 选项。抖动不会让误差消失,而是把整齐的色带打散成更不显眼的纹理。

如果输入使用 Log 编码或覆盖很大的动态范围,常见做法是先加一张 Shaper LUT。它像一把不等距的尺子:把更多 3D LUT 网格分配给人眼或业务更关心的区域,而不是简单地把所有数值均匀塞进立方体。

.cube 文件里保存了什么#

常见的 .cube 文件头可能包含:

TITLE "Example Look"
DOMAIN_MIN 0.0 0.0 0.0
DOMAIN_MAX 1.0 1.0 1.0
LUT_3D_SIZE 33
  • DOMAIN_MINDOMAIN_MAX 定义输入范围;
  • LUT_1D_SIZELUT_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_MINDOMAIN_MAX 与恰好等于 1.0 的输入;
  • 两种插值结果的最大误差出现在哪里。

应该怎样选择#

如果目标只是亮度、Gamma 或三个通道各自的曲线,1D LUT 已经足够,而且更容易检查。

当输出红色需要同时知道输入的绿与蓝,或者要完成色相旋转、选择性色彩与复杂风格映射时,使用 3D LUT。

在实现 3D LUT 时:

  • GPU 已经提供 3D 纹理采样,或者实时性能优先,可以从三线性插值开始;
  • 需要匹配采用四面体插值的调色软件或处理管线,应明确实现四面体插值;
  • 变换非常剧烈时,提高 LUT 分辨率或加入合适的 Shaper,往往比争论插值名称更重要;
  • 需要可靠复现时,必须记录输入色彩空间、定义域、插值方法和超范围处理方式。

回到最初的问题:LUT 并不是一瓶神秘滤镜。它是一组离散答案,插值负责把答案之间的空白补起来。1D LUT 在一条线上寻找邻居;3D LUT 在 RGB 立方体里寻找邻居。三线性使用小立方体的 8 个角,四面体则先选择其中一块,再使用 4 个角。

理解了“坐标、邻居、权重”这三件事,LUT 就从魔法变成了一套可以计算、验证,也可以放心调试的工具。

LUT 是怎么改变颜色的?从一条曲线走进 RGB 立方体
https://cloudsir.top/posts/how-lut-changes-color/
作者
CloudSir
发布于
2026-08-24
许可协议
CC BY-NC-SA 4.0
Gamma 矫正到底矫正了什么?从线性光走到屏幕亮度
高光是怎么装进屏幕的?从曝光压缩到 Filmic Tone Mapping