4. AWB 算法实现:灰度世界、白点检测与色温曲线

好,咱们进入正题。自动白平衡(AWB)说白了,就是让相机在各种光线下都能把白色拍成白色。你想想看,在钨丝灯下拍的白纸偏黄,在阴天拍的白纸偏蓝,这肯定不行。AWB 要做的,就是把这些偏色给拉回来。

这一章,我带你手撸三种最经典的 AWB 算法。它们分别是:灰度世界、白点检测、色温曲线拟合。这三种思路完全不同,但实际工程中经常组合使用。我个人习惯先跑灰度世界做粗调,再用白点检测做精调,最后用色温曲线兜底。

核心观点:没有万能的 AWB 算法。场景一变,算法就得跟着变。灰度世界适合色彩丰富的场景,白点检测适合有白色参照物的场景,色温曲线适合已知光源类型的场景。

4.1 灰度世界算法(Gray World)

灰度世界是最简单、最直觉的 AWB 方法。它的假设是:一张色彩丰富的照片,所有像素的 R、G、B 平均值应该相等,也就是趋向于灰色。

嗯,这个假设在大多数自然场景下是成立的。但我在项目中遇到过一个问题——如果场景里大面积都是同一种颜色,比如一片蓝天或一片绿草地,灰度世界就会翻车。它会强行把蓝色或绿色拉成灰色,结果整个画面都偏色。

