4. 色彩处理:白平衡(AWB)、色彩校正矩阵(CCM)、Gamma校正、色调映射(Tone Mapping)
色彩处理,说白了就是让摄像头看到的颜色,跟你人眼看到的颜色保持一致。这事儿听起来简单,做起来可一点都不轻松。我做了这么多年图像调优,敢说色彩是ISP pipeline里最“玄学”的部分——因为每个人的眼睛对颜色的喜好都不一样。
这一章我们拆成四个模块来讲:白平衡、色彩校正矩阵、Gamma校正、色调映射。它们是一条完整的链路,缺一个都不行。
4.1 白平衡(AWB)——让白色回归白色
白平衡要解决什么问题?很简单:不同光源下,白色物体拍出来应该是白色。日光下是白的,白炽灯下也应该是白的。但传感器没有这个“色温恒常性”,它只会老老实实记录光谱能量分布。
我记得第一次调AWB是在一个室内混合光源的场景——左边是窗户进来的日光,右边是暖色台灯。结果画面一半偏蓝一半偏黄,怎么调都调不匀。后来才明白,全局白平衡在这种场景下根本不够用。
4.1.1 色温与增益计算
AWB的核心就是算三个通道的增益:R_gain、G_gain、B_gain。最经典的方法是“灰世界假设”——认为场景中所有颜色的平均是灰色。如果平均偏红,就压低R通道;偏蓝就压低B通道。
| 光源类型 | 色温(K) | R/G比例 | B/G比例 |
|---|---|---|---|
| 白炽灯 | 2800 | 1.8 | 0.6 |
| 日光 | 5500 | 1.0 | 1.0 |
| 阴天 | 6500 | 0.8 | 1.4 |
| 荧光灯 | 4000 | 1.3 | 0.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;
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, a33 | 1.2 ~ 2.0 | 主对角线,增强本通道 |
| a12, a13, a21, a23, a31, a32 | -0.5 ~ 0.0 | 交叉项,消除通道串扰 |
| 行和 | 0.95 ~ 1.05 | 亮度守恒约束 |
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.1 | 4.5 | 0.0 |
| 0.1 ~ 0.2 | 2.8 | 0.17 |
| 0.2 ~ 0.4 | 1.6 | 0.41 |
| 0.4 ~ 0.6 | 1.0 | 0.65 |
| 0.6 ~ 0.8 | 0.7 | 0.83 |
| 0.8 ~ 1.0 | 0.5 | 0.99 |
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%的场景了。