4322 字
22 分钟
LUT 是怎么改变颜色的:从一条曲线到 RGB 立方体

一颗像素经过白平衡和 CCM 后,得到一组 RGB 数值:

(0.62, 0.38, 0.74)

ISP 还想给它调整对比度,并把这团偏紫的颜色推向另一种色调。工程师可以为每颗像素重复计算一套复杂公式,也可以提前算好许多输入对应的输出,再让 ISP 按输入数字寻找结果。

这张提前写好的对应表叫 LUT,全称是 Look-Up Table,也就是查找表。

LUT 不认识“紫色”这个名字。ISP 先找到输入坐标,再读取附近的样本,根据距离估算输出。我们就跟着这颗像素走一遍。

文中的 RGB 都归一化到 [0,1][0,1]。实际 ISP 可能使用整数、定点数或更宽的浮点范围,坐标换算仍遵循相同思路。

ISP 为什么使用 LUT#

先看一张只有五项的表:

输入输出
0.000.00
0.250.18
0.500.55
0.750.86
1.001.00

输入正好等于 0.50 时,ISP 读取第三项,得到 0.55。每个输入位置都对应一个提前算好的输出。

数组查询的计算量固定,也方便工程师把同一组结果放进软件、硬件寄存器或文件。Gamma 曲线、对比度曲线、镜头风格和显示变换都可能使用 LUT。

一条颜色管线可能这样连接:

白平衡 → CCM → Tone Mapping → LUT → Gamut Mapping → Gamma → 输出

不同 ISP 会调整这条顺序。LUT 的位置取决于工程师制作它时使用的输入色彩空间和数值范围。有的 LUT 接收线性 RGB,有的接收 Log 或 Gamma 编码后的 RGB。接错位置后,表里的数字仍能读取,颜色却会偏离预期。

1D LUT:沿一条曲线查找#

上面五个输出可以直接存进一个数组:

lut = [0.00, 0.18, 0.55, 0.86, 1.00]

数组中的五个数字是五个采样点。把它们依次连起来,就得到一条从输入到输出的曲线。

现在取出那颗像素的红色分量:

R = 0.62

LUT 一共有 N=5N=5 项。输入范围是 [0,1][0,1],数组位置从 0 到 4。工程师用下面的换算把输入放到数组上:

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

代入 x=0.62x=0.62:

p=0.62×(5−1)=2.48p=0.62\times(5-1)=2.48

位置 2.48 落在第 2 项和第 3 项之间。数组下标从 0 开始,两侧样本分别是:

下标 2:0.55
下标 3:0.86

LUT 只保存了这两个位置的结果。2.48 对应的输出需要从两边估算。

线性插值:看离哪边更近#

2.48 从左侧样本出发走了 0.48。工程师把这段小数记作 tt:

t=p−⌊p⌋t=p-\lfloor p\rfloor

线性插值按照距离分配两侧样本的权重:

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

把红色分量的数字放进去:

y=0.52×0.55+0.48×0.86=0.6988y=0.52\times0.55+0.48\times0.86=0.6988

输入 0.62 经过这条 1D LUT 后得到 0.6988。位置越靠近右侧,右侧样本占的比例越大。

可以试一下:拖动输入,看看数组位置跨过整数时,相邻索引和两侧权重怎样变化。

1D LUT 的查表与插值实验

拖动输入,观察数组位置、相邻样本和插值权重怎样变化。

输入 → 输出5 个采样点
输入输出
数组位置 p2.48
相邻索引2 / 3
输出 y0.6988
下标 252%
下标 348%

下标 2 保存 0.55,下标 3 保存 0.86。两个权重相加等于 100%。

输入在两个采样点之间移动时,左右权重连续变化。跨过一个采样点后,ISP 换用下一对邻居,输出曲线仍然连在一起。

Python 可以照着刚才的步骤写:

import math
def lerp(left, right, fraction):
return left * (1.0 - fraction) + right * fraction
def sample_lut_1d(lut, value):
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,
)
4 collapsed lines
curve = [0.00, 0.18, 0.55, 0.86, 1.00]
print(sample_lut_1d(curve, 0.62)) # 0.6988

函数先把输入限制在 [0,1][0,1],防止数组越界。输入等于 1.0 时,左右索引都停在最后一项。

这个函数把定义域写死为 [0,1][0,1]。真实 ISP 可能保留负值和大于 1.0 的高光。工程师需要根据 LUT 的定义域决定裁剪、外推或预先压缩,不能把示例中的 clamp 直接搬进所有管线。

