4、AE算法实战:基于亮度直方图的自动曝光调整策略
自动曝光(AE)是摄像头成像的第一步,也是最容易被忽视的一步。很多人觉得AE不就是调调曝光时间嘛,其实没那么简单。我做过不少摄像头模组调试,遇到过最头疼的问题就是——场景切换时画面突然过曝或过暗,用户体验极差。
今天咱们聊的,是基于亮度直方图的AE策略。说白了,就是通过分析图像的亮度分布,来决定要不要调曝光,往哪个方向调,调多少。
4.1 为什么是亮度直方图?
你想想看,一张图亮不亮,最直观的指标就是平均亮度。但平均亮度有个大坑——它会被大面积均匀区域带偏。比如拍一张夜景,大部分区域是黑的,但路灯很亮,平均亮度可能看起来还行,实际上暗部细节全丢了。
亮度直方图就不一样。它能告诉你每个亮度级别上有多少像素。这样你就能看到:
- 暗部是不是堆积太多(欠曝)
- 亮部是不是被切了(过曝)
- 整体分布是偏左还是偏右
核心思路: 通过直方图的统计特征(均值、中位数、暗部/亮部占比)来驱动曝光参数的调整,而不是只看一个简单的平均亮度值。
4.2 算法整体流程
先看整体框架,我习惯把AE流程分成四个步骤:
这个闭环很关键。我曾经在项目中偷懒,只做一次调整就完事,结果在光照变化快的场景下,画面一直在闪烁。后来老老实实加了反馈循环,才稳定下来。
4.3 核心特征提取
拿到直方图后,我们提取哪些特征?我一般用这五个:
| 特征 | 计算公式 | 含义 |
|---|---|---|
| 平均亮度 | ∑(i × hist[i]) / total_pixels | 整体亮度水平 |
| 中位亮度 | 累计50%像素处的亮度值 | 抗噪性更强,不受极端值影响 |
| 暗部占比 | ∑(hist[0:64]) / total_pixels | 欠曝风险指标 |
| 亮部占比 | ∑(hist[192:256]) / total_pixels | 过曝风险指标 |
| 动态范围 | 亮部占比 / 暗部占比 | 场景对比度估计 |
我的经验: 中位亮度比平均亮度更可靠。有一次在强逆光场景下,平均亮度显示正常,但中位亮度偏低,说明主体其实是欠曝的。后来我改用中位亮度作为主控指标,效果好了很多。
4.4 调整策略设计
策略设计是AE的灵魂。我把它分成三个层级:
4.4.1 目标亮度设定
先定一个目标亮度区间。比如8位图像,我一般设目标中位亮度在100~140之间。这个值可以根据场景动态调整——暗场景可以偏低,亮场景可以偏高。
4.4.2 调整方向判断
- 欠曝: 中位亮度 < 目标下限,且暗部占比 > 30% → 需要增加曝光
- 过曝: 中位亮度 > 目标上限,且亮部占比 > 20% → 需要减少曝光
- 正常: 在目标区间内 → 保持不动
4.4.3 调整量计算
调整量不能太大,否则画面会跳变。我常用的公式:
# 调整量计算(对数域)
target_lum = 120 # 目标中位亮度
current_lum = 80 # 当前中位亮度
# 计算增益比
gain_ratio = target_lum / current_lum # 1.5倍
# 限制最大步长,防止跳变
max_step = 1.3 # 单次最大调整30%
if gain_ratio > max_step:
gain_ratio = max_step
elif gain_ratio < 1/max_step:
gain_ratio = 1/max_step
# 应用到曝光时间或增益
new_exposure_time = current_exposure_time * gain_ratio
注意: 调整量一定要加限幅。我曾经没加限幅,结果在从暗室走到室外时,曝光时间从30ms直接跳到1ms,画面闪了一下白,用户直接投诉了。
4.5 Python实现
下面是一个完整的AE类实现。我尽量写得简洁,方便你直接拿去用。
import numpy as np
import cv2
class HistogramAE:
def __init__(self, target_lum=120, target_range=(100, 140),
dark_thresh=0.3, bright_thresh=0.2, max_step=1.3):
self.target_lum = target_lum
self.target_range = target_range
self.dark_thresh = dark_thresh
self.bright_thresh = bright_thresh
self.max_step = max_step
self.exposure_time = 10.0 # 初始曝光时间(ms)
self.analog_gain = 1.0 # 初始模拟增益
def compute_histogram(self, frame):
"""计算亮度直方图"""
if len(frame.shape) == 3:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
else:
gray = frame
hist = cv2.calcHist([gray], [0], None, [256], [0, 256])
return hist.flatten()
def extract_features(self, hist):
"""提取直方图特征"""
total = np.sum(hist)
if total == 0:
return 0, 0, 0, 0
# 平均亮度
bins = np.arange(256)
mean_lum = np.sum(bins * hist) / total
# 中位亮度
cumsum = np.cumsum(hist)
median_lum = np.searchsorted(cumsum, total * 0.5)
# 暗部占比 (0-63)
dark_ratio = np.sum(hist[0:64]) / total
# 亮部占比 (192-255)
bright_ratio = np.sum(hist[192:256]) / total
return mean_lum, median_lum, dark_ratio, bright_ratio
def adjust_exposure(self, frame):
"""主调整函数"""
hist = self.compute_histogram(frame)
mean_lum, median_lum, dark_ratio, bright_ratio = \
self.extract_features(hist)
# 判断是否需要调整
if self.target_range[0] <= median_lum <= self.target_range[1]:
return self.exposure_time, self.analog_gain, False
# 计算目标增益
gain_ratio = self.target_lum / max(median_lum, 1)
# 限幅
gain_ratio = min(gain_ratio, self.max_step)
gain_ratio = max(gain_ratio, 1.0 / self.max_step)
# 应用调整(优先调曝光时间)
if self.exposure_time * gain_ratio < 30: # 不超过30ms
self.exposure_time *= gain_ratio
else:
self.analog_gain *= gain_ratio
return self.exposure_time, self.analog_gain, True
# 使用示例
ae = HistogramAE()
cap = cv2.VideoCapture(0)
while True:
ret, frame = cap.read()
if not ret:
break
exp_time, gain, adjusted = ae.adjust_exposure(frame)
if adjusted:
print(f"调整: 曝光时间={exp_time:.1f}ms, 增益={gain:.2f}")
# 显示直方图
hist = ae.compute_histogram(frame)
# ... 绘制直方图代码省略
cv2.imshow('Frame', frame)
if cv2.waitKey(30) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
4.6 避坑指南
做AE落地,有几个坑我踩过,分享给你:
- 直方图bin数别太多: 256 bins看起来精细,但噪声也大。我一般用64 bins,够用还稳定。
- 别只看均值: 均值容易被大面积均匀区域欺骗。中位数+暗亮占比的组合更靠谱。
- 调整要平滑: 单次调整不要超过30%,否则画面会闪烁。可以加个IIR滤波:
new_param = 0.7 * old_param + 0.3 * target_param - 注意帧率影响: 曝光时间长了帧率会掉。如果曝光时间超过帧间隔的80%,优先调增益而不是继续加曝光时间。
总结一下: 基于亮度直方图的AE,核心就是「看分布、定策略、稳调整」。不要追求一次到位,闭环反馈才是王道。代码我已经给你了,建议你拿自己的摄像头跑一跑,调调参数,感受一下不同场景下的表现。