4. AE实战:基于统计信息的AE代码实现与场景策略

自动曝光(AE)这东西,说白了就是让摄像头拍出来的画面亮度刚刚好。太亮过曝,太暗欠曝,都不行。我刚开始做ISP的时候,以为AE就是调个增益完事,后来发现根本不是这么回事——不同场景下,曝光策略天差地别。

这一章,咱们直接上手写代码。我会用Python实现一个基于统计信息的AE引擎,然后聊聊逆光、夜景、高动态这三种典型场景该怎么处理。

4.1 AE的核心:统计信息从哪来?

AE算法要干活,首先得知道当前画面亮不亮。这个信息从哪来?从图像传感器输出的统计信息来。

典型的ISP硬件会提供以下几种统计信息:

统计类型 含义 用途
全局亮度均值 整帧像素亮度的平均值 基础曝光判断
分区亮度直方图 将画面分成MxN块,每块统计亮度分布 局部曝光调整、权重计算
亮度直方图 整帧的亮度分布(0~255各有多少像素) 过曝/欠曝检测
饱和度统计 高亮区域像素占比 防止过曝

嗯,这里要注意:分区统计才是AE的灵魂。全局均值太粗糙了,逆光场景下背景亮、人脸暗,均值可能刚好在128,但人脸早就黑成一片了。

核心思路:AE不是让整帧亮度都到目标值,而是让感兴趣区域的亮度到目标值。分区统计就是为了找到这个“感兴趣区域”。

4.2 基于统计信息的AE代码实现

咱们直接上代码。我会实现一个简化版的AE引擎,包含三个核心模块:统计信息采集、亮度评估、曝光参数调整。

import numpy as np
from typing import List, Tuple

class SimpleAE:
    """基于统计信息的自动曝光引擎"""
    
    def __init__(self, target_luminance: float = 120.0,
                 rows: int = 15, cols: int = 15):
        """
        初始化AE引擎
        
        Args:
            target_luminance: 目标亮度值(0~255)
            rows, cols: 分区统计的行列数
        """
        self.target_luminance = target_luminance
        self.rows = rows
        self.cols = cols
        
        # 曝光参数
        self.exposure_time = 30.0  # 毫秒
        self.analog_gain = 1.0
        self.digital_gain = 1.0
        
        # 权重矩阵:默认中心区域权重高
        self.weight_map = self._create_weight_map()
        
        # 历史记录,用于平滑
        self.history = []
        
    def _create_weight_map(self) -> np.ndarray:
        """创建分区权重矩阵,中心区域权重更高"""
        w = np.ones((self.rows, self.cols))
        center_r, center_c = self.rows // 2, self.cols // 2
        
        for r in range(self.rows):
            for c in range(self.cols):
                dist = np.sqrt((r - center_r)**2 + (c - center_c)**2)
                # 高斯权重,中心区域权重高
                w[r, c] = np.exp(-dist / (self.rows * 0.3))
                
        return w / w.sum()  # 归一化
    
    def collect_statistics(self, frame: np.ndarray) -> dict:
        """
        采集统计信息
        
        Args:
            frame: 输入图像,shape=(H, W),灰度图
            
        Returns:
            统计信息字典
        """
        H, W = frame.shape
        block_h, block_w = H // self.rows, W // self.cols
        
        # 分区亮度矩阵
        zone_luminance = np.zeros((self.rows, self.cols))
        
        for r in range(self.rows):
            for c in range(self.cols):
                block = frame[r*block_h:(r+1)*block_h,
                              c*block_w:(c+1)*block_w]
                zone_luminance[r, c] = block.mean()
        
        # 全局直方图
        hist, _ = np.histogram(frame.flatten(), bins=256, range=(0, 256))
        
        # 过曝/欠曝比例
        over_exposed = (frame > 240).mean()
        under_exposed = (frame < 16).mean()
        
        return {
            'zone_luminance': zone_luminance,
            'global_mean': frame.mean(),
            'histogram': hist,
            'over_exposed_ratio': over_exposed,
            'under_exposed_ratio': under_exposed
        }
    
    def evaluate_brightness(self, stats: dict) -> float:
        """
        评估当前亮度,返回加权亮度值
        
        使用分区权重计算感兴趣区域的亮度
        """
        weighted_lum = (stats['zone_luminance'] * self.weight_map).sum()
        return weighted_lum
    
    def adjust_exposure(self, stats: dict) -> Tuple[float, float, float]:
        """
        根据统计信息调整曝光参数
        
        Returns:
            (exposure_time, analog_gain, digital_gain)
        """
        current_lum = self.evaluate_brightness(stats)
        
        # 计算需要的亮度调整比例
        ratio = self.target_luminance / (current_lum + 1e-6)
        
        # 限制调整幅度,防止震荡
        ratio = np.clip(ratio, 0.5, 2.0)
        
        # 曝光策略:先调曝光时间,再调增益
        if ratio < 1.0:
            # 画面太亮,优先降低曝光时间
            new_time = self.exposure_time * ratio
            new_time = max(new_time, 1.0)  # 最小1ms
            new_gain = self.analog_gain
        else:
            # 画面太暗,先增加曝光时间
            new_time = self.exposure_time * ratio
            if new_time > 60.0:  # 超过60ms可能产生运动模糊
                new_time = 60.0
                # 剩余亮度靠增益补偿
                remaining = ratio / (new_time / self.exposure_time)
                new_gain = self.analog_gain * remaining
                new_gain = min(new_gain, 16.0)  # 最大增益16x
            else:
                new_gain = self.analog_gain
        
        # 更新参数(带平滑)
        alpha = 0.3  # 平滑系数,防止突变
        self.exposure_time = (1 - alpha) * self.exposure_time + alpha * new_time
        self.analog_gain = (1 - alpha) * self.analog_gain + alpha * new_gain
        
        return self.exposure_time, self.analog_gain, self.digital_gain
    
    def process_frame(self, frame: np.ndarray) -> dict:
        """
        处理一帧图像,返回曝光参数
        
        这是AE的主入口函数
        """
        # 1. 采集统计信息
        stats = self.collect_statistics(frame)
        
        # 2. 评估亮度
        current_lum = self.evaluate_brightness(stats)
        
        # 3. 调整曝光
        exp_time, ag, dg = self.adjust_exposure(stats)
        
        # 4. 记录历史
        self.history.append({
            'frame_mean': stats['global_mean'],
            'weighted_lum': current_lum,
            'exposure_time': exp_time,
            'analog_gain': ag
        })
        
        return {
            'exposure_time': exp_time,
            'analog_gain': ag,
            'digital_gain': dg,
            'weighted_luminance': current_lum,
            'over_exposed': stats['over_exposed_ratio'],
            'under_exposed': stats['under_exposed_ratio']
        }