三条 1D LUT 只能各看一个通道#

RGB 可以分别使用三条曲线:

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),
)

红色曲线只读取 R=0.62,看不到 G=0.38 和 B=0.74。蓝色曲线也不知道当前红色和绿色是多少。

这样的曲线适合调整明暗、Gamma 和每个通道的响应。它无法表达“红色较低,同时绿色和蓝色较高时,把青色推向蓝色”这类规则。工程师只抬高蓝色曲线,所有带蓝色分量的颜色都会一起变化。

ISP 想根据三个通道的组合改变颜色,就需要同时读取 R、G、B。3D LUT 为三者各留出一条轴。

3D LUT:把 RGB 放进立方体#

把 R、G、B 看成三条互相垂直的坐标轴。每个 RGB 数值都会落在立方体中的一个位置。

八个角点分别是:

RGB颜色
(0, 0, 0)黑
(1, 0, 0)红
(0, 1, 0)绿
(0, 0, 1)蓝
(0, 1, 1)青
(1, 0, 1)品红
(1, 1, 0)黄
(1, 1, 1)白

3D LUT 在三条轴上铺设网格。17³ LUT 每条边有 17 个采样点,一共保存:

173=491317^3=4913

每个网格点存放一组输出 RGB。输入颜色落到某个网格点时,ISP 可以直接读取那里保存的颜色。

我们的输入 (0.62, 0.38, 0.74) 没有落在网格点上。它位于一个小立方体内部,周围有 8 个相邻网格点。

先找到输入所在的小格子#

三条轴分别使用一维 LUT 的位置换算:

pr=r(N−1),pg=g(N−1),pb=b(N−1)p_r=r(N-1),\qquad p_g=g(N-1),\qquad p_b=b(N-1)

把输入和 N=17N=17 代入:

R:0.62 × 16 = 9.92
G:0.38 × 16 = 6.08
B:0.74 × 16 = 11.84

沿 R 轴看,输入位于下标 9 和 10 之间,并且从 9 向 10 走了 0.92。另外两条轴也能得到各自的低侧索引、高侧索引和局部位置:

r0=⌊pr⌋,fr=pr−r0r_0=\lfloor p_r\rfloor,\qquad f_r=p_r-r_0

fr 就是网格位置 pr 的小数部分。它不等于原始的 R。当前例子中,pr=9.92、r0=9,所以 fr=9.92-9=0.92。fg 和 fb 使用同样的算法。

这里的 9 来自向下取整。9.92 位于数组下标 9 和 10 之间,ISP 先把左边的下标记作 r0,再得到右边的 r1:

pr = 9.92
r0 = floor(9.92) = 9
r1 = r0 + 1 = 10
fr = pr - r0 = 9.92 - 9 = 0.92

fr=0.92 表示输入已经从下标 9 向下标 10 走了 92%。它离右侧样本更近,所以沿 R 插值时,c100 一侧会取得更大权重。

R:r0 = 9,r1 = 10,fr = 0.92
G:g0 = 6,g1 = 7,fg = 0.08
B:b0 = 11,b1 = 12,fb = 0.84

fr、fg、fb 描述输入在当前小格子里走到了哪里。三者都位于 [0,1][0,1]。

可以按顺序走一遍:先看输入投影到三条轴,再找到小格子,最后点击八个顶点读取数组位置。

RGB 立方体的坐标定位实验

沿着同一颗像素完成投影、网格定位、顶点组合和数组读取。

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 轴上的位置。

切换 LUT 尺寸后,同一颗像素会落到不同的数组下标。输入 RGB 没有变化,网格密度改变了坐标换算中的 N−1N-1。

每条轴可以选择低侧或高侧。三个方向组合出 23=82^3=8 个角点:

名称LUT 网格坐标
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)

c100 中的三位数字依次对应 R、G、B。1 选择高侧,0 选择低侧。这次查询的 c100 位于 (10, 6, 11)。

下面的函数完成相同的坐标换算:

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
red = grid_coordinate(0.62, 17)
green = grid_coordinate(0.38, 17)
blue = grid_coordinate(0.74, 17)
print(red) # (9, 10, 0.92)
print(green) # (6, 7, 0.08)
print(blue) # (11, 12, 0.84)

坐标换算只告诉 ISP 去哪里读。真正的颜色来自 LUT 表项:3D LUT 的每个网格点都提前存着一组输出 RGB。ISP 找到当前小格子的 8 个角点后,先从 LUT 读出这 8 组颜色,再用 fr、fg、fb 做插值。

