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

剪辑软件给 Log 视频套用 LUT 后,画面的明暗和颜色随之改变。LUT 执行的核心操作是查表

LUT 是 Look-Up Table,也就是查找表。它保存一组输入与输出的对应关系。输入落在两个样本之间时,程序用插值估算输出。

1D LUT 在一条线上选择两个邻居,3D LUT 在 RGB 立方体中选择网格顶点。两者都需要坐标、邻居和权重。

文中的 RGB 均归一化到 [0,1][0,1]。亮度单位、显示设备和完整色彩管理不在讨论范围内。

LUT 保存输入与输出#

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

输入输出
0.000.00
0.250.18
0.500.55
0.750.86
1.001.00

输入为 0.50 时,程序读取第三行并输出 0.55。数组查询开销低,同一张表也便于软件和设备交换。

一条色彩管线会先把输入映射到 LUT 定义域,再用曲线分配明暗精度,随后交给 3D LUT 处理通道间的颜色关系。

查看颜色管线的四个阶段

切换预设并点击阶段,查看同一组 RGB 数值经过定义域、Shaper 和 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 的采样结果

调节采样点数量和插值方式。虚线表示目标曲线,实线表示 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 时,右侧索引停在最后一项。

需要保留超范围高光时,请按 LUT 格式和色彩管线约定选择裁剪、外推或预映射,不要直接使用示例中的 clamp

三条 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、对比度和通道增益。每个输出通道只读取对应的输入通道,无法判断三个通道组成了什么颜色。

三条 1D 曲线独立处理通道

调节 B 曲线,观察肤色、青色和蓝色等含蓝色分量的色块同时变化。

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

选择“R 低、G 和 B 高”的颜色需要同时读取三个通道。3D LUT 用三个输入通道表达这种条件。

假设调色规则只针对“绿色和蓝色高、红色低”的青色区域。蓝色曲线读不到绿色输入;抬高蓝色曲线会同时影响天空、紫色和肤色阴影等含蓝色区域。

3D LUT 同时读取 R、G、B,因而能针对特定通道组合修改颜色。

3D LUT 把 RGB 作为三维坐标#

把 R、G、B 看成三条互相垂直的轴:

  • 沿 R 轴走,红色增加;
  • 沿 G 轴走,绿色增加;
  • 沿 B 轴走,蓝色增加。

黑色 (0, 0, 0) 和白色 (1, 1, 1) 位于立方体的一组对角。红、绿、蓝、青、品红、黄位于其余角点。每组 RGB 数值对应立方体中的一个坐标。

在 RGB 立方体中定位输入

彩色圆点表示输入坐标,蓝色线框表示它所在的 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 个样本。当前网格单元由 8 个顶点围成。

17³ LUT 每条边放置 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 表示当前单元中 R 选择高侧 r1,G 和 B 选择低侧 g0b0。程序用这三个索引从 LUT 读取一个输出颜色。

从输入坐标定位 c000 到 c111

按投影、网格定位、顶点组合和数组读取的顺序,查看空间坐标如何转换为 LUT 索引。

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#

三线性插值沿 R、G、B 三个方向依次执行一维线性插值。

设输入点在当前网格单元内的局部位置为 (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 分辨率#

LUT 分辨率描述网格密度。33³17³ 保存更多中间颜色样本,因而能记录曲率更高的变换。

图像或输出位深#

8-bit 每通道提供 256 个编码级别,10-bit 提供 1024 个。LUT 插值无法消除低位深量化产生的色带。

插值方式#

最近邻、三线性和四面体决定如何估算 LUT 样本之间的输出。近似误差与输出量化是两个误差来源。

抖动(dithering)加入受控噪声,把规则色带打散成颗粒。它不会恢复量化丢失的细节,但能减弱色带的视觉边界。

分开观察三种精度限制

LUT 尺寸控制采样密度,插值估算样本间的输出,位深控制量化级数。

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

6-bit 选项用于放大量化色带。抖动把规则色带打散成颗粒,误差仍然存在。

Log 编码和高动态范围输入常在 3D LUT 前使用 Shaper LUT。Shaper 重新分配数值区间,让 3D 网格在重点明暗区域获得更多采样点。

.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. 色彩空间或传递函数不匹配#

把 sRGB 输入送入为 LogC3 设计的 LUT,或把带 Gamma 的输入送入线性 RGB LUT,都会查询错误位置。插值算法无法修正输入编码错误。

2. 过早裁剪#

浮点颜色管线可以保留小于 0 或大于 1 的中间值。过早 clamp 会丢失高光或负值信息。裁剪位置由 LUT 定义域和后续流程决定。

3. 轴顺序与数组展开顺序错误#

程序会把三维数组展开为一维内存。前文的 lut_index() 假设 R 变化最快;文件若采用另一种顺序,8 个下标仍合法,但会指向另一组颜色。文件规范、解析器和 GPU 纹理上传代码必须使用同一顺序。

4. 把 LUT 当成完整色彩管理#

LUT 保存固定映射,不携带完整的观察环境、显示设备、白点和场景信息。ACES、OCIO、ICC 等色彩管理系统可以调用 LUT,也会处理 LUT 之外的上下文。

5. 只在漂亮样片上测试#

测试样片应包含:

  • 黑白点是否发生裁剪,中性灰是否偏色;
  • RGB 和 CMY 六个高饱和颜色;
  • 接近网格边界的输入;
  • DOMAIN_MINDOMAIN_MAX 与恰好等于 1.0 的输入;
  • 两种插值结果的最大误差出现在哪里。

选择 LUT 与插值方法#

亮度、Gamma 和独立通道曲线可以使用 1D LUT。

色相旋转、选择性色彩和通道耦合变换需要 3D LUT。

在实现 3D LUT 时:

  • GPU 提供 3D 纹理采样或项目要求实时处理时,可以先用三线性插值;
  • 需要匹配采用四面体插值的调色软件或处理管线,应明确实现四面体插值;
  • 变换曲率高时,提高 LUT 分辨率或加入 Shaper;
  • 需要可靠复现时,必须记录输入色彩空间、定义域、插值方法和超范围处理方式。

LUT 保存离散的输入输出样本,插值估算样本之间的结果。1D LUT 在一条线上读取两个邻居;3D LUT 在 RGB 立方体中定位一个网格单元。三线性使用单元的 8 个顶点,四面体插值使用输入所在四面体的 4 个顶点。

实现和调试 LUT 时,依次核对输入坐标、相邻样本和插值权重。

LUT 是怎么改变颜色的?从一条曲线走进 RGB 立方体
https://blog.cloudsir.top/posts/how-lut-changes-color/
作者
CloudSir
发布于
2026-08-24
许可协议
CC BY-NC-SA 4.0
摄影集 · 蓝
使用Gitee + Webhook自动化部署网站

下拉加载评论