4、色彩校正基础:CCM推导、最小二乘法与肤色保护

色彩校正矩阵(CCM)这东西,说白了就是把传感器拍到的原始RGB值,映射到人眼真正看到的颜色空间。我刚开始做ISP调试那会儿,总觉得这步很简单——不就是个3x3矩阵嘛。后来被一个偏色严重的案子狠狠教育了一顿,才发现里面门道不少。

今天咱们就把CCM的推导、求解和肤色保护策略,一次性讲透。

4.1 为什么需要色彩校正矩阵?

先看一个事实:CMOS传感器的光谱响应曲线,和人眼视锥细胞的响应曲线,长得完全不一样。不信你拿相机拍一张标准色卡,直接输出的RAW图,颜色一定是偏的——红色不够正,蓝色可能发紫。

CCM的作用就是做这个线性变换:

[R']   [a11 a12 a13] [R]
[G'] = [a21 a22 a23] [G]
[B']   [a31 a32 a33] [B]

其中R、G、B是传感器原始值,R'、G'、B'是校正后的目标值。矩阵里的9个系数,就是我们要解的。

核心要点:CCM是一个线性变换,它假设传感器响应到人眼感知的映射是线性的。这个假设在低照度下会失效,但那是后话了。

4.2 CCM推导:从色卡到矩阵

推导过程其实不复杂。我们拿一张标准24色卡(比如X-Rite ColorChecker),在受控光源下拍摄。然后测量两个东西:

  1. 传感器输出值:每个色块在RAW图上的RGB均值
  2. 目标参考值:同一色块在标准光源下的Lab或sRGB值

有了这24组对应关系,问题就变成了:找一个3x3矩阵,让传感器值经过变换后,尽可能接近目标值。

写成数学形式:

对于第i个色块:
  R'_i = a11*R_i + a12*G_i + a13*B_i
  G'_i = a21*R_i + a22*G_i + a23*B_i
  B'_i = a31*R_i + a32*G_i + a33*B_i

我们希望 R'_i ≈ R_ref_i, G'_i ≈ G_ref_i, B'_i ≈ B_ref_i

嗯,这里要注意:我们通常把RGB值归一化到[0,1]区间再算,不然数值量级差异会影响求解精度。

4.3 最小二乘法求解:让误差最小

9个未知数,24组方程,这是个超定系统。最常用的解法就是最小二乘法。

我习惯把问题拆成三个独立的子问题——分别对R、G、B通道求解。以R通道为例:

对于24个色块,构建矩阵方程:
  [R_ref_1]   [R_1 G_1 B_1] [a11]
  [R_ref_2] = [R_2 G_2 B_2] [a12]
  [  ...  ]   [  ...  ... ] [a13]
  [R_ref_24]  [R_24 G_24 B_24]

简写为:Y = X * A

最小二乘解就是:

A = (X^T * X)^{-1} * X^T * Y

代码实现其实很简洁:

import numpy as np

def solve_ccm(sensor_rgb, ref_rgb):
    """
    sensor_rgb: (N, 3) 传感器原始值
    ref_rgb:    (N, 3) 目标参考值
    返回: (3, 3) CCM矩阵
    """
    ccm = np.zeros((3, 3))
    for ch in range(3):
        Y = ref_rgb[:, ch]          # 目标通道值
        X = sensor_rgb               # 传感器RGB
        A, _, _, _ = np.linalg.lstsq(X, Y, rcond=None)
        ccm[ch, :] = A
    return ccm
我的经验:直接用最小二乘解出来的CCM,在色卡上的误差可能很小,但实际场景中经常出问题。为什么?因为色卡只有24个点,覆盖不了真实世界的所有颜色。尤其是肤色、天空、草地这些关键色,很容易跑偏。

4.4 肤色保护策略:为什么需要?怎么做?

肤色保护,说白了就是:在整体色彩校正的同时,保证人脸肤色不偏绿、不偏黄、不偏紫。

我在一个手机项目上吃过亏。当时用标准色卡调出来的CCM,拍风景和静物都很好,但一拍人像,脸色发青。客户直接打回重调。后来我加了肤色保护,才过。

常用的肤色保护策略有三种:

策略 原理 优点 缺点
加权最小二乘 给肤色色块更高的权重 实现简单,效果可控 需要手动标定肤色区域
色域约束 限制CCM输出在肤色色域内 不会出现离谱偏色 可能牺牲整体精度
后处理补偿 先做全局CCM,再对肤色区域做二次校正 灵活性高 计算量大,需要肤色检测

我个人最常用的是加权最小二乘。做法很简单:

  1. 在24色卡中,选出肤色相关的色块(比如第1、2、3、4块)
  2. 给这些色块分配更高的权重,比如w=10,其他色块w=1
  3. 求解加权最小二乘:A = (X^T * W * X)^{-1} * X^T * W * Y

代码实现:

def solve_ccm_weighted(sensor_rgb, ref_rgb, weights):
    """
    weights: (N,) 每个色块的权重
    """
    ccm = np.zeros((3, 3))
    W = np.diag(weights)
    for ch in range(3):
        Y = ref_rgb[:, ch]
        X = sensor_rgb
        XtWX = X.T @ W @ X
        XtWY = X.T @ W @ Y
        A = np.linalg.solve(XtWX, XtWY)
        ccm[ch, :] = A
    return ccm
注意:权重不能设得太大。我曾经试过给肤色权重100,结果其他颜色全部偏掉,拍出来的红色变成了橙色。权重一般控制在5-20之间比较合理。

4.5 知识结构总览

下面这张图,把CCM的完整流程串起来了:

色彩校正矩阵(CCM)知识结构 传感器RAW RGB 标准24色卡 + 参考值 最小二乘法求解CCM Y = X·A → A = (XᵀX)⁻¹XᵀY 加权最小二乘 色域约束 后处理补偿 最终CCM矩阵 (3×3) 验证:色卡ΔE + 肤色主观评价

4.6 验证与调优

CCM算出来之后,别急着用。先做两件事:

  • 客观指标:计算24色卡的平均ΔE和最大ΔE。一般要求平均ΔE < 3,最大ΔE < 6
  • 主观评价:拍几张真实场景——人像、风景、食物。重点看肤色、草地、天空

如果肤色偏了,回到加权最小二乘那步,调整权重再算。我一般迭代3-5轮就能收敛。

一个小技巧:在验证阶段,把校正前后的图像并排显示,用分屏对比。人眼对肤色变化非常敏感,有时候ΔE很小但看着就是不舒服。这时候相信你的眼睛,别死磕指标。