在视频后期处理里,LUT(Look-Up Table,颜色查找表)和 PIP(Picture-in-Picture,画中画)通常被当作两个独立功能。前者负责把颜色从一套规则映射到另一套规则,后者负责在画面角落叠加一个子画面。把两者放在同一条工作流里,能解决一个很实际的问题:调出来的颜色到底有没有变干净。下面用一次代号为 LUT-E 的调色实战记录为例,说明如何用 LUT 把灰蒙蒙的素材清理出层次,再用 PIP 输出前后对比,最后得到一张可以贴到交付文档里的效果图。这是一次典型的“清图胜利”流程,重点不是某个参数多高明,而是整条链路从素材、配置、脚本到验证都是可复现的。
1. 先理解 LUT 与 PIP:一个负责改颜色,一个负责看对比
1.1 LUT 是什么,为什么能“清图”
LUT 的全称是 Look-Up Table,中文叫颜色查找表。通俗地说,它是一张输入颜色到输出颜色的映射表:给一个 RGB 值,它返回一个新的 RGB 值。调色软件拿到这张表后,会把图像里的每一个像素按表重新映射一次,从而改变画面整体风格。
LUT 分为 1D LUT 和 3D LUT。1D LUT 只对 RGB 三个通道分别做独立映射,适合做亮度、伽马这类调整。3D LUT 把 R、G、B 三个通道组合起来查表,可以表达更复杂的颜色交叉影响,比如改变绿色时同时影响黄色和青色。影视后期常见的.cube文件就是 3D LUT 的一种标准格式。
下面是一个最小.cube文件的片段:
TITLE "lut_e" LUT_3D_SIZE 33 DOMAIN_MIN 0.0 0.0 0.0 DOMAIN_MAX 1.0 1.0 1.0 0.000000 0.000000 0.000000 0.001953 0.001953 0.001953 0.003906 0.003906 0.003906 ...这个文件表示:颜色输入范围是 0.0 到 1.0,每个维度采样 33 个点,形成一个 33x33x33 的三维映射表。实际应用时,图像里的任意 RGB 值都会在表中找到对应输出。
很多人容易把 LUT 和“滤镜”混为一谈。滤镜通常是预设好的一组视觉风格,LUT 则是滤镜背后的颜色映射数据。同一张 LUT,可以在 DaVinci Resolve、Premiere Pro、ffmpeg 里反复使用。这也是为什么调色项目里,LUT 文件本身要单独保存、单独版本管理。
1.2 PIP 为什么能帮调色做判断
PIP 是 Picture-in-Picture 的缩写,中文叫画中画。最常见的场景是直播时把摄像头画面放到游戏画面角落,但在调色工作流里,它的作用更大:主画面显示处理后的效果,角落子画面显示原始未处理画面。这样可以在同一张图里完成“前后对比”,不需要来回切换文件,也不会因为看图环境变化而误判。
PIP 看起来只是多了一个小窗口,实际却很考验处理细节:两个画面必须使用相同的分辨率缩放逻辑,避免比例被压扁;子画面位置要避开关键主体;输出时还要考虑边框、阴影和透明度,否则对比图会显得杂乱。
在 LUT-E 项目中,PIP 承担的是“验收”角色。LUT 负责把画面从灰蒙蒙变成通透,PIP 负责用分钟级的速度给出对比结果。调色完成不等于“看起来更好”,而是要在原始画面对比下,仍然成立。
1.3 LUT-E 这次要解决什么问题
这次项目的输入素材是一张带噪声和灰雾感的测试图:亮度集中在中间,暗部没有沉下去,亮部也没有层次。目标是做一套可复现的 LUT 调色流程,实现三个效果:
- 提高对比度,让画面从“灰”变“通透”;
- 微调色温,去掉偏色;
- 适当锐化,让边缘更清楚,但不产生明显噪点。
最终用 PIP 把原图和结果放在同一张图里,形成“清图胜利”的验证结果。整条流程不依赖图形界面,使用 Python 脚本生成 LUT 文件,再用 ffmpeg 合成对比图。这样做的好处是:换一张图片、换一批参数,只要重跑脚本就能得到同样效果。
2. 环境准备:先搭好可重复运行的骨架
2.1 工具链选型
LUT-E 的核心是三个组件:Python 负责生成 LUT 和应用 LUT,ffmpeg 负责生成 PIP 对比图,YAML 负责记录参数。这样做的原因很简单:脚本可重复执行,配置可版本管理,输出目录固定,不会出现“这次调得顺手,下次找不到参数”的问题。
| 组件 | 用途 | 建议版本 |
|---|---|---|
| Python | 运行 LUT 生成和应用脚本 | 3.9 及以上 |
| ffmpeg | 生成 PIP 对比图,后续可扩展视频批处理 | 6.0 及以上 |
| numpy | 处理三维 LUT 网格和图像数组 | 1.24 及以上 |
| Pillow | 图片读写、锐化 | 9.4 及以上 |
| PyYAML | 读取 YAML 配置 | 6.0 及以上 |
如果你的电脑里还没有这些工具,建议先使用 Python 虚拟环境,避免污染系统目录。ffmpeg 建议直接使用官方网站提供的静态构建版本,因为它包含的滤镜组件更完整。
2.2 目录结构
工程目录不需要复杂,但要分层清晰。推荐结构如下:
lute-demo/ ├── config/ │ └── lut_e.yaml ├── input/ │ └── original.png ├── luts/ │ └── lut_e.cube ├── output/ │ ├── applied.png │ └── pip_preview.png └── scripts/ ├── make_lut.py ├── apply_lut.py └── make_sample.pyconfig 放调色参数,luts 放生成的 LUT 文件,output 放中间产物和最终对比图,scripts 放脚本。原图放在 input 下,处理后的图片不覆盖输入文件。这个约定能避免误操作,也让批处理脚本更容易扩展。
2.3 安装依赖并验证
在命令行执行:
mkdir -p lute-demo/{config,input,luts,output,scripts} cd lute-demo python -m venv venv source venv/bin/activate pip install --upgrade pip pip install numpy pillow pyyaml ffmpeg -versionWindows 下激活虚拟环境的命令是venv\Scripts\activate,其余命令一致。如果python命令指向的是 Python 3.9 以上版本,安装后可以用一个更直接的检查命令确认:
python -c "import numpy, PIL, yaml; print('deps ok')" ffmpeg -filters | grep overlaygrep overlay是为了确认 ffmpeg 编译时包含了 overlay 滤镜。PIP 合成依赖这个滤镜,如果版本过旧或编译配置不全,后面会报错。
2.4 生成一张测试原图
为了方便复现,可以直接用脚本生成一张带渐变和噪声的图片,模拟“灰蒙蒙”的效果。新建scripts/make_sample.py:
#!/usr/bin/env python3 import numpy as np from PIL import Image def main(): rng = np.random.default_rng(42) w, h = 1920, 1080 r = np.linspace(120, 200, w, dtype=np.uint8) g = np.linspace(110, 180, w, dtype=np.uint8) b = np.linspace(100, 160, w, dtype=np.uint8) r = np.tile(r, (h, 1)) g = np.tile(g, (h, 1)) b = np.tile(b, (h, 1)) noise = rng.normal(0, 8, (h, w, 3)) rgb = np.stack([r, g, b], axis=2) rgb = np.clip(rgb + noise, 0, 255).astype(np.uint8) Image.fromarray(rgb).save("input/original.png") print("input/original.png saved") if __name__ == "__main__": main()运行:
python scripts/make_sample.py这张图的特点是:整体偏灰、层次不强,但保留渐变和噪声,适合验证 LUT 后的对比度变化。实际项目中,这一步替换为真实素材即可。
3. 实现一个最小的 LUT-E 流程
3.1 用 YAML 记录调色参数
配置文件config/lut_e.yaml定义了整个流程的核心参数。为什么用 YAML 而不是直接把参数写死在 Python 脚本里?因为调色是一个反复试参数的过程,配置和代码分离后,改参数不用动到执行逻辑,也更容易记录“哪一版参数产生了哪一张图”。
project: lut-e image: input/original.png output_dir: output color_space: sRGB lut: name: lut_e size: 33 exposure: 1.06 contrast: 1.16 saturation: 1.08 temperature: -6 post: sharpen: 0.8 pip: enabled: true position: bottom-right margin: 40 scale: 0.35这里先解释几个关键字段:
lut.size:3D LUT 的采样密度,常见是 17、33、65。33 是质量和文件大小的中间选择。exposure:整体亮度增益。大于 1 变亮,小于 1 变暗。contrast:对比度系数。大于 1 拉大亮暗差距,小于 1 会让画面变灰。saturation:饱和度系数。调整颜色鲜艳程度。temperature:色温偏移,负值偏冷,正值偏暖。post.sharpen:LUT 应用后的锐化强度,数值越大边缘越锐利。pip.scale:PIP 子画面相对于主画面的缩放比例。
3.2 用 Python 生成 CUBE 格式 LUT
scripts/make_lut.py读取 YAML,生成一个 3D LUT 文件。代码只做了最简单的亮度、对比度、饱和度和色温调整,方便看清每一段之间的关系。
#!/usr/bin/env python3 import argparse import yaml import numpy as np def load_config(path): with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) def identity_3d(size=33): values = np.linspace(0.0, 1.0, size) grid = np.stack( np.meshgrid(values, values, values, indexing="ij"), axis=-1 ) return grid.reshape(-1, 3) def adjust_3d(lut, cfg): lut_cfg = cfg["lut"] exposure = lut_cfg.get("exposure", 1.0) contrast = lut_cfg.get("contrast", 1.0) saturation = lut_cfg.get("saturation", 1.0) temperature = lut_cfg.get("temperature", 0) out = lut * exposure out = (out - 0.5) * contrast + 0.5 gray = out.mean(axis=-1, keepdims=True) out = gray + (out - gray) * saturation out[..., 0] += temperature / 255.0 return np.clip(out, 0.0, 1.0) def save_cube(lut, path, size=33, title="lut_e"): with open(path, "w", encoding="utf-8") as f: f.write(f'TITLE "{title}"\n') f.write(f"LUT_3D_SIZE {size}\n") f.write("DOMAIN_MIN 0.0 0.0 0.0\n") f.write("DOMAIN_MAX 1.0 1.0 1.0\n") for r, g, b in lut: f.write(f"{r:.6f} {g:.6f} {b:.6f}\n") if __name__ == "__main__": parser = argparse.ArgumentParser(description="Generate CUBE LUT from YAML config") parser.add_argument("--config", required=True) parser.add_argument("--output", required=True) args = parser.parse_args() cfg = load_config(args.config) lut_size = cfg["lut"].get("size", 33) lut = identity_3d(lut_size) lut = adjust_3d(lut, cfg) save_cube(lut, args.output, lut_size, cfg["lut"].get("name", "lut_e")) print(f"saved {args.output} with {len(lut)} entries")执行:
python scripts/make_lut.py --config config/lut_e.yaml --output luts/lut_e.cube生成的文件就是标准的 3D LUT,可以直接导入 DaVinci Resolve、Premiere Pro 等软件。这里使用“先建单位 LUT,再调整”的方式,优点是代码逻辑直观:每个参数只影响一段处理,调试时容易定位问题。
要注意,这种最简单实现没有做真正的三维插值,图像应用时用的是最近邻查找。生产环境建议使用三线性插值或四面体插值,否则渐变区域可能出现轻微色块。
3.3 把 LUT 应用到图片
scripts/apply_lut.py读取.cube文件并将颜色映射到目标图片上。它在 LUT 映射后额外做一步锐化,把“清理画面”的观感补全。
#!/usr/bin/env python3 import argparse import yaml import numpy as np from PIL import Image, ImageFilter def load_cube(path): size = 33 values = [] with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#"): continue if line.startswith("LUT_3D_SIZE"): size = int(line.split()[-1]) elif line.startswith("TITLE") or line.startswith("DOMAIN"): continue else: parts = line.split() if len(parts) == 3: values.append([float(x) for x in parts]) data = np.array(values, dtype=np.float32).reshape(size, size, size, 3) return size, data def apply_cube(image_path, cube_path, out_path, sharpen=0.0): img = Image.open(image_path).convert("RGB") arr = np.asarray(img, dtype=np.float32) / 255.0 size, lookup = load_cube(cube_path) h, w, _ = arr.shape idx = (arr * (size - 1)).astype(np.int32) r = idx[..., 0] g = idx[..., 1] b = idx[..., 2] mapped = lookup[r, g, b] out = (np.clip(mapped, 0.0, 1.0) * 255).astype(np.uint8) result = Image.fromarray(out) if sharpen > 0: result = result.filter( ImageFilter.UnsharpMask(radius=2, percent=sharpen * 100, threshold=3) ) result.save(out_path) return result if __name__ == "__main__": parser = argparse.ArgumentParser(description="Apply CUBE LUT to an image") parser.add_argument("--config") parser.add_argument("--image", required=True) parser.add_argument("--lut", required=True) parser.add_argument("--output", required=True) args = parser.parse_args() sharpen = 0.0 if args.config: with open(args.config, encoding="utf-8") as f: cfg = yaml.safe_load(f) sharpen = cfg.get("post", {}).get("sharpen", 0.0) apply_cube(args.image, args.lut, args.output, sharpen) print("applied LUT and saved:", args.output)运行:
python scripts/apply_lut.py \ --config config/lut_e.yaml \ --image input/original.png \ --lut luts/lut_e.cube \ --output output/applied.png这段代码有几个关键点:
- 查找时用
(arr * (size - 1))把 0 到 1 的颜色值映射到表的索引区间; lookup[r, g, b]直接按三维索引取值,得到的是该颜色对应的输出 RGB;- LUT 处理后立刻 clamp 到 0 到 1,再转回 0 到 255,避免越界导致偏色;
- 锐化放在 LUT 之后,因为锐化属于空间处理,无法写进逐像素的 LUT 文件。
3.4 用 ffmpeg 生成 PIP 对比图
生成 LUT 并应用后,需要一张对比图验收。使用 ffmpeg 的 overlay 滤镜把原图缩略图放在主画面右下角:
ffmpeg -y \ -i output/applied.png \ -i input/original.png \ -filter_complex \ "[0:v]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,setsar=1[base]; \ [1:v]scale=iw*0.35:ih*0.35,setsar=1[small]; \ [base][small]overlay=W-w-40:H-h-40:format=auto[out]" \ -map "[out]" -frames:v 1 output/pip_preview.png这段命令的意思是:
- 第一个输入是处理后的图片,把它固定到 1920x1080,作为主画面;
- 第二个输入是原始图片,缩放为原来的 0.35 倍,作为角落子画面;
- overlay 滤镜把子画面放到主画面的右下角,距离边距 40 像素;
- 输出一张静态 PNG。
如果想要子画面在左下角,把overlay的坐标改成40:H-h-40即可。其他位置可以根据需要调整。
3.5 用 run.sh 串联整条流程
开发阶段可以一条一条执行命令,但第二次、第三次调参时,最好用一个脚本把三步串起来:
#!/usr/bin/env bash set -euo pipefail PYTHON=venv/bin/python CONFIG=config/lut_e.yaml LUT_OUT=luts/lut_e.cube IMG_IN=input/original.png IMG_LUT=output/applied.png PIP_OUT=output/pip_preview.png python scripts/make_sample.py $PYTHON scripts/make_lut.py --config "$CONFIG" --output "$LUT_OUT" $PYTHON scripts/apply_lut.py --config "$CONFIG" --image "$IMG_IN" --lut "$LUT_OUT" --output "$IMG_LUT" ffmpeg -y \ -i "$IMG_LUT" \ -i "$IMG_IN" \ -filter_complex \ "[0:v]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,setsar=1[base]; \ [1:v]scale=iw*0.35:ih*0.35,setsar=1[small]; \ [base][small]overlay=W-w-40:H-h-40:format=auto[out]" \ -map "[out]" -