你对着灰卡做了白平衡,灰色终于不偏黄了;可镜头转向一盒彩笔,紫色还是偏蓝,绿色也有点发黄。白色已经正确,为什么其他颜色仍然不准?
因为相机传感器眼里的“红、绿、蓝”,和目标色彩空间里的红、绿、蓝并不是同一组三原色。白平衡只能分别缩放三个通道,而 CCM(Color Correction Matrix,颜色校正矩阵)会让三个通道互相混合。最常见的 CCM 只有 ,九个数字却足以完成一类重要的全局颜色校正。
本文默认输入是减过黑电平、做过白平衡、仍处于线性光域的 RGB 浮点数。我们先理解矩阵怎样改变一颗颜色,再从一张色卡求出矩阵,最后处理矩阵方向、负值、裁剪和泛化等工程问题。
白平衡正确,颜色仍然可能不对
传感器上的 R、G、B 滤光片并非只让一种理想波长通过。红色感光单元也可能接收到一部分绿光,绿色和蓝色响应同样存在重叠。不同相机的滤光片、硅材料与微透镜不同,它们得到的是各自的 camera RGB。
白平衡可以写成一个对角矩阵:
它能让选定的中性物体满足 ,但对角线之外全是零:红色输出永远看不到绿色和蓝色输入。传感器原色和目标原色之间的差异需要更一般的线性变换。
CCM 在颜色管线的什么位置
一条教学用的相机管线可以简化成下面几站。真实 ISP 还会穿插坏点修复、降噪、镜头阴影校正和高光恢复,但这不改变 CCM 的基本位置。
CCM 在颜色管线的哪一站?
点选每个阶段,看它负责什么。CCM 之前的数据应保持线性,之后才进入色调与显示编码。
CCM 通常在线性光域工作。若先对 RGB 做 Gamma 编码,再乘矩阵,矩阵看到的就不再是可线性相加的光量。两个步骤即使各自都“算对了”,组合结果也不是原本拟合的颜色变换。
WB 与 CCM 在数学上都可以写成矩阵,甚至能相乘合并;工程上常把它们保留成独立阶段,因为白平衡随光源改变,而相机到目标空间的基础变换更稳定,也更容易分别调试。
九个数字怎样改变一颗颜色
采用列向量约定时,CCM 写作:
把第一行展开:
也就是说,矩阵的一行描述一个输出通道如何由三个输入通道组成。第一列则描述“输入 R 会被送到哪些输出通道”。这两个视角都对,但不要混在一起。
例如输入 ,矩阵第一行是 ,那么:
正系数把某通道加入结果,负系数则从结果中减去一部分串扰。负系数并不等于矩阵错误;真正需要警惕的是后续是否过早把中间结果裁剪掉。
3×3 矩阵实验室
编辑九个系数或输入颜色。每一行都是一次点积;留意裁剪前的负值与大于 1 的值。
矩阵 M
逐行点积
单位矩阵的对角线是 1,其他位置是 0,输出与输入完全相同。只有对角项的矩阵只是通道增益;非对角项出现后,一个输出通道才会真正依赖其他输入通道。
行向量、列向量与转置陷阱
上面的公式使用列向量,所以 NumPy 中单个颜色可以写成 matrix @ color。但一张图常保存为 height × width × 3,最后一个轴是 RGB。把所有像素看成一行一个颜色时,应写:
corrected = image @ matrix.T转置不是凭感觉加的:行向量约定是 ,列向量约定是 。如果配置文件已经按另一种约定保存,而代码又额外转置一次,程序不会报错,只会产生一张颜色古怪的图。
OpenCV 常见的 BGR 顺序是另一类隐蔽错误。最便宜的测试不是先看复杂照片,而是分别输入纯红、纯绿、纯蓝和几级灰阶,检查每个输出是否与矩阵对应的列一致。
从几何上看:CCM 在扭动 RGB 空间
RGB 颜色可以看作立方体中的点。线性矩阵会把原点保持在原点,同时改变三条基向量的方向和长度:单位立方体一般会变成倾斜的平行六面体。
RGB 空间变形器
CCM 不只是改变一颗颜色,它会把整个 RGB 立方体一起拉伸、倾斜。切换裁剪,观察形状如何被削平。
几何意义
非对角项把一个输入轴分配到多个输出轴,立方体因而倾斜。
当前显示未裁剪坐标;越出标准立方体并不代表计算错误。
这个几何视角解释了三个事实:
- CCM 是全局线性变换,相同矩阵作用于所有像素;
- 输出可能小于 0 或大于 1,因为变换后的形状可以越出显示立方体;
- 一旦立刻裁剪,空间外的许多不同颜色会被压到同一表面,后续操作无法区分它们。
因此,图像处理中间阶段通常使用浮点数并保留越界值,等明确进入目标色域或显示阶段时再决定 gamut mapping、色调压缩或裁剪策略。CSS 色块为了显示必须裁剪,但它不是矩阵的真实数值范围。
一张色卡怎样求出 CCM
假设拍摄色卡后得到 个相机测量颜色 ,同时知道目标空间中的参考颜色 。希望找到矩阵 ,让所有色块的预测尽量接近目标:
这就是线性最小二乘。把测量颜色与目标颜色都排成“一行一个色块”的数组,用 NumPy 可以直接求解:
import numpy as np
def fit_ccm(camera_rgb, target_rgb): """拟合列向量约定下的 3×3 CCM。""" camera_rgb = np.asarray(camera_rgb, dtype=np.float64) target_rgb = np.asarray(target_rgb, dtype=np.float64)
if camera_rgb.shape != target_rgb.shape: raise ValueError("测量值和目标值必须一一对应") if camera_rgb.ndim != 2 or camera_rgb.shape[1] != 3: raise ValueError("输入形状应为 (色块数, 3)")
# 求 X @ A ≈ Y;A 的形状是 (3, 3) row_transform, *_ = np.linalg.lstsq( camera_rgb, target_rgb, rcond=None, ) return row_transform.Tnp.linalg.lstsq 比手写 更稳健,因为后者显式形成正规方程,会放大病态数据带来的数值问题。
只用三个线性独立色块,理论上就能解出九个未知数。但那相当于让矩阵精确穿过三组观测:一点测量噪声、反光或曝光误差都可能被写进矩阵。使用更多覆盖不同色相和明度的色块,才能让误差在整体上折中。
色卡拟合实验室
选择训练色块数量,并加入测量噪声或异常点。比较训练误差与从未参与拟合的验证误差。
每格左半是目标色,右半是拟合输出。只有三个训练色块时,矩阵可能精确穿过它们,却在其他颜色上表现很差;异常点则会把全局解向错误方向拉动。
加权、正则化与约束
普通最小二乘默认每个 RGB 数值误差同样重要。实际调校可能给中性色、肤色或低噪声色块更高权重;也可能加入正则项,避免矩阵系数为了追逐噪声变得过大:
有时还会约束行和,使某个白点保持中性。这些方法不是免费提升:权重和约束表达的是产品目标,选错同样会牺牲其他颜色。教学阶段应先把未约束最小二乘、数据清洗和验证集做对。
为什么训练误差很小,真实照片仍然不好看
色卡只覆盖有限的反射光谱和颜色区域。CCM 拟合得很好,只表示它在这套相机、镜头、光源、曝光和参考数据上找到了一个好的全局线性近似。
常见的失效原因包括:
- 光源改变。 同一个表面在日光和窄带 LED 下产生的传感器响应不同,一张矩阵不一定跨光源通用;
- 色块测量不可靠。 过曝、阴影、眩光、色卡老化都会污染输入;
- 训练覆盖不足。 只用中低饱和色拟合,无法保证高饱和颜色;
- 同色异谱。 两种光谱在人眼或参考观察者下看起来相同,在相机传感器上可能不同,固定三通道矩阵无法完全消除;
- 目标不一致。 用线性参考值拟合,却拿 Gamma 编码值验证,评价从一开始就不在同一个域。
应当保留未参与拟合的验证色块,最好再使用真实场景与多个光源。训练误差回答“矩阵记住这些数据了吗”,验证误差才更接近“矩阵能否用于新颜色”。
整张图像的 NumPy 实现
下面的实现保留浮点越界值,把是否裁剪留给调用者:
import numpy as np
def apply_ccm(image, matrix, clip=False): """对形状为 (..., 3) 的线性 RGB 数组应用 CCM。""" image = np.asarray(image, dtype=np.float32) matrix = np.asarray(matrix, dtype=np.float32)
if image.shape[-1] != 3: raise ValueError("图像最后一个维度必须是 RGB") if matrix.shape != (3, 3): raise ValueError("CCM 必须是 3×3")
corrected = image @ matrix.T if clip: corrected = np.clip(corrected, 0.0, 1.0) return corrected如果原图是 uint16,不要直接在整数数组上乘矩阵。先减黑电平、按白电平归一化并转换为浮点数;否则负系数可能触发无符号整数回绕,中间结果也可能溢出。
对高分辨率图像,image @ matrix.T 已是向量化操作,不需要 Python 三重循环。矩阵只有九个数,性能瓶颈通常在内存带宽、前后处理和数据格式转换,而不是乘法本身。
CCM、白平衡和 3D LUT 的边界
三者都会改变 RGB,却擅长不同的问题:
| 方法 | 数学形式 | 通道是否耦合 | 典型任务 |
|---|---|---|---|
| 白平衡 | 对角 矩阵 | 否 | 恢复中性、适配光源 |
| CCM | 一般 矩阵 | 是,线性 | 相机原色到目标原色 |
| 3D LUT | 三维离散表与插值 | 是,非线性 | 复杂校正、色域映射、风格 |
CCM 的九个系数紧凑、快速、易于分析,但它不能选择性地“只改青色而不动别处”,也无法表达弯曲的色调响应。3D LUT 更自由,却依赖采样密度、插值和明确的输入定义域。工程上常先用 WB 与 CCM 完成物理上可解释的基础校正,再用曲线或 LUT 处理非线性目标。
一份从简单到复杂的调试清单
遇到颜色不对时,不要先盯着真实照片猜参数。按下面顺序缩小问题:
- 单位矩阵测试: 输出必须逐值等于输入;
- 纯色测试: 纯 R、G、B 的输出应分别等于矩阵的三列;
- 灰阶测试: 观察行和是否让中性色保持中性;
- 确认数据域: 输入是否减过黑电平、做过 WB、仍是线性 RGB;
- 确认约定: RGB 还是 BGR,行向量还是列向量,配置文件是否已经转置;
- 关闭裁剪: 先检查真实的负值和越界值,再定位是谁负责最终映射;
- 分离训练与验证: 不要只汇报拟合所用色块的误差;
- 跨场景检查: 至少检查高光、暗部、高饱和颜色和不同光源。
CCM 的公式很短,难点却不在矩阵乘法本身,而在于知道九个数字的输入是什么、目标是什么,以及中间结果何时仍值得保留。把这些边界说清楚,矩阵就不再是一盒神秘参数:它只是把相机的三根颜色坐标轴,重新对齐到我们希望使用的颜色空间。