3. AE核心算法:统计信息提取与曝光控制

自动曝光(AE)是ISP 3A算法里最基础、也最考验工程经验的一环。说白了,就是让摄像头在不同光照下都能输出亮度合适的图像。你想想看,如果曝光不准,后面再好的降噪、再强的HDR都白搭。

我个人习惯把AE算法拆成两大部分:统计信息的提取和曝光量的闭环控制。前者告诉你当前画面长什么样,后者决定下一步该怎么调。今天我们就把这套流程掰开揉碎了讲。

3.1 统计信息提取:看懂画面

AE的第一步,是从RAW域或YUV域的图像数据里提取统计信息。硬件ISP模块通常会直接输出这些统计值,我们只需要配置好窗口和权重就行。

3.1.1 亮度直方图

亮度直方图是最直观的工具。它统计了每个亮度级别的像素个数。我一般会看三个关键区域:

  • 暗部(0~64):如果像素堆积在这里,画面欠曝。
  • 中间调(64~192):主体细节所在,希望尽量平坦。
  • 亮部(192~255):如果出现尖峰,说明过曝了。

实战经验:我在项目中遇到过一种情况——直方图看起来分布很均匀,但画面实际很暗。后来发现是传感器输出的是线性数据,而直方图统计的是Gamma校正后的数据。所以,一定要确认统计模块工作在哪个域。

直方图还能用来做场景分类。比如,低对比度场景的直方图集中在中间窄带,高对比度场景则两端都有分布。这个信息对后续的曝光策略选择很有帮助。

3.1.2 分区统计

全局直方图有个问题——它不知道亮暗区域在画面中的位置。比如逆光人像,人脸暗、背景亮,全局直方图可能显示正常,但人脸已经黑成一片了。

分区统计就是解决这个问题的。我们把画面分成M×N个格子(常见的是15×15或13×17),每个格子独立计算平均亮度和直方图。

分区数 优点 缺点
4×4 计算量小,适合低端平台 空间分辨率低,容易漏掉小目标
15×15 主流选择,平衡精度和性能 需要一定的DMA带宽
32×32 精细控制,适合人脸跟踪 存储和计算开销大

每个分区还可以配置权重。比如,画面中央区域权重设为2,边缘设为0.5。这样AE会优先保证中央区域的曝光准确。

避坑指南:我曾经在调试一个安防摄像头时,发现AE总是对天空过曝。后来检查发现,分区权重没有排除天空区域。我的做法是:根据场景语义(比如天空检测)动态调整分区权重,效果立竿见影。

3.2 曝光表设计与查表法

有了统计信息,下一步就是确定目标亮度。曝光表(Exposure Table)就是干这个的。

3.2.1 曝光表的结构

曝光表本质上是一个查找表(LUT),输入是场景亮度估计值,输出是曝光参数组合(曝光时间、模拟增益、数字增益)。

// 曝光表示例(简化版)
typedef struct {
    uint16_t scene_lux;      // 场景照度估计值
    uint16_t exposure_time;  // 曝光时间(us)
    uint16_t analog_gain;    // 模拟增益(Q8格式)
    uint16_t digital_gain;   // 数字增益(Q8格式)
} exposure_entry_t;

exposure_entry_t exp_table[] = {
    {10,   30000, 256, 256},  // 暗光:长曝光+高增益
    {50,   20000, 128, 256},  // 室内:中等曝光
    {200,  10000, 64,  256},  // 室外阴天
    {1000, 5000,  32,  256},  // 晴天
    {5000, 1000,  16,  256},  // 强光
};

曝光表的设计有几个关键点:

  • 覆盖范围:从0.1 lux到10000 lux都要有对应的条目。
  • 平滑过渡:相邻条目之间的参数变化不能太大,否则画面会闪烁。
  • 优先级约束:先调曝光时间,再调模拟增益,最后调数字增益。因为数字增益会放大噪声。

3.2.2 查表与插值

实际场景的照度不会刚好落在表里的某个点上。所以我们需要做线性插值。

// 查表插值函数
void lookup_exposure(uint16_t target_lux, exposure_entry_t *out) {
    // 找到相邻的两个表项
    int idx_low = 0, idx_high = TABLE_SIZE - 1;
    for (int i = 0; i < TABLE_SIZE - 1; i++) {
        if (target_lux >= exp_table[i].lux && target_lux <= exp_table[i+1].lux) {
            idx_low = i;
            idx_high = i + 1;
            break;
        }
    }
    
    // 计算插值系数
    float ratio = (float)(target_lux - exp_table[idx_low].lux) / 
                  (exp_table[idx_high].lux - exp_table[idx_low].lux);
    
    // 线性插值
    out->exposure_time = exp_table[idx_low].exposure_time + 
                         ratio * (exp_table[idx_high].exposure_time - exp_table[idx_low].exposure_time);
    out->analog_gain = exp_table[idx_low].analog_gain + 
                       ratio * (exp_table[idx_high].analog_gain - exp_table[idx_low].analog_gain);
    // ... 数字增益同理
}

