4、光源估计方法(中):完美反射算法(White Patch / Max-RGB)原理与实现
上一章我们聊了灰度世界,它假设场景平均是灰的。今天要讲的完美反射算法,思路正好反过来——它认为场景里最亮的那个点,应该是纯白的。
听起来有点绕?别急,我慢慢拆开讲。
4.1 核心思想:最亮的就是白的
完美反射算法,也叫 White Patch 或 Max-RGB。名字很直白:找一块“白色补丁”,用它来校准整张图。
它的假设是:场景中至少存在一个表面,能完全反射入射光的所有波长。换句话说,这个点本身没有颜色,它呈现的颜色就是光源的颜色。
数学表达:
如果场景中有一个纯白表面,那么它反射到相机传感器的 RGB 值,就代表了光源的 RGB 值。
校正时,把每个像素除以这个“最大 RGB”值,就能消除光源影响。
我在项目中遇到过一种情况:拍白色陶瓷餐具时,这个算法效果特别好。因为瓷器表面确实有高光区,那些区域基本就是光源的直接反射。
4.2 算法流程:三步走
实现起来其实很简单,就三步:
- 找最大值:遍历图像,找出 R、G、B 三个通道各自的最大值。
- 计算增益:用最大值除以目标值(通常是 255 或 1.0),得到每个通道的增益系数。
- 应用校正:每个像素乘以对应通道的增益,完成白平衡。
嗯,这里要注意:如果某个通道的最大值是 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 知识结构图
下面这张图总结了完美反射算法的核心逻辑和变体:
4.7 适用场景与局限性
完美反射算法不是万能的。我总结了几种典型情况:
| 场景 | 效果 | 原因 |
|---|---|---|
| 有白色参考物 | 优秀 | 假设成立,校正准确 |
| 高光区域丰富 | 良好 | 高光近似光源色 |
| 夜景/弱光 | 较差 | 噪点干扰最大值 |
| 纯色场景(如草原) | 失效 | 没有白色参考点 |
| 强彩色光源 | 偏差 | 最亮区域可能带色 |
说白了,这个算法适合“场景里有白”的情况。如果没有,别硬用,试试灰度世界或者后面要讲的更高级方法。
我的经验:
在手机摄像头 ISP 流水线上,完美反射算法常被用作“快速预检”。先跑一遍,如果增益系数在合理范围内(比如 0.8~1.5),就直接用;如果超出范围,再切换到更复杂的算法。这样既快又稳。