下面两个实验共用同一张预先生成的 17³ 演示 LUT。它一共有 173=491317^3=4913 个表项。输入变化时,LUT 本身不变;变化的是 (r0/r1, g0/g1, b0/b1),所以 ISP 会从同一张表里读到另一组 8 个表项。

点云把整张表贴在这里。每个点的位置表示输入网格坐标,点的颜色表示该表项保存的输出 RGB。白色小线框圈出当前例子 (0.62, 0.38, 0.74) 命中的 8 个表项。

这就是后面两种插值共用的 17³ LUT

4913 个点对应 4913 个表项。点的位置是输入网格坐标,点的颜色是该表项保存的输出 RGB。

全表:17³ = 4913 个预存表项白色线框:输入 (0.62, 0.38, 0.74) 命中的 8 个表项

页面加载组件前,程序先一次性生成整张演示表;滑杆变化时只查数组,不重新生成颜色:

demo_lut = []
for b in range(17):
for g in range(17):
for r in range(17):
rgb_in = (r / 16, g / 16, b / 16)
demo_lut.append(style_transform(rgb_in))
# 交互时只用整数网格坐标读取表项
corner = demo_lut[(b_index * 17 + g_index) * 17 + r_index]

真实 ISP 通常从标定文件或调色工具生成的 .cube 数据装载 LUT;这里用确定的 style_transform 生成演示表,是为了让整篇文章能够复算。

三线性插值:沿三个方向走完#

ISP 用局部位置把这 8 个角点合成一个输出颜色。

三线性插值重复三轮刚才的一维插值:

  1. 沿 R 方向,把 8 个角点合成 4 个颜色。
  2. 沿 G 方向,把 4 个颜色合成 2 个颜色。
  3. 沿 B 方向,把最后 2 个颜色合成 1 个颜色。

可以试一下:在完整的 0~1 范围拖动输入 R、G、B。实验先定位当前小格子,读出 8 个 LUT 表项,再从上到下完成 8 → 4 → 2 → 1。

三线性插值的三轮合并实验

输入范围是完整的 0~1。组件会先定位当前网格单元,再沿 R、G、B 完成三轮合并。

Rpr = 0.620 × 16 = 9.92r0 = floor(9.92) = 9r1 = r0 + 1 = 10fr = pr − r0 = 0.92
Gpg = 0.380 × 16 = 6.08g0 = floor(6.08) = 6g1 = g0 + 1 = 7fg = pg − g0 = 0.08
Bpb = 0.740 × 16 = 11.84b0 = floor(11.84) = 11b1 = b0 + 1 = 12fb = pb − b0 = 0.84
当前输入从 17³ LUT 读出的 8 个表项输入只决定查表坐标;表项中的输出 RGB 已经预先存好。
c000 · LUT[9, 6, 11](0.610, 0.371, 0.678)
c100 · LUT[10, 6, 11](0.684, 0.374, 0.675)
c010 · LUT[9, 7, 11](0.614, 0.442, 0.683)
c110 · LUT[10, 7, 11](0.688, 0.445, 0.679)
c001 · LUT[9, 6, 12](0.612, 0.373, 0.743)
c101 · LUT[10, 6, 12](0.686, 0.376, 0.741)
c011 · LUT[9, 7, 12](0.617, 0.444, 0.748)
c111 · LUT[10, 7, 12](0.691, 0.447, 0.745)
RGBc000c100c010c110c001c101c011c111c00c10c01c11

沿 R 方向走 fr=0.92,四条边各得到一个点。

滑杆是全局 RGB 输入;fr、fg、fb 是输入落入某个小格子后得到的局部坐标。17³ 演示 LUT 保持不变,输入跨单元后只会改读另一组八个表项。三轮计算使用同一颗输入的 fr、fg、fb,中间结果依次从 8 个缩减到 4、2、1 个。

每一轮都沿一条轴做线性插值。改变 fr 只会直接影响第一轮,新的四个结果再进入 G 和 B 两轮计算。

颜色插值需要同时计算三个通道:

def lerp_color(left, right, fraction):
return tuple(
lerp(a, b, fraction)
for a, b in zip(left, right)
)

然后按照 R、G、B 的顺序完成三轮计算:

def trilinear_from_corners(corners, fr, fg, fb):
# 第一轮:沿 R 方向,8 个角点变成 4 个颜色
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)
# 第二轮:沿 G 方向,4 个颜色变成 2 个颜色
c0 = lerp_color(c00, c10, fg)
c1 = lerp_color(c01, c11, fg)
# 第三轮:沿 B 方向,得到输出颜色
return lerp_color(c0, c1, fb)