代码要点:

  • 权重矩阵:默认中心权重高,适合人脸在中间的场景。逆光场景需要调整权重。
  • 平滑系数alpha:0.3是我常用的值,太快会闪烁,太慢跟不上场景变化。
  • 曝光时间上限60ms:这是我个人的经验值,超过这个值手持拍摄容易糊。

4.3 不同场景下的曝光策略

同一个AE算法,在不同场景下表现天差地别。我做过一个项目,室内效果完美,拿到室外逆光场景直接翻车。后来我总结出三种典型场景的应对策略。

4.3.1 逆光场景

逆光时,背景很亮,主体(通常是脸)很暗。全局均值可能正常,但人脸已经欠曝了。

策略:人脸优先曝光

  • 检测人脸区域:通过人脸检测模块获取人脸框位置。
  • 修改权重矩阵:将人脸区域的权重提高,背景区域权重降低。
  • 目标亮度适当提高:人脸区域的目标亮度可以设到140~160,比正常场景高一些。
def set_face_weight(self, face_rects: List[Tuple[int, int, int, int]]):
    """
    根据人脸区域设置权重
    
    Args:
        face_rects: 人脸框列表,每个元素为(x, y, w, h)
    """
    # 重置权重为均匀分布
    new_weight = np.ones((self.rows, self.cols))
    
    for (fx, fy, fw, fh) in face_rects:
        # 将人脸框映射到分区坐标
        r_start = int(fy / self.frame_height * self.rows)
        r_end = int((fy + fh) / self.frame_height * self.rows)
        c_start = int(fx / self.frame_width * self.cols)
        c_end = int((fx + fw) / self.frame_width * self.cols)
        
        # 人脸区域权重设为5倍
        new_weight[r_start:r_end, c_start:c_end] *= 5.0
    
    self.weight_map = new_weight / new_weight.sum()

避坑指南:我曾经在逆光场景下把权重全压在人脸上,结果背景过曝到惨白。后来加了过曝保护——如果背景区域过曝比例超过10%,就适当降低人脸权重,保证背景不过曝。

4.3.2 夜景场景

夜景的难点是亮度不够,而且噪声大。单纯提高增益会引入大量噪声。

策略:长曝光 + 低增益

参数 白天 夜景
曝光时间 1~30 ms 30~200 ms
模拟增益 1x~4x 1x~2x(优先用长曝光)
数字增益 1x 1x~2x(尽量不用)
目标亮度 100~130 80~100(暗一点反而噪声少)

我个人的习惯是:夜景下先拉长曝光时间到极限(比如200ms),如果还不够亮,再慢慢加模拟增益。数字增益能不用就不用,那玩意儿放大噪声太明显。

def night_mode_adjust(self, stats: dict) -> Tuple[float, float, float]:
    """夜景模式下的曝光调整"""
    current_lum = self.evaluate_brightness(stats)
    ratio = self.target_luminance / (current_lum + 1e-6)
    
    # 夜景策略:优先增加曝光时间
    new_time = self.exposure_time * ratio
    
    # 曝光时间上限放宽到200ms
    if new_time <= 200.0:
        new_gain = self.analog_gain
    else:
        new_time = 200.0
        # 剩余部分用增益补偿,但限制增益不超过4x
        remaining = ratio / (200.0 / self.exposure_time)
        new_gain = min(self.analog_gain * remaining, 4.0)
    
    # 平滑更新
    alpha = 0.2  # 夜景下平滑更慢,防止闪烁
    self.exposure_time = (1 - alpha) * self.exposure_time + alpha * new_time
    self.analog_gain = (1 - alpha) * self.analog_gain + alpha * new_gain
    
    return self.exposure_time, self.analog_gain, self.digital_gain

