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)。
实现步骤:
- 计算整幅图像R、G、B三个通道的均值:R_avg、G_avg、B_avg
- 计算灰色参考值:Gray = (R_avg + G_avg + B_avg) / 3
- 计算各通道增益:G_r = Gray / R_avg,G_g = Gray / G_avg,G_b = Gray / B_avg
- 对每个像素应用增益
// 灰度世界假设实现示例
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)。这些点通常对应高光反射区域。
实现步骤:
- 统计所有像素的亮度值,找到最亮的N%像素(通常取1%~5%)
- 计算这些像素的R、G、B均值
- 以这些均值为基准,计算各通道增益
// 完美反射假设实现示例
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调试是我工作中花时间最多的地方之一。
调试流程:
- 采集数据:在不同光源下拍摄灰卡和典型场景
- 标定曲线:建立R/G、B/G与色温的映射关系
- 设置阈值:确定哪些像素参与统计(排除过暗、过亮、饱和区域)
- 调整增益:微调各通道增益,确保中性色准确
- 验证效果:在多种场景下测试,检查偏色情况
- 不要只依赖一种算法,建议灰度世界+完美反射+色温估计三者融合
- 统计区域要避开纯色大块(天空、墙壁)
- 增益不要超过2.0,否则噪声会被放大到不可接受
- 注意光源切换时的平滑过渡,避免色温突变
我曾经遇到一个很头疼的问题:在超市的冷柜前,摄像头拍出来的肉总是偏蓝。冷柜的LED灯光色温很高,加上肉本身是红色的,灰度世界假设一算,红色通道被压得很低,肉就变成灰蓝色了。后来我加了一个「肤色保护」逻辑——如果检测到肤色区域,就降低该区域的校正强度。效果好了很多。
还有一个经验:AWB的收敛速度要把握好。太快了,光源切换时画面会闪烁;太慢了,用户都走到下一个场景了,色温还没跟上。我一般把收敛时间控制在0.5~1秒,既不会闪烁,也不会让人觉得迟钝。
4.6 本章小结
白平衡说难不难,说简单也不简单。灰度世界假设和完美反射假设是基础,色温估计是进阶。实际工程中,没有哪个算法是万能的,关键是要根据场景做融合和适配。
嗯,调试AWB需要耐心。你可能调了一整天,觉得效果已经很好了,结果拿到户外一试,又偏色了。别灰心,这是正常的。多积累一些场景数据,慢慢就能找到感觉。