计算顺序可以交换,三线性结果保持一致。实现中的角点命名与局部坐标必须使用同一套轴顺序,否则程序会读取合法下标,却算出错误颜色。

GPU 的 3D 纹理采样器可以完成三线性插值。使用硬件采样前,工程师仍要核对纹理坐标、通道排列和边界规则。

四面体插值:先判断输入落在哪一块#

工程师还可以沿一条对角线,把小立方体切成 6 个四面体。输入只会落在其中一块,每块使用 4 个顶点。

程序比较 fr、fg、fb 的大小,判断输入属于哪一个四面体。我们的局部坐标是:

fr = 0.92,fb = 0.84,fg = 0.08

它们满足:

fr≥fb≥fgf_r\ge f_b\ge f_g

当前输入使用下面四个顶点:

000 → 100 → 101 → 111

对应权重是:

1−fr,fr−fb,fb−fg,fg1-f_r,\qquad f_r-f_b,\qquad f_b-f_g,\qquad f_g

代入当前数字:

c000:0.08
c100:0.08
c101:0.76
c111:0.08

四个权重都不小于 0,加起来等于 1。输出就是四个顶点颜色的加权和。

可以试一下:在完整的 0~1 范围拖动输入 R、G、B,让局部坐标跨过四面体的分界面。半透明体积会切换到另一块,顶点和权重也会一起变化。

四面体插值的选区与权重实验

输入范围是完整的 0~1。组件会动态定位网格单元,再用局部位置的大小顺序选择四面体。

Rpr = 0.620 × 16 = 9.92r0 = floor(9.92) = 9r1 = r0 + 1 = 10fr = pr − r0 = 0.92
Gpg = 0.380 × 16 = 6.08g0 = floor(6.08) = 6g1 = g0 + 1 = 7fg = pg − g0 = 0.08
Bpb = 0.740 × 16 = 11.84b0 = floor(11.84) = 11b1 = b0 + 1 = 12fb = pb − b0 = 0.84
当前输入从 17³ LUT 读出的 8 个表项输入只决定查表坐标;表项中的输出 RGB 已经预先存好。
c000 · LUT[9, 6, 11](0.610, 0.371, 0.678)
c100 · LUT[10, 6, 11](0.684, 0.374, 0.675)
c010 · LUT[9, 7, 11](0.614, 0.442, 0.683)
c110 · LUT[10, 7, 11](0.688, 0.445, 0.679)
c001 · LUT[9, 6, 12](0.612, 0.373, 0.743)
c101 · LUT[10, 6, 12](0.686, 0.376, 0.741)
c011 · LUT[9, 7, 12](0.617, 0.444, 0.748)
c111 · LUT[10, 7, 12](0.691, 0.447, 0.745)
四面体空间视图第 1 步

先比较局部坐标。此时保留八个角点,确认输入位于立方体里的哪里。

RGBc000c100c010c110c001c101c011c111f
选择四面体插值阶段
fr ≥ fb ≥ fgfr = 0.92,fg = 0.08,fb = 0.84六种大小顺序分别对应沿主对角线切出的六块四面体。

滑杆是全局 RGB 输入;fr、fg、fb 是当前小格子内的局部坐标。17³ 演示 LUT 保持不变,输入跨单元后只会改读另一组八个表项。四个权重保持非负,相加等于 100%。

六个四面体共享 000 → 111 这条主对角线。输入跨过分界面后,ISP 换用相邻的四个顶点;边界两侧的输出仍然接在一起。

完整参考实现需要覆盖六种大小顺序:

def weighted_color(corners, names, weights):
output = [0.0, 0.0, 0.0]
for name, weight in zip(names, weights):
for channel in range(3):
output[channel] += corners[name][channel] * weight
return tuple(output)
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")
9 collapsed lines
weights = (1 - fb, fb - fg, fg - fr, fr)
elif fg >= fb >= fr:
names = ("000", "010", "011", "111")
weights = (1 - fg, fg - fb, fb - fr, fr)
else:
names = ("000", "010", "110", "111")
weights = (1 - fg, fg - fr, fr - fb, fb)
return weighted_color(corners, names, weights)

三线性插值读取当前单元的 8 个顶点,四面体插值读取所在四面体的 4 个顶点。前者符合许多 GPU 纹理单元的工作方式,后者常见于调色和软件色彩处理。

两种方法都在离散样本之间估算结果。工程师需要拿它们和制作 LUT 时的连续变换比较,才能判断误差。只看两种插值输出,无法知道哪一个更接近原始颜色变换。