算法步骤其实就三步:

  1. 计算整张图像的 R、G、B 通道均值:R_avg、G_avg、B_avg
  2. 计算三个通道的增益:R_gain = G_avg / R_avg、B_gain = G_avg / B_avg(G 通道保持不变)
  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, BGR顺序
    """
    # 转成float防止溢出
    img_float = img.astype(np.float32)
    
    # 计算各通道均值
    b_avg = np.mean(img_float[:, :, 0])
    g_avg = np.mean(img_float[:, :, 1])
    r_avg = np.mean(img_float[:, :, 2])
    
    # 计算增益
    r_gain = g_avg / r_avg
    b_gain = g_avg / b_avg
    
    # 应用增益
    img_float[:, :, 2] *= r_gain  # R通道
    img_float[:, :, 0] *= b_gain  # B通道
    
    # 裁剪到[0, 255]并转回uint8
    img_out = np.clip(img_float, 0, 255).astype(np.uint8)
    return img_out
小技巧:实际工程中,我不会直接用全图均值。我会先剔除最亮和最暗的 5% 像素,避免高光过曝和暗部噪声干扰均值计算。这个操作叫「截尾均值」,能显著提升鲁棒性。

4.2 白点检测算法(White Point Detection)

灰度世界假设所有颜色平均后是灰色,但白点检测的思路完全不同。它认为:图像里总有一些像素是真正的白色或灰色,只要找到这些白点,用它们的颜色信息就能算出正确的白平衡增益。

怎么找白点?我常用的方法是:在 YCbCr 色彩空间里,找那些色度分量(Cb、Cr)接近 0 的像素。说白了,就是找那些看起来不带颜色的像素点。

我曾经踩过一个坑:直接用 RGB 空间的阈值来检测白点,结果经常把高光过曝区域误判成白点。后来我改用 YCbCr 空间,效果好了很多。因为 YCbCr 把亮度和色度分开了,更容易控制检测条件。

白点检测的流程如下:

步骤 操作 说明
1 RGB → YCbCr 转换色彩空间,分离亮度与色度
2 亮度筛选 只保留 Y 在 [Y_low, Y_high] 区间的像素
3 色度筛选 只保留 |Cb| < T_cb 且 |Cr| < T_cr 的像素
4 计算白点均值 对筛选出的像素计算 R、G、B 均值
5 计算增益 与灰度世界类似,用白点均值计算增益

代码实现:

def white_point_awb(img, y_low=50, y_high=240, cb_thresh=15, cr_thresh=15):
    """
    白点检测白平衡
    """
    # 转换到YCbCr
    img_ycbcr = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb)
    y, cb, cr = cv2.split(img_ycbcr)
    
    # 创建白点掩码
    mask = (y > y_low) & (y < y_high) & \
           (np.abs(cb - 128) < cb_thresh) & \
           (np.abs(cr - 128) < cr_thresh)
    
    # 如果没有检测到白点,回退到灰度世界
    if np.sum(mask) < 100:
        return gray_world_awb(img)
    
    # 提取白点像素
    white_pixels = img[mask]
    
    # 计算白点均值
    b_mean = np.mean(white_pixels[:, 0])
    g_mean = np.mean(white_pixels[:, 1])
    r_mean = np.mean(white_pixels[:, 2])
    
    # 计算增益
    r_gain = g_mean / r_mean
    b_gain = g_mean / b_mean
    
    # 应用增益
    img_float = img.astype(np.float32)
    img_float[:, :, 2] *= r_gain
    img_float[:, :, 0] *= b_gain
    
    return np.clip(img_float, 0, 255).astype(np.uint8)
注意:白点检测的阈值非常敏感。阈值设得太宽,会把非白点也包含进来;设得太窄,可能一个白点都找不到。我建议在调试阶段,把检测到的白点用红色标记出来可视化,这样一眼就能看出阈值是否合理。

4.3 色温曲线拟合(Color Temperature Curve Fitting)

前面两种方法都是基于图像内容的,但色温曲线拟合走的是另一条路——它基于先验知识。说白了,就是提前测量好各种光源下 R/G 和 B/G 的比值,然后拟合出一条曲线。实际使用时,根据图像算出的 R/G、B/G 比值,在曲线上找到对应的色温,再用这个色温去查表得到增益。

我当年在手机 ISP 团队时,就负责搭建这套标定系统。我们会在暗室里用 D65(6500K)、D50(5000K)、A 光源(2856K)等标准光源拍摄灰卡,记录下不同色温下的 R/G、B/G 比值。然后把这些数据点拟合成一条平滑曲线。

色温曲线拟合的典型数据长这样:

色温 (K) R/G B/G 光源类型
2800 1.85 0.65 白炽灯
4000 1.45 0.85 荧光灯
5500 1.20 1.05 日光
6500 1.10 1.15 阴天
8000 1.00 1.30 阴影

拟合曲线后,算法流程就变成了:

  1. 从图像中提取 R/G、B/G 比值(可以用灰度世界或白点检测的结果)
  2. 在曲线上找到最接近的色温点
  3. 用该色温对应的增益值做白平衡校正

下面这张 SVG 图展示了三种 AWB 算法的核心逻辑和它们之间的关系:

AWB 三种算法核心逻辑对比 灰度世界算法 假设:色彩丰富的场景 RGB均值相等 → 灰色 计算增益:G_avg / R_avg 白点检测算法 假设:存在白色参照物 YCbCr空间筛选白点 白点均值计算增益 色温曲线拟合 假设:已知光源先验 R/G vs B/G 曲线拟合 查表得到色温与增益 输出:R_gain, B_gain 校正后图像 实际工程中常组合使用:灰度世界做粗调 → 白点检测做精调 → 色温曲线做兜底

4.4 三种算法的对比与选型建议

讲到这里,你可能要问了:这三种算法到底该用哪个?我的答案是:看场景。

我整理了一个对比表,方便你快速决策:

维度 灰度世界 白点检测 色温曲线
计算量 极低 中等 低(查表)
适用场景 色彩丰富的自然场景 有白色/灰色参照物 已知光源类型
失败场景 单色大面积场景 无白点/低照度 混合光源
鲁棒性 一般 较好 依赖标定质量
实现难度 简单 中等 较复杂(需标定)
我的工程经验:在手机 ISP 里,我们通常把三种算法做成一个级联管道。第一级用灰度世界快速收敛,第二级用白点检测修正偏差,第三级用色温曲线做边界约束。这样既保证了速度,又兼顾了精度。你如果做嵌入式开发,也可以参考这个思路。