2833 字
14 分钟
CCM 是怎么校正颜色的?从 3×3 矩阵看懂 RGB 通道混合

你对着灰卡做了白平衡,灰色终于不偏黄了;可镜头转向一盒彩笔,紫色还是偏蓝,绿色也有点发黄。白色已经正确,为什么其他颜色仍然不准?

因为相机传感器眼里的“红、绿、蓝”,和目标色彩空间里的红、绿、蓝并不是同一组三原色。白平衡只能分别缩放三个通道,而 CCM(Color Correction Matrix,颜色校正矩阵)会让三个通道互相混合。最常见的 CCM 只有 3×33\times3,九个数字却足以完成一类重要的全局颜色校正。

本文默认输入是减过黑电平、做过白平衡、仍处于线性光域的 RGB 浮点数。我们先理解矩阵怎样改变一颗颜色,再从一张色卡求出矩阵,最后处理矩阵方向、负值、裁剪和泛化等工程问题。

白平衡正确,颜色仍然可能不对#

传感器上的 R、G、B 滤光片并非只让一种理想波长通过。红色感光单元也可能接收到一部分绿光,绿色和蓝色响应同样存在重叠。不同相机的滤光片、硅材料与微透镜不同,它们得到的是各自的 camera RGB

白平衡可以写成一个对角矩阵:

[RwGwBw]=[gR000gG000gB][RcGcBc]\begin{bmatrix}R_w\\G_w\\B_w\end{bmatrix}= \begin{bmatrix}g_R&0&0\\0&g_G&0\\0&0&g_B\end{bmatrix} \begin{bmatrix}R_c\\G_c\\B_c\end{bmatrix}

它能让选定的中性物体满足 RwGwBwR_w\approx G_w\approx B_w,但对角线之外全是零:红色输出永远看不到绿色和蓝色输入。传感器原色和目标原色之间的差异需要更一般的线性变换。

CCM 在颜色管线的什么位置#

一条教学用的相机管线可以简化成下面几站。真实 ISP 还会穿插坏点修复、降噪、镜头阴影校正和高光恢复,但这不改变 CCM 的基本位置。

CCM 在颜色管线的哪一站?

点选每个阶段,看它负责什么。CCM 之前的数据应保持线性,之后才进入色调与显示编码。

这一步做:在线性域用 3×3 矩阵混合通道,把相机 RGB 映射到目标 RGB。
这一步不做:它是全局线性变换,不擅长局部风格或复杂非线性。

CCM 通常在线性光域工作。若先对 RGB 做 Gamma 编码,再乘矩阵,矩阵看到的就不再是可线性相加的光量。两个步骤即使各自都“算对了”,组合结果也不是原本拟合的颜色变换。

WB 与 CCM 在数学上都可以写成矩阵,甚至能相乘合并;工程上常把它们保留成独立阶段,因为白平衡随光源改变,而相机到目标空间的基础变换更稳定,也更容易分别调试。

九个数字怎样改变一颗颜色#

采用列向量约定时,CCM 写作:

[RGB]=[m00m01m02m10m11m12m20m21m22][RGB]\begin{bmatrix}R'\\G'\\B'\end{bmatrix}= \begin{bmatrix} m_{00}&m_{01}&m_{02}\\ m_{10}&m_{11}&m_{12}\\ m_{20}&m_{21}&m_{22} \end{bmatrix} \begin{bmatrix}R\\G\\B\end{bmatrix}

把第一行展开:

R=m00R+m01G+m02BR'=m_{00}R+m_{01}G+m_{02}B

也就是说,矩阵的一行描述一个输出通道如何由三个输入通道组成。第一列则描述“输入 R 会被送到哪些输出通道”。这两个视角都对,但不要混在一起。

例如输入 [0.6,0.4,0.2]T[0.6,0.4,0.2]^T,矩阵第一行是 [1.2,0.15,0.05][1.2,-0.15,-0.05],那么:

R=1.2×0.60.15×0.40.05×0.2=0.65R'=1.2\times0.6-0.15\times0.4-0.05\times0.2=0.65

正系数把某通道加入结果,负系数则从结果中减去一部分串扰。负系数并不等于矩阵错误;真正需要警惕的是后续是否过早把中间结果裁剪掉。

3×3 矩阵实验室

编辑九个系数或输入颜色。每一行都是一次点积;留意裁剪前的负值与大于 1 的值。

矩阵预设

矩阵 M

逐行点积

R' = 1.14×0.62 + -0.08×0.42 + -0.06×0.24 = 0.659
G' = -0.05×0.62 + 1.09×0.42 + -0.04×0.24 = 0.417
B' = -0.02×0.62 + -0.14×0.42 + 1.16×0.24 = 0.207
输入#9e6b3d
输出(显示已裁剪)0.659, 0.417, 0.207
最小输出0.207
最大输出0.659
范围状态位于 [0, 1]

单位矩阵的对角线是 1,其他位置是 0,输出与输入完全相同。只有对角项的矩阵只是通道增益;非对角项出现后,一个输出通道才会真正依赖其他输入通道。

行向量、列向量与转置陷阱#

上面的公式使用列向量,所以 NumPy 中单个颜色可以写成 matrix @ color。但一张图常保存为 height × width × 3,最后一个轴是 RGB。把所有像素看成一行一个颜色时,应写:

corrected = image @ matrix.T

转置不是凭感觉加的:行向量约定是 c=cMTc'=cM^T,列向量约定是 c=Mcc'=Mc。如果配置文件已经按另一种约定保存,而代码又额外转置一次,程序不会报错,只会产生一张颜色古怪的图。

OpenCV 常见的 BGR 顺序是另一类隐蔽错误。最便宜的测试不是先看复杂照片,而是分别输入纯红、纯绿、纯蓝和几级灰阶,检查每个输出是否与矩阵对应的列一致。