LUT 分辨率决定网格有多密#

常见 3D LUT 尺寸包括:

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

更密的网格可以记录更弯曲的颜色变换,也会占用更多存储空间和带宽。尺寸翻倍后,样本数会增长到原来的约八倍。

网格太稀时,LUT 会漏掉采样点之间的细节。提高图像位深不能补回这些细节,因为位深回答的是“一个通道能写下多少个码值”,LUT 分辨率回答的是“原始变换记录了多少个位置”。

插值方法回答第三个问题:样本之间怎样连接。网格密度、输出位深和插值方法需要分别检查。

高动态范围输入还会遇到采样分配问题。工程师可以在 3D LUT 前加入 Shaper LUT,先把暗部、中间调或高光展开,让 3D 网格把更多采样点留给需要精细处理的范围。

.cube 文件保存了什么#

许多调色和图像软件使用 .cube 文件交换 LUT。文件头会说明输入范围和网格大小:

TITLE "Example Look"
DOMAIN_MIN 0.0 0.0 0.0
DOMAIN_MAX 1.0 1.0 1.0
LUT_3D_SIZE 17

后面的每一行保存一个网格点的输出 RGB:

0.000000 0.000000 0.000000
0.062500 0.000000 0.000000
0.125000 0.000000 0.000000
...

DOMAIN_MIN 和 DOMAIN_MAX 决定文件期待的输入范围。LUT_3D_SIZE 17 表示三条轴各有 17 个采样点。

读取文件时,工程师还要确认数据行的遍历顺序。下面这个下标公式假设 R 变化最快:

def lut_index(red_index, green_index, blue_index, size):
return (blue_index * size + green_index) * size + red_index

这种排列下,数组会先走完一行 R,再增加 G,最后增加 B。文件、解析器、硬件表格和测试代码必须采用同一顺序。

解析完成后,至少检查两件事:

expected_count = size ** 3
if len(values) != expected_count:
raise ValueError("3D LUT 数据量与声明尺寸不一致")
if domain_min == domain_max:
raise ValueError("LUT 定义域没有有效范围")

数据量正确只能证明文件读全了。轴顺序写反后,数组长度仍然正确,纯红、纯绿和纯蓝却会指向错误位置。

把 LUT 接进 ISP 前检查五件事#

输入颜色空间#

同一个 0.5 在线性 RGB、sRGB 和 Log 编码中代表不同的亮度。ISP 必须把输入转换到 LUT 制作时使用的空间,再开始查表。

定义域#

输入可能落在 DOMAIN_MIN 与 DOMAIN_MAX 之外。工程师需要确定硬件会裁剪、外推还是先用 Shaper 映射。过早裁剪会丢掉负值和超范围高光。

轴与内存顺序#

用纯红、纯绿、纯蓝和几级灰阶测试 LUT。它们比复杂照片更容易暴露 RGB/BGR 交换和数组展开顺序错误。

插值方法#

软件、GPU 和硬件 ISP 需要采用约定好的插值方法。目标是复现某款调色软件时,还要确认对方使用三线性还是四面体插值。

输出范围#

LUT 可能产生小于 0 或大于 1 的结果。后续 Gamut Mapping、Tone Mapping 或输出编码可以继续处理这些数值。查表后立刻逐通道裁剪,可能改变色相并压平渐变。

沿着一个像素排查结果#

那颗输入像素仍然是:

(0.62, 0.38, 0.74)

颜色不符合预期时,按计算顺序记录中间结果:

  1. 写下 LUT 收到的 RGB,确认输入色彩空间和编码。
  2. 把输入映射到定义域,检查有没有提前裁剪。
  3. 计算三个轴的位置,核对低侧索引、高侧索引和局部小数。
  4. 读取相邻顶点,用纯色测试确认轴顺序。
  5. 打印插值权重,确认权重非负且总和为 1。
  6. 保留浮点输出,再交给后续色域映射和编码步骤。

对 (0.62, 0.38, 0.74) 而言,17³ LUT 应得到:

R:9 → 10,fr = 0.92
G:6 → 7,fg = 0.08
B:11 → 12,fb = 0.84

这三组坐标确定了 ISP 应该读取哪一小块数据。三线性插值沿三个方向逐步合成颜色,四面体插值先选择所在区域,再对四个顶点加权。

LUT 改变颜色的过程到这里已经完整:工程师预先保存一些输入位置的输出颜色,ISP 找到当前像素附近的样本,再按距离算出中间结果。

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

下拉加载评论