4、光源估计方法(中):完美反射算法(White Patch / Max-RGB)原理与实现

上一章我们聊了灰度世界,它假设场景平均是灰的。今天要讲的完美反射算法,思路正好反过来——它认为场景里最亮的那个点,应该是纯白的。

听起来有点绕?别急,我慢慢拆开讲。

4.1 核心思想:最亮的就是白的

完美反射算法,也叫 White Patch 或 Max-RGB。名字很直白:找一块“白色补丁”,用它来校准整张图。

它的假设是:场景中至少存在一个表面,能完全反射入射光的所有波长。换句话说,这个点本身没有颜色,它呈现的颜色就是光源的颜色。

数学表达:

如果场景中有一个纯白表面,那么它反射到相机传感器的 RGB 值,就代表了光源的 RGB 值。

校正时,把每个像素除以这个“最大 RGB”值,就能消除光源影响。

我在项目中遇到过一种情况:拍白色陶瓷餐具时,这个算法效果特别好。因为瓷器表面确实有高光区,那些区域基本就是光源的直接反射。

4.2 算法流程:三步走

实现起来其实很简单,就三步:

  1. 找最大值:遍历图像,找出 R、G、B 三个通道各自的最大值。
  2. 计算增益:用最大值除以目标值(通常是 255 或 1.0),得到每个通道的增益系数。
  3. 应用校正:每个像素乘以对应通道的增益,完成白平衡。

嗯,这里要注意:如果某个通道的最大值是 0,那增益就是无穷大。实际代码里要加个保护,避免除零。

4.3 代码实现:从原理到实战

我习惯用 Python 做快速验证。下面这个版本是最基础的 Max-RGB:

import numpy as np
import cv2

def white_patch_awb(img):
    """
    完美反射白平衡 - 基础版
    img: BGR 格式,uint8 [0, 255]
    """
    # 分离通道
    b, g, r = cv2.split(img)
    
    # 找每个通道的最大值
    max_b = np.max(b)
    max_g = np.max(g)
    max_r = np.max(r)
    
    # 防止除零
    max_b = max_b if max_b > 0 else 1
    max_g = max_g if max_g > 0 else 1
    max_r = max_r if max_r > 0 else 1
    
    # 计算增益(目标值 255)
    gain_b = 255.0 / max_b
    gain_g = 255.0 / max_g
    gain_r = 255.0 / max_r
    
    # 应用增益
    b_bal = np.clip(b * gain_b, 0, 255).astype(np.uint8)
    g_bal = np.clip(g * gain_g, 0, 255).astype(np.uint8)
    r_bal = np.clip(r * gain_r, 0, 255).astype(np.uint8)
    
    return cv2.merge([b_bal, g_bal, r_bal])

这段代码能跑,但说实话,太粗糙了。你想想看,如果图像里有一个像素是纯红色(R=255, G=0, B=0),那绿色和蓝色通道的最大值就是 0,增益直接爆炸。

避坑指南:

我曾经在一个夜景项目里直接用这个基础版,结果画面偏色到离谱。后来发现是因为夜景里有几个孤立的亮噪点,被算法当成了“完美反射”。

解决方案:用百分位最大值代替全局最大值,比如取 99.9% 分位的值,而不是 max。

4.4 改进版:百分位 Max-RGB

用百分位代替全局最大值,能有效抵抗噪点和异常像素的干扰。我个人习惯用 99.5% 到 99.9% 之间的值:

def white_patch_percentile(img, percentile=99.5):
    """
    完美反射白平衡 - 百分位版
    percentile: 百分位值,默认 99.5
    """
    b, g, r = cv2.split(img)
    
    # 计算百分位值
    max_b = np.percentile(b, percentile)
    max_g = np.percentile(g, percentile)
    max_r = np.percentile(r, percentile)
    
    # 保护
    max_b = max_b if max_b > 0 else 1
    max_g = max_g if max_g > 0 else 1
    max_r = max_r if max_r > 0 else 1
    
    gain_b = 255.0 / max_b
    gain_g = 255.0 / max_g
    gain_r = 255.0 / max_r
    
    b_bal = np.clip(b * gain_b, 0, 255).astype(np.uint8)
    g_bal = np.clip(g * gain_g, 0, 255).astype(np.uint8)
    r_bal = np.clip(r * gain_r, 0, 255).astype(np.uint8)
    
    return cv2.merge([b_bal, g_bal, r_bal])

百分位取多少,取决于场景。我一般这样选:

场景类型 推荐百分位 说明
室内均匀光照 99.9% 场景干净,噪点少
室外自然光 99.5% 有天空、云层等大面积亮区
夜景/弱光 99.0% 噪点多,需要更保守
屏幕拍摄 99.8% 屏幕本身有高亮区域

4.5 与灰度世界的对比

这两种算法,说白了就是看问题的角度不同:

  • 灰度世界:看整体,假设平均是灰的。
  • 完美反射:看局部,假设最亮是白的。

实际项目中,我经常把两者结合。比如先跑灰度世界做粗调,再用完美反射做精调。或者反过来,看哪个结果更稳定就用哪个。

小技巧:

如果场景里确实有白色物体(比如白墙、白纸),完美反射的效果会非常好。如果没有,可以试试“局部完美反射”——只取图像中心区域的最大值,因为摄影师通常会把主体放在中间。

4.6 知识结构图

下面这张图总结了完美反射算法的核心逻辑和变体:

完美反射算法(White Patch / Max-RGB)知识结构 核心假设:最亮表面 = 纯白 基础流程:找 Max → 算增益 → 校正像素 全局 Max-RGB 易受噪点影响 百分位 Max-RGB 抗噪能力强 局部 Max-RGB 中心区域优先 应用:室内、产品拍摄、屏幕翻拍 💡 关键选择: 场景有白色物体 → 用全局 Max-RGB;场景噪点多 → 用百分位版;主体在中心 → 用局部版。

4.7 适用场景与局限性

完美反射算法不是万能的。我总结了几种典型情况:

场景 效果 原因
有白色参考物 优秀 假设成立,校正准确
高光区域丰富 良好 高光近似光源色
夜景/弱光 较差 噪点干扰最大值
纯色场景(如草原) 失效 没有白色参考点
强彩色光源 偏差 最亮区域可能带色

说白了,这个算法适合“场景里有白”的情况。如果没有,别硬用,试试灰度世界或者后面要讲的更高级方法。

我的经验:

在手机摄像头 ISP 流水线上,完美反射算法常被用作“快速预检”。先跑一遍,如果增益系数在合理范围内(比如 0.8~1.5),就直接用;如果超出范围,再切换到更复杂的算法。这样既快又稳。