4.3.3 高动态场景

高动态场景(HDR)是最头疼的——画面里既有很亮的窗户,又有很暗的角落。单帧曝光根本搞不定。

策略:多帧融合 + 局部曝光

这里我讲两种主流做法:

  1. 多帧HDR:拍三帧(短曝光、中曝光、长曝光),然后融合。短帧保留高亮细节,长帧保留暗部细节。
  2. 局部自适应曝光:不同区域用不同曝光参数。这个需要硬件支持,但效果更好。

对于多帧HDR,AE要做的是:

  • 根据场景动态范围,决定短帧和长帧的曝光时间。
  • 短帧目标亮度:60~80(保证高亮区域不过曝)
  • 长帧目标亮度:160~200(保证暗部提亮)
  • 中帧:正常曝光,作为融合的参考帧。
def hdr_exposure_plan(self, stats: dict) -> dict:
    """
    生成HDR多帧曝光方案
    
    根据直方图判断动态范围,决定短帧和长帧的曝光时间
    """
    hist = stats['histogram']
    
    # 计算暗部和亮部像素比例
    dark_ratio = hist[:64].sum() / hist.sum()
    bright_ratio = hist[192:].sum() / hist.sum()
    
    # 判断是否需要HDR
    if dark_ratio > 0.3 and bright_ratio > 0.3:
        # 高动态场景,启用HDR
        normal_time = self.exposure_time
        
        # 短帧:正常曝光的 1/4
        short_time = normal_time * 0.25
        # 长帧:正常曝光的 4倍
        long_time = min(normal_time * 4.0, 200.0)
        
        return {
            'mode': 'hdr',
            'short_exposure': short_time,
            'normal_exposure': normal_time,
            'long_exposure': long_time,
            'short_gain': 1.0,
            'long_gain': 1.0
        }
    else:
        # 动态范围正常,用单帧
        return {
            'mode': 'normal',
            'exposure_time': self.exposure_time,
            'analog_gain': self.analog_gain
        }

4.4 AE策略选择流程图

下面这张图总结了不同场景下AE策略的选择逻辑。我在项目中就是按这个流程走的,基本能覆盖90%以上的场景。

AE场景策略选择流程 采集统计信息 动态范围是否过高? (暗部 >30% 且 亮部 >30%) 是 HDR模式 短帧:1/4正常曝光 长帧:4倍正常曝光 否 是否存在逆光? (背景亮 vs 人脸暗) 是 逆光模式 人脸区域权重提高 目标亮度:140~160 否 是否夜景? (全局亮度 < 50 或 暗部 > 50%) 是 夜景模式 长曝光(~200ms) 低增益,目标亮度80~100 否 正常模式 标准曝光时间 目标亮度:100~130 说明 实际项目中需要 结合场景检测模块

4.5 实战中的避坑指南

代码写完了,策略也定了,但实际跑起来还是会遇到各种问题。我把自己踩过的坑列出来,你们能少走弯路。

坑1:曝光震荡

我曾经遇到一个场景,AE在亮暗之间来回跳,画面一闪一闪的。原因是平滑系数太小,或者目标亮度设得太死。解决方案:

  • 增加平滑系数(alpha调到0.1~0.2)
  • 设置死区(亮度在目标值±5%范围内不调整)
  • 限制单次调整幅度(ratio限制在0.8~1.2)

坑2:场景切换响应慢

从室内走到室外,AE半天反应不过来。这是因为平滑系数太大,或者曝光时间范围限制太死。解决方案:

  • 检测亮度突变(连续两帧亮度变化超过30%),临时增大alpha
  • 放宽曝光时间范围,让AE能快速调整

坑3:人脸检测不准导致曝光异常

有一次人脸检测模块把背景里的海报误检成人脸,结果AE对着海报优化曝光,真正的人脸反而黑了。解决方案:

  • 对人脸检测结果做置信度过滤
  • 结合运动检测,静止区域权重降低
  • 保留一部分背景权重,防止完全依赖人脸

4.6 小结

这一章我们做了三件事:

  • 写了AE代码:基于分区统计的AE引擎,包含权重矩阵、平滑策略、曝光参数调整。
  • 讲了三种场景策略:逆光(人脸优先)、夜景(长曝光低增益)、高动态(多帧融合)。
  • 总结了实战经验:曝光震荡、场景切换慢、人脸误检,这些坑我都踩过。

AE这东西,理论不难,难的是调参和场景适配。我建议你们拿着代码去跑几个真实场景,看看不同参数下效果差多少。只有亲手调过,才能真正理解AE的脾气。