4、AE算法实战:基于亮度直方图的自动曝光调整策略

自动曝光(AE)是摄像头成像的第一步,也是最容易被忽视的一步。很多人觉得AE不就是调调曝光时间嘛,其实没那么简单。我做过不少摄像头模组调试,遇到过最头疼的问题就是——场景切换时画面突然过曝或过暗,用户体验极差。

今天咱们聊的,是基于亮度直方图的AE策略。说白了,就是通过分析图像的亮度分布,来决定要不要调曝光,往哪个方向调,调多少。

4.1 为什么是亮度直方图?

你想想看,一张图亮不亮,最直观的指标就是平均亮度。但平均亮度有个大坑——它会被大面积均匀区域带偏。比如拍一张夜景,大部分区域是黑的,但路灯很亮,平均亮度可能看起来还行,实际上暗部细节全丢了。

亮度直方图就不一样。它能告诉你每个亮度级别上有多少像素。这样你就能看到:

  • 暗部是不是堆积太多(欠曝)
  • 亮部是不是被切了(过曝)
  • 整体分布是偏左还是偏右

核心思路: 通过直方图的统计特征(均值、中位数、暗部/亮部占比)来驱动曝光参数的调整,而不是只看一个简单的平均亮度值。

4.2 算法整体流程

先看整体框架,我习惯把AE流程分成四个步骤:

基于亮度直方图的AE调整流程 采集RAW/YUV帧 获取当前图像数据 计算亮度直方图 256 bins / 64 bins 提取统计特征 均值/中位数/暗亮占比 调整曝光 增益/时间/光圈 是否达标? 是 输出当前参数 否 → 继续调整 注意:每次调整后重新采集 形成闭环反馈

这个闭环很关键。我曾经在项目中偷懒,只做一次调整就完事,结果在光照变化快的场景下,画面一直在闪烁。后来老老实实加了反馈循环,才稳定下来。

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,核心就是「看分布、定策略、稳调整」。不要追求一次到位,闭环反馈才是王道。代码我已经给你了,建议你拿自己的摄像头跑一跑,调调参数,感受一下不同场景下的表现。