4. 白平衡算法:灰度世界假设、完美反射假设、色温估计、AWB调试实战

白平衡这东西,说白了就是让白色物体在照片里看起来是白的。你想想看,同样一张白纸,在暖色灯光下发黄,在阴天发蓝。人眼会自动校正,但传感器不会。所以我们需要算法来帮它「看」出真实颜色。

我入行那会儿,第一个被分配的任务就是调白平衡。当时觉得不就是乘几个系数嘛,后来才发现,这里面的坑比想象中多得多。

4.1 白平衡的核心问题

先搞清楚一个问题:为什么需要白平衡?

因为光源色温不同。色温低的光偏红黄(比如蜡烛),色温高的光偏蓝紫(比如阴天)。传感器记录的是反射光,不是物体本身的颜色。如果不做校正,整张图的色调就跟着光源跑了。

白平衡的目标,就是估计出当前光源的色温,然后对RGB三个通道分别做增益补偿,让中性色(灰、白)恢复中性。

核心公式:
R' = R × G_r
G' = G × G_g
B' = B × G_b
其中 G_r、G_g、G_b 是各通道增益,通常 G_g 固定为 1.0,G_r 和 G_b 根据色温调整。

4.2 灰度世界假设

这是最经典的方法,也是我最早接触的算法。

假设条件:一幅色彩丰富的图像中,所有像素的RGB平均值应该趋近于灰色(R=G=B)。

实现步骤:

  1. 计算整幅图像R、G、B三个通道的均值:R_avg、G_avg、B_avg
  2. 计算灰色参考值:Gray = (R_avg + G_avg + B_avg) / 3
  3. 计算各通道增益:G_r = Gray / R_avg,G_g = Gray / G_avg,G_b = Gray / B_avg
  4. 对每个像素应用增益
// 灰度世界假设实现示例
void whiteBalanceGrayWorld(uint8_t* img, int width, int height) {
    double sumR = 0, sumG = 0, sumB = 0;
    int total = width * height;
    
    for (int i = 0; i < total; i++) {
        sumR += img[i * 3 + 0];
        sumG += img[i * 3 + 1];
        sumB += img[i * 3 + 2];
    }
    
    double avgR = sumR / total;
    double avgG = sumG / total;
    double avgB = sumB / total;
    double gray = (avgR + avgG + avgB) / 3.0;
    
    double gainR = gray / avgR;
    double gainG = gray / avgG;
    double gainB = gray / avgB;
    
    // 应用增益
    for (int i = 0; i < total; i++) {
        img[i * 3 + 0] = clamp(img[i * 3 + 0] * gainR);
        img[i * 3 + 1] = clamp(img[i * 3 + 1] * gainG);
        img[i * 3 + 2] = clamp(img[i * 3 + 2] * gainB);
    }
}
注意:灰度世界假设在以下场景会失效:
- 画面被单一颜色主导(比如大片草地、蓝天)
- 场景本身就不包含丰富的色彩信息
- 光源极端的场景(纯色灯光)

我曾经在一个项目中遇到客户投诉,说我们的摄像头拍出来的草地偏紫。查了半天,发现是灰度世界假设把大面积的绿色当成了偏色,强行往灰色拉,结果绿色通道被压下去,红色和蓝色通道被抬起来,草地就变紫了。嗯,这就是典型的「假设不成立」案例。

4.3 完美反射假设

这个方法换个思路——它不找平均,而是找最亮的点。

假设条件:图像中最亮的像素点应该是纯白色的(R=G=B=255)。这些点通常对应高光反射区域。

实现步骤:

  1. 统计所有像素的亮度值,找到最亮的N%像素(通常取1%~5%)
  2. 计算这些像素的R、G、B均值
  3. 以这些均值为基准,计算各通道增益
// 完美反射假设实现示例
void whiteBalancePerfectReflect(uint8_t* img, int width, int height, float percent = 0.01) {
    int total = width * height;
    int sampleCount = (int)(total * percent);
    
    // 按亮度排序,取最亮的像素
    // 实际工程中会用直方图加速
    // 这里简化处理
    
    double sumR = 0, sumG = 0, sumB = 0;
    // 假设已经找到最亮的 sampleCount 个像素
    // 计算它们的均值...
    
    double maxVal = 255.0;
    double gainR = maxVal / (sumR / sampleCount);
    double gainG = maxVal / (sumG / sampleCount);
    double gainB = maxVal / (sumB / sampleCount);
    
    // 应用增益
}
经验之谈:完美反射假设对高光区域的质量非常敏感。如果场景中没有真正的白色高光(比如阴天、室内),效果会大打折扣。我一般会结合灰度世界假设一起用,取两者的加权平均。