注意:曝光时间和增益的插值要分开做。因为曝光时间受帧率限制(比如最大33ms对应30fps),而增益受噪声限制。我曾经见过一个方案,把曝光时间和增益绑在一起插值,结果在过渡区出现了帧率跳变。

3.3 PID控制器在AE中的应用

查表法给出的是开环控制。但实际场景中,传感器的响应是非线性的,而且环境光会变化。所以我们需要闭环控制——PID控制器。

3.3.1 误差计算

PID的输入是亮度误差:

error = target_brightness - current_brightness;

其中target_brightness是目标亮度(比如直方图均值128),current_brightness是当前帧的统计亮度。

3.3.2 位置式PID

我习惯用位置式PID,直接输出曝光参数的变化量:

float pid_update(float error) {
    // 比例项
    float p_term = Kp * error;
    
    // 积分项(带限幅)
    integral += error;
    integral = clamp(integral, -INTEGRAL_MAX, INTEGRAL_MAX);
    float i_term = Ki * integral;
    
    // 微分项
    float d_term = Kd * (error - last_error);
    last_error = error;
    
    // 总输出
    float output = p_term + i_term + d_term;
    return clamp(output, -OUTPUT_MAX, OUTPUT_MAX);
}

参数调优的经验值:

  • Kp:0.3~0.8,太大容易振荡。
  • Ki:0.01~0.05,主要用于消除稳态误差。
  • Kd:0.1~0.3,抑制快速变化,但容易引入噪声。

我的习惯:先把Kp调到画面不振荡,然后加一点点Ki让亮度稳定在目标值,最后加Kd抑制过冲。记住,积分项一定要限幅,否则从暗室走到阳光下,积分项会积累到很大,导致画面长时间过曝。

3.4 步长与死区控制

PID输出的是连续值,但实际硬件参数是离散的。而且,如果每次微小的亮度变化都去调整曝光,画面会一直闪烁。这就是步长和死区要解决的问题。

3.4.1 步长控制

步长控制限制了每次调整的最大幅度。比如,曝光时间每次最多变化500us,增益每次最多变化16(Q8格式)。

// 步长限幅
int16_t delta_exp = clamp(pid_output, -MAX_STEP_EXP, MAX_STEP_EXP);
int16_t delta_gain = clamp(pid_output, -MAX_STEP_GAIN, MAX_STEP_GAIN);

步长太大,画面会跳变;步长太小,收敛慢。我一般根据场景动态调整步长:

  • 大误差(|error| > 30):大步长,快速收敛。
  • 小误差(|error| < 10):小步长,精细调整。

3.4.2 死区控制

死区就是设置一个误差阈值,在这个范围内不调整曝光。比如,亮度误差在±3以内,我们认为画面已经足够好了,不需要调整。

// 死区判断
if (abs(error) < DEAD_ZONE) {
    return;  // 不调整
}

死区太小,画面会频繁微调,看起来像在呼吸;死区太大,亮度偏差会一直存在。我常用的死区值是±2~±5(8位亮度范围)。

避坑指南:我曾经在一个项目中,死区设成了±1,结果画面在暗光下一直闪烁。后来发现是传感器噪声导致亮度统计值在±1之间跳动。我的解决方案是:对统计值做时域滤波,比如用IIR滤波器平滑一下,再送入PID。

3.5 知识体系总览

下面这张图总结了AE核心算法的完整流程:

AE核心算法流程 RAW/YUV图像数据 统计信息提取 亮度直方图 + 分区统计 目标亮度计算 PID控制器(闭环) 步长控制 + 死区控制 曝光参数输出 反馈(下一帧统计) 曝光表提供开环初始值 PID做闭环精细调节

从图中可以看到,AE是一个典型的测量-计算-控制-反馈闭环。统计信息提取是基础,曝光表提供初始值,PID做精细调节,步长和死区保证稳定性。

嗯,这套流程我用了很多年,从手机摄像头到安防监控,基本框架都没变过。区别只在于参数调优和场景适配。你只要把今天讲的这几个模块吃透,AE这块就算入门了。