4. 色彩处理:白平衡(AWB)、色彩校正矩阵(CCM)、Gamma校正、色调映射(Tone Mapping)

色彩处理,说白了就是让摄像头看到的颜色,跟你人眼看到的颜色保持一致。这事儿听起来简单,做起来可一点都不轻松。我做了这么多年图像调优,敢说色彩是ISP pipeline里最“玄学”的部分——因为每个人的眼睛对颜色的喜好都不一样。

这一章我们拆成四个模块来讲:白平衡、色彩校正矩阵、Gamma校正、色调映射。它们是一条完整的链路,缺一个都不行。

核心观点:色彩处理不是“调得越准越好”,而是“调得越符合人眼感知越好”。硬件实现时,精度和面积永远是博弈。
色彩处理流水线 RAW输入 Bayer格式 AWB 白平衡 CCM 色彩校正 Gamma Gamma校正 Tone Mapping 色调映射 RGB/YUV输出

4.1 白平衡(AWB)——让白色回归白色

白平衡要解决什么问题?很简单:不同光源下,白色物体拍出来应该是白色。日光下是白的,白炽灯下也应该是白的。但传感器没有这个“色温恒常性”,它只会老老实实记录光谱能量分布。

我记得第一次调AWB是在一个室内混合光源的场景——左边是窗户进来的日光,右边是暖色台灯。结果画面一半偏蓝一半偏黄,怎么调都调不匀。后来才明白,全局白平衡在这种场景下根本不够用。

4.1.1 色温与增益计算

AWB的核心就是算三个通道的增益:R_gain、G_gain、B_gain。最经典的方法是“灰世界假设”——认为场景中所有颜色的平均是灰色。如果平均偏红,就压低R通道;偏蓝就压低B通道。

光源类型色温(K)R/G比例B/G比例
白炽灯28001.80.6
日光55001.01.0
阴天65000.81.4
荧光灯40001.30.9
我的经验:不要只依赖灰世界假设。当场景有大面积单色物体(比如绿草地、红墙)时,灰世界会完全失效。我习惯加一个“色温统计直方图”做辅助判断,把极端颜色剔除后再算增益。

4.1.2 硬件实现要点

RTL里做AWB,最头疼的是除法。R_gain = G_avg / R_avg,这个除法在硬件里很贵。我建议用查找表+线性插值代替除法器,面积能省30%以上。

// 伪代码:AWB增益计算(硬件友好版)
// 输入:R_avg, G_avg, B_avg (16bit)
// 输出:R_gain, B_gain (Q8.8格式)

// 用查找表实现 1/x
lut_recip[G_avg >> 8] = 2^16 / (G_avg >> 8);
recip_G = lut_recip[G_avg >> 8];

R_gain = (R_avg * recip_G) >> 8;
B_gain = (B_avg * recip_G) >> 8;

// 限幅:防止增益过大导致噪声放大
if (R_gain > 4.0) R_gain = 4.0;
if (B_gain > 4.0) B_gain = 4.0;
注意:增益不能无限大。我曾经遇到一个极端暗光场景,B_gain算出来到8.0,结果蓝色通道噪声被放大了8倍,画面全是蓝噪点。后来我加了增益上限4.0,并且对高增益场景做额外的降噪处理。

4.2 色彩校正矩阵(CCM)——把颜色“掰”回来

AWB做完之后,颜色其实还是不准的。为什么?因为传感器的色彩滤镜(CFA)的响应曲线跟人眼视锥细胞不一样。CCM就是用一个3x3矩阵,把传感器RGB空间映射到标准色彩空间(比如sRGB)。

说白了,CCM就是给颜色做一次“坐标系变换”。

4.2.1 矩阵设计与约束

CCM矩阵长这样:

[R_out]   [a11 a12 a13]   [R_in]
[G_out] = [a21 a22 a23] * [G_in]
[B_out]   [a31 a32 a33]   [B_in]

设计CCM时有个关键约束:亮度守恒。也就是说,矩阵每一行的系数之和应该接近1.0,否则会改变亮度。我见过有人把系数调得很大,结果画面亮度忽明忽暗,那就是没遵守这个规则。

系数典型值作用
a11, a22, a331.2 ~ 2.0主对角线,增强本通道
a12, a13, a21, a23, a31, a32-0.5 ~ 0.0交叉项,消除通道串扰
行和0.95 ~ 1.05亮度守恒约束
避坑指南:我曾经在调试一个手机摄像头时,发现红色饱和度过高。检查CCM发现a11设到了2.5,红色通道被过度放大。把a11降到1.5,同时增加a12和a13的负值来抑制串扰,红色就自然了。

