第3章:自动白平衡原理:灰度世界、完美反射与色温估计

各位同学,欢迎来到自动白平衡的核心章节。

说实话,我刚开始接触ISP(图像信号处理)时,觉得白平衡不就是调个色温嘛,有什么难的?直到第一次在混合光源场景下翻车——拍出来的照片一半偏蓝一半偏黄,怎么调都救不回来。嗯,从那以后我才老老实实把几个经典假设模型啃透。

这一章,我们就来拆解自动白平衡(AWB)的三大基石:灰度世界假设、完美反射假设,以及色温估计方法。理解了它们,你才算真正入了AWB的门。

3.1 为什么要做白平衡?

人眼有色彩恒常性——不管在日光灯下还是烛光下,我们看白纸还是白的。但CMOS传感器没这个本事,它老老实实记录光谱能量分布。所以,一张在钨丝灯下拍的白纸,如果不做处理,就是黄澄澄的。

白平衡的本质,就是把图像中原本是中性色(灰/白)的像素,映射回R=G=B的状态。说白了,就是找到场景中的“白”,然后算出一个校正增益,把偏色拉回来。

核心公式:
校正后像素 = 原始像素 × 增益矩阵
其中增益矩阵通常是对角阵:
R_gain = G / R_avg, B_gain = G / B_avg

3.2 灰度世界假设(Gray World)

这是最经典、也最暴力的方法。它的假设很简单:一幅色彩丰富的自然图像,所有像素的R、G、B通道平均值应该相等。如果不等,说明有色偏,那就把偏高的通道压下来,偏低的通道拉上去。

3.2.1 算法流程

  1. 计算全图R、G、B通道均值:R_avg, G_avg, B_avg
  2. 计算增益:R_gain = G_avg / R_avg, B_gain = G_avg / B_avg
  3. 对每个像素应用增益:R_out = R_in × R_gain, B_out = B_in × B_gain
import numpy as np

def gray_world_awb(img):
    """灰度世界白平衡"""
    # img: HxWx3, dtype=np.uint8
    img_float = img.astype(np.float32)
    R_avg = np.mean(img_float[:,:,0])
    G_avg = np.mean(img_float[:,:,1])
    B_avg = np.mean(img_float[:,:,2])
    
    R_gain = G_avg / R_avg
    B_gain = G_avg / B_avg
    
    img_float[:,:,0] *= R_gain
    img_float[:,:,2] *= B_gain
    
    return np.clip(img_float, 0, 255).astype(np.uint8)
我的经验:灰度世界在户外自然光下效果不错,但遇到大面积单色场景(比如一片蓝天、一片绿草地)就会翻车。我曾经在一个全是蓝色包装的仓库里测试,结果图像被拉成了红色——因为蓝色通道均值太高,增益把它压得太狠了。

3.3 完美反射假设(Perfect Reflector / White Patch)

这个方法换个思路:它假设图像中最亮的像素应该是纯白的(R=G=B=255)。如果最亮像素偏色,那就用它的偏色程度来校正整幅图。

3.3.1 算法流程

  1. 找到图像中亮度最高的像素(或前1%最亮像素的平均值)
  2. 计算该像素的R、G、B值:R_max, G_max, B_max
  3. 计算增益:R_gain = 255 / R_max, B_gain = 255 / B_max
  4. 应用增益到全图
def perfect_reflector_awb(img, percentile=99):
    """完美反射白平衡"""
    img_float = img.astype(np.float32)
    
    # 取前percentile%最亮像素的平均值
    R_max = np.percentile(img_float[:,:,0], percentile)
    G_max = np.percentile(img_float[:,:,1], percentile)
    B_max = np.percentile(img_float[:,:,2], percentile)
    
    R_gain = 255.0 / R_max
    B_gain = 255.0 / B_max
    
    img_float[:,:,0] *= R_gain
    img_float[:,:,2] *= B_gain
    
    return np.clip(img_float, 0, 255).astype(np.uint8)
