一颗像素经过白平衡和 CCM 后,得到一组 RGB 数值:
(0.62, 0.38, 0.74)ISP 还想给它调整对比度,并把这团偏紫的颜色推向另一种色调。工程师可以为每颗像素重复计算一套复杂公式,也可以提前算好许多输入对应的输出,再让 ISP 按输入数字寻找结果。
这张提前写好的对应表叫 LUT,全称是 Look-Up Table,也就是查找表。
LUT 不认识“紫色”这个名字。ISP 先找到输入坐标,再读取附近的样本,根据距离估算输出。我们就跟着这颗像素走一遍。
文中的 RGB 都归一化到 。实际 ISP 可能使用整数、定点数或更宽的浮点范围,坐标换算仍遵循相同思路。
ISP 为什么使用 LUT
先看一张只有五项的表:
| 输入 | 输出 |
|---|---|
| 0.00 | 0.00 |
| 0.25 | 0.18 |
| 0.50 | 0.55 |
| 0.75 | 0.86 |
| 1.00 | 1.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.62LUT 一共有 项。输入范围是 ,数组位置从 0 到 4。工程师用下面的换算把输入放到数组上:
代入 :
位置 2.48 落在第 2 项和第 3 项之间。数组下标从 0 开始,两侧样本分别是:
下标 2:0.55下标 3:0.86LUT 只保存了这两个位置的结果。2.48 对应的输出需要从两边估算。
线性插值:看离哪边更近
2.48 从左侧样本出发走了 0.48。工程师把这段小数记作 :
线性插值按照距离分配两侧样本的权重:
把红色分量的数字放进去:
输入 0.62 经过这条 1D LUT 后得到 0.6988。位置越靠近右侧,右侧样本占的比例越大。
可以试一下:拖动输入,看看数组位置跨过整数时,相邻索引和两侧权重怎样变化。
1D LUT 的查表与插值实验
拖动输入,观察数组位置、相邻样本和插值权重怎样变化。
下标 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函数先把输入限制在 ,防止数组越界。输入等于 1.0 时,左右索引都停在最后一项。
这个函数把定义域写死为 。真实 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 个采样点,一共保存:
每个网格点存放一组输出 RGB。输入颜色落到某个网格点时,ISP 可以直接读取那里保存的颜色。
我们的输入 (0.62, 0.38, 0.74) 没有落在网格点上。它位于一个小立方体内部,周围有 8 个相邻网格点。
先找到输入所在的小格子
三条轴分别使用一维 LUT 的位置换算:
把输入和 代入:
R:0.62 × 16 = 9.92G:0.38 × 16 = 6.08B:0.74 × 16 = 11.84沿 R 轴看,输入位于下标 9 和 10 之间,并且从 9 向 10 走了 0.92。另外两条轴也能得到各自的低侧索引、高侧索引和局部位置:
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.92r0 = floor(9.92) = 9r1 = r0 + 1 = 10fr = pr - r0 = 9.92 - 9 = 0.92fr=0.92 表示输入已经从下标 9 向下标 10 走了 92%。它离右侧样本更近,所以沿 R 插值时,c100 一侧会取得更大权重。
R:r0 = 9,r1 = 10,fr = 0.92G:g0 = 6,g1 = 7,fg = 0.08B:b0 = 11,b1 = 12,fb = 0.84fr、fg、fb 描述输入在当前小格子里走到了哪里。三者都位于 。
可以按顺序走一遍:先看输入投影到三条轴,再找到小格子,最后点击八个顶点读取数组位置。
RGB 立方体的坐标定位实验
沿着同一颗像素完成投影、网格定位、顶点组合和数组读取。
P = (0.62, 0.38, 0.74)。三个分量分别给出点在 R、G、B 轴上的位置。
切换 LUT 尺寸后,同一颗像素会落到不同的数组下标。输入 RGB 没有变化,网格密度改变了坐标换算中的 。
每条轴可以选择低侧或高侧。三个方向组合出 个角点:
| 名称 | 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。它一共有 个表项。输入变化时,LUT 本身不变;变化的是 (r0/r1, g0/g1, b0/b1),所以 ISP 会从同一张表里读到另一组 8 个表项。
点云把整张表贴在这里。每个点的位置表示输入网格坐标,点的颜色表示该表项保存的输出 RGB。白色小线框圈出当前例子 (0.62, 0.38, 0.74) 命中的 8 个表项。
这就是后面两种插值共用的 17³ LUT
4913 个点对应 4913 个表项。点的位置是输入网格坐标,点的颜色是该表项保存的输出 RGB。
页面加载组件前,程序先一次性生成整张演示表;滑杆变化时只查数组,不重新生成颜色:
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 个角点合成一个输出颜色。
三线性插值重复三轮刚才的一维插值:
- 沿 R 方向,把 8 个角点合成 4 个颜色。
- 沿 G 方向,把 4 个颜色合成 2 个颜色。
- 沿 B 方向,把最后 2 个颜色合成 1 个颜色。
可以试一下:在完整的 0~1 范围拖动输入 R、G、B。实验先定位当前小格子,读出 8 个 LUT 表项,再从上到下完成 8 → 4 → 2 → 1。
三线性插值的三轮合并实验
输入范围是完整的 0~1。组件会先定位当前网格单元,再沿 R、G、B 完成三轮合并。
pr = 0.620 × 16 = 9.92r0 = floor(9.92) = 9r1 = r0 + 1 = 10fr = pr − r0 = 0.92pg = 0.380 × 16 = 6.08g0 = floor(6.08) = 6g1 = g0 + 1 = 7fg = pg − g0 = 0.08pb = 0.740 × 16 = 11.84b0 = floor(11.84) = 11b1 = b0 + 1 = 12fb = pb − b0 = 0.84(0.610, 0.371, 0.678)(0.684, 0.374, 0.675)(0.614, 0.442, 0.683)(0.688, 0.445, 0.679)(0.612, 0.373, 0.743)(0.686, 0.376, 0.741)(0.617, 0.444, 0.748)(0.691, 0.447, 0.745)沿 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它们满足:
当前输入使用下面四个顶点:
000 → 100 → 101 → 111对应权重是:
代入当前数字:
c000:0.08c100:0.08c101:0.76c111:0.08四个权重都不小于 0,加起来等于 1。输出就是四个顶点颜色的加权和。
可以试一下:在完整的 0~1 范围拖动输入 R、G、B,让局部坐标跨过四面体的分界面。半透明体积会切换到另一块,顶点和权重也会一起变化。
四面体插值的选区与权重实验
输入范围是完整的 0~1。组件会动态定位网格单元,再用局部位置的大小顺序选择四面体。
pr = 0.620 × 16 = 9.92r0 = floor(9.92) = 9r1 = r0 + 1 = 10fr = pr − r0 = 0.92pg = 0.380 × 16 = 6.08g0 = floor(6.08) = 6g1 = g0 + 1 = 7fg = pg − g0 = 0.08pb = 0.740 × 16 = 11.84b0 = floor(11.84) = 11b1 = b0 + 1 = 12fb = pb − b0 = 0.84(0.610, 0.371, 0.678)(0.684, 0.374, 0.675)(0.614, 0.442, 0.683)(0.688, 0.445, 0.679)(0.612, 0.373, 0.743)(0.686, 0.376, 0.741)(0.617, 0.444, 0.748)(0.691, 0.447, 0.745)先比较局部坐标。此时保留八个角点,确认输入位于立方体里的哪里。
fr = 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 尺寸包括:
| 尺寸 | 保存的颜色样本 |
|---|---|
| 4,913 | |
| 35,937 | |
| 274,625 |
更密的网格可以记录更弯曲的颜色变换,也会占用更多存储空间和带宽。尺寸翻倍后,样本数会增长到原来的约八倍。
网格太稀时,LUT 会漏掉采样点之间的细节。提高图像位深不能补回这些细节,因为位深回答的是“一个通道能写下多少个码值”,LUT 分辨率回答的是“原始变换记录了多少个位置”。
插值方法回答第三个问题:样本之间怎样连接。网格密度、输出位深和插值方法需要分别检查。
高动态范围输入还会遇到采样分配问题。工程师可以在 3D LUT 前加入 Shaper LUT,先把暗部、中间调或高光展开,让 3D 网格把更多采样点留给需要精细处理的范围。
.cube 文件保存了什么
许多调色和图像软件使用 .cube 文件交换 LUT。文件头会说明输入范围和网格大小:
TITLE "Example Look"DOMAIN_MIN 0.0 0.0 0.0DOMAIN_MAX 1.0 1.0 1.0LUT_3D_SIZE 17后面的每一行保存一个网格点的输出 RGB:
0.000000 0.000000 0.0000000.062500 0.000000 0.0000000.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)颜色不符合预期时,按计算顺序记录中间结果:
- 写下 LUT 收到的 RGB,确认输入色彩空间和编码。
- 把输入映射到定义域,检查有没有提前裁剪。
- 计算三个轴的位置,核对低侧索引、高侧索引和局部小数。
- 读取相邻顶点,用纯色测试确认轴顺序。
- 打印插值权重,确认权重非负且总和为 1。
- 保留浮点输出,再交给后续色域映射和编码步骤。
对 (0.62, 0.38, 0.74) 而言,17³ LUT 应得到:
R:9 → 10,fr = 0.92G:6 → 7,fg = 0.08B:11 → 12,fb = 0.84这三组坐标确定了 ISP 应该读取哪一小块数据。三线性插值沿三个方向逐步合成颜色,四面体插值先选择所在区域,再对四个顶点加权。
LUT 改变颜色的过程到这里已经完整:工程师预先保存一些输入位置的输出颜色,ISP 找到当前像素附近的样本,再按距离算出中间结果。
下拉加载评论