4.2.2 RTL实现:乘法器复用

3x3矩阵需要9个乘法器,但实际场景中,很多系数是0或者固定值。我建议用时分复用的方式,只做3个乘法器,分3个周期算完一行。这样面积能省60%。

// RTL伪代码:CCM时分复用
// 每个周期算一个通道
always @(posedge clk) begin
  case (state)
    2'b00: begin // 算R_out
      mult1 <= R_in * a11;
      mult2 <= G_in * a12;
      mult3 <= B_in * a13;
      R_out <= mult1 + mult2 + mult3;
    end
    2'b01: begin // 算G_out
      mult1 <= R_in * a21;
      mult2 <= G_in * a22;
      mult3 <= B_in * a23;
      G_out <= mult1 + mult2 + mult3;
    end
    2'b10: begin // 算B_out
      mult1 <= R_in * a31;
      mult2 <= G_in * a32;
      mult3 <= B_in * a33;
      B_out <= mult1 + mult2 + mult3;
    end
  endcase
end

4.3 Gamma校正——为了显示器的“非线性”

Gamma校正可能是这四个模块里最“反直觉”的一个。人眼对暗部的变化比亮部敏感得多,但传感器是线性的——它忠实记录光子数量。如果不做Gamma校正,暗部细节会全部丢失。

标准Gamma曲线是:V_out = V_in ^ (1/γ),其中γ通常取2.2。

4.3.1 分段线性近似

在RTL里做幂运算太贵了。我习惯用分段线性近似——把Gamma曲线分成8~16段,每段用一条直线拟合。这样只需要一个查找表和几个加法器。

输入范围斜率截距
0 ~ 0.14.50.0
0.1 ~ 0.22.80.17
0.2 ~ 0.41.60.41
0.4 ~ 0.61.00.65
0.6 ~ 0.80.70.83
0.8 ~ 1.00.50.99
我的习惯:分段点不要等间距分。暗部斜率变化大,分段要密一些;亮部变化小,分段可以粗一些。这样用8段就能达到16段等间距的精度。

4.4 色调映射(Tone Mapping)——把HDR塞进SDR

现在的传感器动态范围越来越高,12bit、14bit甚至20bit都有。但显示器只有8bit(0~255)。色调映射就是把宽动态范围的图像,压缩到窄动态范围的同时,保留视觉细节。

嗯,这里有个矛盾:压缩动态范围必然会损失对比度。好的色调映射算法,就是在“压缩”和“保细节”之间找平衡。

4.4.1 全局 vs 局部色调映射

  • 全局色调映射:整张图用同一个映射曲线。简单、硬件开销小,但亮部和暗部细节容易一起丢失。
  • 局部色调映射:根据每个像素周围的亮度做自适应映射。效果好,但硬件复杂度高,需要做guided filter或者bilateral filter。

我个人建议:在RTL里优先做全局色调映射,配合一个简单的自适应亮度统计模块。根据场景的平均亮度动态调整映射曲线,效果能接近局部映射的80%,但面积只有后者的1/5。

// 全局色调映射:S型曲线
// 输入:HDR像素值 (14bit)
// 输出:SDR像素值 (8bit)

// 先算场景平均亮度
avg_lum = compute_average_luminance(frame);

// 根据平均亮度调整曲线参数
if (avg_lum < 0.3) begin
  // 暗场景:提亮暗部
  curve_param = 0.4;
end else if (avg_lum > 0.7) begin
  // 亮场景:压高光
  curve_param = 0.6;
end else begin
  curve_param = 0.5;
end

// S型映射
sdr_out = 255 * (hdr_in / max_hdr) ^ curve_param;
注意:色调映射会改变颜色饱和度。我遇到过一个问题:做完色调映射后,红色变得特别“艳”,像假的一样。后来发现是因为映射曲线对亮部压缩太多,导致颜色比例失衡。解决方案是在色调映射之后,加一个饱和度补偿模块。

4.5 本章小结

色彩处理这条链路,每一步都在做“妥协”。AWB在色温估计和场景适应性之间妥协,CCM在色彩准确度和噪声之间妥协,Gamma在校正精度和硬件面积之间妥协,色调映射在动态范围和细节保留之间妥协。

做RTL实现时,我的建议是:先跑通功能,再优化面积。很多同学一上来就想用最复杂的算法,结果面积超标、时序跑不过。其实对于大部分消费级产品,分段线性+查找表+时分复用这套组合拳,已经能覆盖90%的场景了。