剪辑软件给 Log 视频套用 LUT 后,画面的明暗和颜色随之改变。LUT 执行的核心操作是查表。
LUT 是 Look-Up Table,也就是查找表。它保存一组输入与输出的对应关系。输入落在两个样本之间时,程序用插值估算输出。
1D LUT 在一条线上选择两个邻居,3D LUT 在 RGB 立方体中选择网格顶点。两者都需要坐标、邻居和权重。
文中的 RGB 均归一化到 。亮度单位、显示设备和完整色彩管理不在讨论范围内。
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 数值经过定义域、Shaper 和 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 的采样结果
调节采样点数量和插值方式。虚线表示目标曲线,实线表示 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时,右侧索引停在最后一项。
需要保留超范围高光时,请按 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 曲线,观察肤色、青色和蓝色等含蓝色分量的色块同时变化。
选择“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 网格单元。切换视图,核对各轴投影。
- 输入颜色 P
- 所在网格单元
- 完整定义域 [0,1]³
P = (0.62, 0.38, 0.74)p = P × (5 − 1) = (2.48, 1.52, 2.96)5³ LUT 包含 125 个样本。当前网格单元由 8 个顶点围成。
17³ LUT 每条边放置 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 表示当前单元中 R 选择高侧 r1,G 和 B 选择低侧 g0、b0。程序用这三个索引从 LUT 读取一个输出颜色。
从输入坐标定位 c000 到 c111
按投影、网格定位、顶点组合和数组读取的顺序,查看空间坐标如何转换为 LUT 索引。
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.84c000 对应 LUT 坐标 (9, 6, 11),c100 对应 (10, 6, 11),对角的 c111 对应 (10, 7, 12)。其余五个顶点使用另外五组高低索引组合。
三线性插值执行三轮 lerp
三线性插值沿 R、G、B 三个方向依次执行一维线性插值。
设输入点在当前网格单元内的局部位置为 ,每个分量都位于 :
- 沿 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 分辨率
LUT 分辨率描述网格密度。33³ 比 17³ 保存更多中间颜色样本,因而能记录曲率更高的变换。
图像或输出位深
8-bit 每通道提供 256 个编码级别,10-bit 提供 1024 个。LUT 插值无法消除低位深量化产生的色带。
插值方式
最近邻、三线性和四面体决定如何估算 LUT 样本之间的输出。近似误差与输出量化是两个误差来源。
抖动(dithering)加入受控噪声,把规则色带打散成颗粒。它不会恢复量化丢失的细节,但能减弱色带的视觉边界。
分开观察三种精度限制
LUT 尺寸控制采样密度,插值估算样本间的输出,位深控制量化级数。
6-bit 选项用于放大量化色带。抖动把规则色带打散成颗粒,误差仍然存在。
Log 编码和高动态范围输入常在 3D LUT 前使用 Shaper LUT。Shaper 重新分配数值区间,让 3D 网格在重点明暗区域获得更多采样点。
.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. 色彩空间或传递函数不匹配
把 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_MIN、DOMAIN_MAX与恰好等于1.0的输入;- 两种插值结果的最大误差出现在哪里。
选择 LUT 与插值方法
亮度、Gamma 和独立通道曲线可以使用 1D LUT。
色相旋转、选择性色彩和通道耦合变换需要 3D LUT。
在实现 3D LUT 时:
- GPU 提供 3D 纹理采样或项目要求实时处理时,可以先用三线性插值;
- 需要匹配采用四面体插值的调色软件或处理管线,应明确实现四面体插值;
- 变换曲率高时,提高 LUT 分辨率或加入 Shaper;
- 需要可靠复现时,必须记录输入色彩空间、定义域、插值方法和超范围处理方式。
LUT 保存离散的输入输出样本,插值估算样本之间的结果。1D LUT 在一条线上读取两个邻居;3D LUT 在 RGB 立方体中定位一个网格单元。三线性使用单元的 8 个顶点,四面体插值使用输入所在四面体的 4 个顶点。
实现和调试 LUT 时,依次核对输入坐标、相邻样本和插值权重。
下拉加载评论