注意:完美反射假设对高光区域非常敏感。如果场景中有镜面反射或光源本身(比如灯泡),最亮像素可能不是白色而是光源色,这时校正会完全错误。我建议在实际项目中,先做高光检测,排除掉过曝区域再取最亮像素。

3.4 两种假设的对比

特性 灰度世界 完美反射
假设基础 全图均值相等 最亮像素为白
适用场景 色彩丰富的自然场景 有明确白色参考的场景
失效场景 大面积单色 高光过曝、光源直射
计算复杂度 低(一次全图遍历) 低(需排序或分位数)
鲁棒性 中等 较差(对异常值敏感)

3.5 色温估计方法

前面两种方法都是直接算增益,但实际工程中,我们往往需要先估计场景的色温,再查表或插值得到增益。为什么?因为不同色温下,白点的R/G、B/G比值是固定的,我们可以提前标定好。

3.5.1 基于色温曲线的估计

标准做法是:在D65(6500K)、D50(5000K)、A光源(2856K)等标准色温下,拍摄灰卡,记录R/G和B/G比值。然后拟合出一条色温-比值曲线。实际使用时,从图像中提取灰区像素的R/G、B/G,反查色温。

# 伪代码:色温查表
calibration_table = {
    6500: (1.0, 1.0),   # R_gain, B_gain
    5000: (1.1, 0.9),
    2856: (1.5, 0.6),
}

def estimate_color_temp(rg_ratio, bg_ratio):
    """根据R/G和B/G比值估计色温"""
    # 实际项目中用插值,这里简化
    for ct, (rg_ref, bg_ref) in calibration_table.items():
        if abs(rg_ratio - rg_ref) < 0.05 and abs(bg_ratio - bg_ref) < 0.05:
            return ct
    return None

3.5.2 灰区检测

色温估计的关键是找到图像中的“灰区”——那些原本应该是中性色的像素。怎么找?我常用的方法是:在RGB空间中,筛选出R/G和B/G比值在一定范围内的像素。这些像素大概率是灰色或白色。

灰区判定条件:
|R/G - 1| < threshold 且 |B/G - 1| < threshold
阈值通常取0.1~0.3,视噪声水平调整。

3.6 知识体系总览

下面这张图把本章的核心逻辑串起来了。你可以看到,灰度世界和完美反射是两种独立的假设,而色温估计则是更工程化的方法——它依赖标定数据,但鲁棒性更好。

自动白平衡核心方法 AWB 输入图像 灰度世界假设 完美反射假设 色温估计方法 计算R/G/B均值 提取最亮像素 灰区检测+查表 校正后图像输出 三种方法各有优劣:灰度世界适合丰富场景,完美反射依赖高光,色温估计最稳定但需要标定 实际工程中常将三者融合,或根据场景自适应切换

3.7 实战中的避坑指南

  • 不要迷信单一假设:灰度世界和完美反射都有硬伤。我做过一个项目,在黄昏时分拍海面,灰度世界把整张图拉成了紫色——因为海面大面积蓝色,均值严重偏离。
  • 灰区检测要加亮度约束:太暗的像素噪声大,R/G比值不稳定。我一般只取亮度在[30, 230]范围内的像素做灰区检测。
  • 色温表要覆盖全范围:从2500K到7500K至少标定5个点,否则插值误差会很大。我曾经只标了3个点,结果在荧光灯场景下色温估计偏差了800K,校正后肤色像僵尸。
  • 别忘了G通道:很多初学者只调R和B,但G通道的增益其实也可以微调。虽然G通常作为参考通道,但在某些传感器中,G通道的响应曲线并不平坦。
我的习惯:在实际项目中,我会先用灰度世界做粗调,再用完美反射做细调,最后用色温估计做校验。三个结果取加权平均,权重根据场景动态调整。这个方法虽然笨,但胜在稳定——至少没再翻过车。