3、AE算法实现:统计信息提取、亮度权重映射、曝光补偿、AE Table设计与查表法

AE算法听起来玄乎,说白了就是让摄像头自己知道「现在该亮一点还是暗一点」。我做了这么多年ISP,发现很多工程师把AE想复杂了。其实核心就四步:先看画面有多亮,再决定哪里重要,然后算算要不要补偿,最后查个表把参数定下来。

嗯,咱们一步步拆开讲。

3.1 统计信息提取:你得知道画面在说什么

AE的第一步,是从RAW域拿到统计信息。这部分通常在ISP的统计引擎里硬件完成,但算法工程师得清楚它吐出来的是什么。

我个人习惯把统计信息分成三类:

  • 亮度直方图:每个bin统计像素个数,一般是256个bin,覆盖0~1023的RAW值
  • 分区平均亮度:把画面切成M×N块,每块算一个平均亮度值
  • 过曝/欠曝像素占比:统计亮度超过阈值(比如饱和值95%)和低于阈值(比如暗电流+噪声门限)的像素比例

这里有个坑。我在项目中遇到过,有人直接用RAW域的10bit数据做直方图,结果发现暗部细节全被噪声淹没了。为什么?因为RAW域的响应是线性的,人眼对亮度的感知却是对数的。你想想看,暗部稍微有点噪声,在直方图上就占了很大一片。

我的建议:统计之前先做一次简单的Gamma映射(比如Gamma=0.45),把线性域映射到感知域再做直方图。这样暗部的区分度会好很多。

代码实现上,统计模块通常长这样:

// 伪代码:RAW域AE统计信息提取
#define GRID_ROWS 15
#define GRID_COLS 15
#define HIST_BINS 256

typedef struct {
    uint32_t hist[HIST_BINS];        // 亮度直方图
    uint16_t grid_luma[GRID_ROWS][GRID_COLS]; // 分区平均亮度
    float over_exposure_ratio;        // 过曝比例
    float under_exposure_ratio;       // 欠曝比例
} AE_Stats;

void AE_ExtractStats(uint16_t *raw_buf, int width, int height, AE_Stats *stats) {
    // 1. 清空统计缓冲区
    memset(stats, 0, sizeof(AE_Stats));
    
    // 2. 遍历每个像素
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            uint16_t val = raw_buf[y * width + x];
            
            // 直方图统计(先做Gamma映射)
            uint8_t bin = (uint8_t)(pow(val / 1023.0f, 1.0f/2.2f) * 255);
            stats->hist[bin]++;
            
            // 分区平均亮度
            int grid_y = y * GRID_ROWS / height;
            int grid_x = x * GRID_COLS / width;
            stats->grid_luma[grid_y][grid_x] += val;
        }
    }
    
    // 3. 归一化分区亮度
    for (int i = 0; i < GRID_ROWS; i++)
        for (int j = 0; j < GRID_COLS; j++)
            stats->grid_luma[i][j] /= (width/GRID_COLS) * (height/GRID_ROWS);
}

3.2 亮度权重映射:不是所有区域都重要

拿到分区亮度之后,你不能简单取平均。为什么?因为画面中心通常比边缘重要,人脸区域比天空重要。这就是权重映射要做的事。

我常用的权重映射分两种:

权重类型 适用场景 权重分布特点
中心权重 普通拍照、风景 中心区域权重高,边缘递减
人脸权重 人像、自拍 人脸检测框内权重显著提高

中心权重的实现很简单,就是一个二维高斯分布:

// 中心权重表生成(15x15网格)
float weight[15][15];
float sigma = 3.0f;  // 标准差,控制权重衰减速度
int cx = 7, cy = 7;  // 中心坐标

for (int i = 0; i < 15; i++) {
    for (int j = 0; j < 15; j++) {
        float dx = (i - cx) / sigma;
        float dy = (j - cy) / sigma;
        weight[i][j] = expf(-0.5f * (dx*dx + dy*dy));
    }
}

加权平均亮度的计算:

float weighted_luma = 0.0f;
float total_weight = 0.0f;

for (int i = 0; i < GRID_ROWS; i++) {
    for (int j = 0; j < GRID_COLS; j++) {
        weighted_luma += stats->grid_luma[i][j] * weight[i][j];
        total_weight += weight[i][j];
    }
}
weighted_luma /= total_weight;
曾经踩过的坑:有一次我把人脸权重设得太高,结果人脸正常了,背景全过曝。后来加了限制——人脸区域的权重不能超过总权重的40%。这个经验值你可以参考,但具体还得看你的传感器动态范围。

3.3 曝光补偿:让算法学会「手下留情」

加权平均亮度算出来了,但直接拿它去调曝光参数,拍出来的照片往往偏暗。为什么?因为人眼喜欢亮一点的画面,而算法只追求「平均亮度等于目标值」。

曝光补偿就是干这个的——在算法算出来的基础上,人为加一点偏移。我一般分三步:

  1. 场景检测:判断当前是逆光、夜景、还是普通场景
  2. 补偿量计算:根据场景查一个补偿偏移量
  3. 限幅:补偿后的亮度不能超过传感器最大增益的物理限制