从几何上看:CCM 在扭动 RGB 空间#

RGB 颜色可以看作立方体中的点。线性矩阵会把原点保持在原点,同时改变三条基向量的方向和长度:单位立方体一般会变成倾斜的平行六面体。

RGB 空间变形器

CCM 不只是改变一颗颜色,它会把整个 RGB 立方体一起拉伸、倾斜。切换裁剪,观察形状如何被削平。

变换
KRGYBMCW

几何意义

非对角项把一个输入轴分配到多个输出轴,立方体因而倾斜。

当前显示未裁剪坐标;越出标准立方体并不代表计算错误。

这个几何视角解释了三个事实:

  1. CCM 是全局线性变换,相同矩阵作用于所有像素;
  2. 输出可能小于 0 或大于 1,因为变换后的形状可以越出显示立方体;
  3. 一旦立刻裁剪,空间外的许多不同颜色会被压到同一表面,后续操作无法区分它们。

因此,图像处理中间阶段通常使用浮点数并保留越界值,等明确进入目标色域或显示阶段时再决定 gamut mapping、色调压缩或裁剪策略。CSS 色块为了显示必须裁剪,但它不是矩阵的真实数值范围。

一张色卡怎样求出 CCM#

假设拍摄色卡后得到 NN 个相机测量颜色 cic_i,同时知道目标空间中的参考颜色 tit_i。希望找到矩阵 MM,让所有色块的预测尽量接近目标:

minimizeM i=1NMciti22\underset{M}{\operatorname{minimize}}\ \sum_{i=1}^{N}\|Mc_i-t_i\|_2^2

这就是线性最小二乘。把测量颜色与目标颜色都排成“一行一个色块”的数组,用 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.T

np.linalg.lstsq 比手写 (XTX)1XTY(X^TX)^{-1}X^TY 更稳健,因为后者显式形成正规方程,会放大病态数据带来的数值问题。

只用三个线性独立色块,理论上就能解出九个未知数。但那相当于让矩阵精确穿过三组观测:一点测量噪声、反光或曝光误差都可能被写进矩阵。使用更多覆盖不同色相和明度的色块,才能让误差在整体上折中。

色卡拟合实验室

选择训练色块数量,并加入测量噪声或异常点。比较训练误差与从未参与拟合的验证误差。

训练色块
训练
训练
训练
训练
训练
训练
验证
验证
验证
验证
验证
验证
训练平均误差0.0125
验证平均误差0.0212
矩阵最大系数1.32

每格左半是目标色,右半是拟合输出。只有三个训练色块时,矩阵可能精确穿过它们,却在其他颜色上表现很差;异常点则会把全局解向错误方向拉动。

加权、正则化与约束#

普通最小二乘默认每个 RGB 数值误差同样重要。实际调校可能给中性色、肤色或低噪声色块更高权重;也可能加入正则项,避免矩阵系数为了追逐噪声变得过大:

minimizeM iwiMciti2+λMI2\underset{M}{\operatorname{minimize}}\ \sum_i w_i\|Mc_i-t_i\|^2+\lambda\|M-I\|^2

有时还会约束行和,使某个白点保持中性。这些方法不是免费提升:权重和约束表达的是产品目标,选错同样会牺牲其他颜色。教学阶段应先把未约束最小二乘、数据清洗和验证集做对。

为什么训练误差很小,真实照片仍然不好看#

色卡只覆盖有限的反射光谱和颜色区域。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,却擅长不同的问题:

方法数学形式通道是否耦合典型任务
白平衡对角 3×33\times3 矩阵恢复中性、适配光源
CCM一般 3×33\times3 矩阵是,线性相机原色到目标原色
3D LUT三维离散表与插值是,非线性复杂校正、色域映射、风格

CCM 的九个系数紧凑、快速、易于分析,但它不能选择性地“只改青色而不动别处”,也无法表达弯曲的色调响应。3D LUT 更自由,却依赖采样密度、插值和明确的输入定义域。工程上常先用 WB 与 CCM 完成物理上可解释的基础校正,再用曲线或 LUT 处理非线性目标。

一份从简单到复杂的调试清单#

遇到颜色不对时,不要先盯着真实照片猜参数。按下面顺序缩小问题:

  1. 单位矩阵测试: 输出必须逐值等于输入;
  2. 纯色测试: 纯 R、G、B 的输出应分别等于矩阵的三列;
  3. 灰阶测试: 观察行和是否让中性色保持中性;
  4. 确认数据域: 输入是否减过黑电平、做过 WB、仍是线性 RGB;
  5. 确认约定: RGB 还是 BGR,行向量还是列向量,配置文件是否已经转置;
  6. 关闭裁剪: 先检查真实的负值和越界值,再定位是谁负责最终映射;
  7. 分离训练与验证: 不要只汇报拟合所用色块的误差;
  8. 跨场景检查: 至少检查高光、暗部、高饱和颜色和不同光源。

CCM 的公式很短,难点却不在矩阵乘法本身,而在于知道九个数字的输入是什么、目标是什么,以及中间结果何时仍值得保留。把这些边界说清楚,矩阵就不再是一盒神秘参数:它只是把相机的三根颜色坐标轴,重新对齐到我们希望使用的颜色空间。

CCM 是怎么校正颜色的?从 3×3 矩阵看懂 RGB 通道混合
https://cloudsir.top/posts/how-ccm-corrects-color/
作者
CloudSir
发布于
2026-08-24
许可协议
CC BY-NC-SA 4.0
Debayer 是怎么补出颜色的?从一张马赛克走进完整 RGB 图像
白平衡是怎么校正光源颜色的?从 Bayer 域增益到 RGB 域增益