4.4 色温估计方法

前面两种方法都是「盲估计」,不关心光源到底是什么。色温估计则更进了一步——它试图直接算出当前光源的色温值。

常用方法:

方法 原理 优点 缺点
色温曲线法 在色温坐标系中预存R/G、B/G曲线 速度快,适合硬件实现 需要标定,泛化性差
色度图法 将RGB映射到色度坐标,找最近色温 精度高 计算量大
神经网络法 用CNN直接回归色温值 适应性强 需要大量训练数据

我个人比较喜欢色温曲线法,因为它够快。在嵌入式平台上,每帧只有几毫秒的处理时间,你不可能跑个神经网络。色温曲线法只需要查表和插值,几微秒就搞定了。

具体做法是:先在不同色温下拍摄灰卡,记录R/G和B/G的比值。然后把这些比值拟合成曲线。实际运行时,计算当前图像的R/G和B/G,在曲线上找到对应的色温值。

// 色温曲线法示意
typedef struct {
    float colorTemp;  // 色温
    float rgRatio;    // R/G
    float bgRatio;    // B/G
} ColorTempPoint;

// 预标定的色温曲线
ColorTempPoint ctCurve[] = {
    {2500, 2.1, 1.8},
    {4000, 1.5, 1.3},
    {5500, 1.0, 1.0},
    {6500, 0.8, 0.9},
    {8000, 0.6, 0.8}
};

float estimateColorTemp(float rg, float bg) {
    // 在曲线上找最近的点
    // 插值得到色温值
    // 返回色温
}

4.5 AWB调试实战

理论说完了,来点实际的。AWB调试是我工作中花时间最多的地方之一。

调试流程:

  1. 采集数据:在不同光源下拍摄灰卡和典型场景
  2. 标定曲线:建立R/G、B/G与色温的映射关系
  3. 设置阈值:确定哪些像素参与统计(排除过暗、过亮、饱和区域)
  4. 调整增益:微调各通道增益,确保中性色准确
  5. 验证效果:在多种场景下测试,检查偏色情况
避坑指南:
- 不要只依赖一种算法,建议灰度世界+完美反射+色温估计三者融合
- 统计区域要避开纯色大块(天空、墙壁)
- 增益不要超过2.0,否则噪声会被放大到不可接受
- 注意光源切换时的平滑过渡,避免色温突变

我曾经遇到一个很头疼的问题:在超市的冷柜前,摄像头拍出来的肉总是偏蓝。冷柜的LED灯光色温很高,加上肉本身是红色的,灰度世界假设一算,红色通道被压得很低,肉就变成灰蓝色了。后来我加了一个「肤色保护」逻辑——如果检测到肤色区域,就降低该区域的校正强度。效果好了很多。

还有一个经验:AWB的收敛速度要把握好。太快了,光源切换时画面会闪烁;太慢了,用户都走到下一个场景了,色温还没跟上。我一般把收敛时间控制在0.5~1秒,既不会闪烁,也不会让人觉得迟钝。

4.6 本章小结

白平衡说难不难,说简单也不简单。灰度世界假设和完美反射假设是基础,色温估计是进阶。实际工程中,没有哪个算法是万能的,关键是要根据场景做融合和适配。

嗯,调试AWB需要耐心。你可能调了一整天,觉得效果已经很好了,结果拿到户外一试,又偏色了。别灰心,这是正常的。多积累一些场景数据,慢慢就能找到感觉。

白平衡算法知识体系 白平衡算法 灰度世界假设 完美反射假设 色温估计 RGB均值 灰色参考 最亮像素 高光统计 色温曲线 色度图 调试要点 • 三种算法融合使用,避免单一假设失效 • 统计区域排除纯色大块和过暗/过亮区域 • 增益限制在2.0以内,注意噪声控制