举个例子,逆光场景下,人脸区域亮度可能只有背景的1/4。如果不做补偿,算法会为了照顾背景而把人脸压得很暗。这时候我会加+1.0EV到+1.5EV的补偿。

// 曝光补偿计算
float AE_CalcCompensation(AE_Stats *stats, int scene_type) {
    float comp = 0.0f;
    
    switch (scene_type) {
        case SCENE_BACKLIGHT:
            comp = 1.2f;  // +1.2EV
            break;
        case SCENE_NIGHT:
            comp = 0.5f;  // +0.5EV,夜景不能补太多,否则噪声爆炸
            break;
        case SCENE_NORMAL:
            comp = 0.3f;  // +0.3EV,稍微亮一点更讨喜
            break;
    }
    
    // 限幅:补偿后亮度不能超过目标值的2倍
    float max_comp = 1.0f;  // 最大+1EV
    return min(comp, max_comp);
}

3.4 AE Table设计与查表法:把计算变成查表

好了,现在我们有目标亮度了。但怎么把它变成曝光参数(曝光时间、模拟增益、数字增益)?

最直接的办法是公式计算,但公式计算有延迟,而且不同传感器响应曲线不一样。我更喜欢用查表法——提前标定好一张AE Table,把「目标亮度→曝光参数」的映射关系存进去。

AE Table的设计原则:

  • 优先调曝光时间:曝光时间增加不引入噪声,但会降低帧率
  • 其次调模拟增益:模拟增益比数字增益噪声小
  • 最后调数字增益:数字增益会放大量化噪声,能不用就不用

一张典型的AE Table长这样:

亮度区间 曝光时间(ms) 模拟增益(dB) 数字增益(dB) 说明
0 ~ 50 33 24 6 极暗场景,优先拉满曝光时间
50 ~ 100 33 18 3 暗光,降低模拟增益
100 ~ 150 33 12 0 室内,只用模拟增益
150 ~ 200 20 6 0 正常光照,缩短曝光时间
200 ~ 255 10 0 0 强光,只用曝光时间控制

查表法的实现:

// AE Table结构
typedef struct {
    uint8_t luma_threshold;  // 亮度阈值
    float exposure_time;     // 曝光时间(ms)
    float analog_gain;       // 模拟增益(dB)
    float digital_gain;      // 数字增益(dB)
} AE_Table_Entry;

AE_Table_Entry ae_table[] = {
    {50,  33.0f, 24.0f, 6.0f},
    {100, 33.0f, 18.0f, 3.0f},
    {150, 33.0f, 12.0f, 0.0f},
    {200, 20.0f,  6.0f, 0.0f},
    {255, 10.0f,  0.0f, 0.0f},
};

void AE_LookupTable(uint8_t target_luma, float *exp_time, float *analog_gain, float *digital_gain) {
    for (int i = 0; i < 5; i++) {
        if (target_luma <= ae_table[i].luma_threshold) {
            *exp_time = ae_table[i].exposure_time;
            *analog_gain = ae_table[i].analog_gain;
            *digital_gain = ae_table[i].digital_gain;
            return;
        }
    }
    // 超出范围取最后一项
    *exp_time = ae_table[4].exposure_time;
    *analog_gain = ae_table[4].analog_gain;
    *digital_gain = ae_table[4].digital_gain;
}
关键点:AE Table不是一成不变的。换一颗传感器、换一个镜头,都要重新标定。我习惯在实验室用灰阶卡+标准光源,逐级拍100张照片,把每个亮度下的最优参数记录下来,拟合出最终的Table。

3.5 整体流程:一张图看懂AE算法

说了这么多,咱们用一张SVG图把整个流程串起来。这张图我画了很多遍,每次给新人讲AE都用它。

AE算法整体流程图 AE算法整体流程图 RAW图像输入 统计信息提取 亮度权重映射 曝光补偿 AE Table查表 输出曝光参数 关键说明 • 统计信息由硬件引擎完成 • 权重映射支持动态切换 • AE Table需针对传感器标定 反馈闭环:下一帧继续调整

这张图里有个细节你可能注意到了——反馈箭头。AE不是一锤子买卖,它是一个闭环。当前帧算出的曝光参数,会影响下一帧的RAW输入。所以AE算法必须做平滑处理,防止画面亮度来回跳变。

嗯,关于平滑处理,我习惯用IIR滤波器:

// 曝光参数平滑
float smooth_factor = 0.3f;  // 平滑系数,越小越平滑

float current_exp_time = ae_table_entry.exposure_time;
float prev_exp_time = ae_state->last_exposure_time;

float final_exp_time = prev_exp_time * (1 - smooth_factor) + current_exp_time * smooth_factor;
一个小技巧:平滑系数不要固定。场景变化大(比如从室内走到室外)的时候,系数可以调大到0.6~0.8,让算法快速响应。场景稳定的时候,系数降到0.1~0.2,防止微小的亮度波动导致画面闪烁。

好了,AE算法的核心实现就这些。统计信息提取是基础,权重映射让算法「有重点」,曝光补偿让画面更讨喜,AE Table把复杂的计算变成了简单的查表。这套流程我在三个不同的传感器平台上验证过,效果都还不错。你拿去用的时候,记得根据你的传感器动态范围和镜头F数微调一下Table里的阈值。