4、黑电平校正(BLC):原理、暗电流补偿、动态BLC算法实现

黑电平校正,圈里人常叫BLC。说白了,就是告诉传感器:「你觉得是黑色的地方,到底是不是真的黑?」

我刚入行那会儿,总觉得BLC是个小角色。不就是减个固定值嘛,有什么好调的?直到有一次,一个项目在暗光下拍出来的画面偏紫,怎么调白平衡都救不回来。折腾了两周,最后发现是黑电平没校好,暗电流把整个底色抬上去了。嗯,从那以后,我再也不敢小看BLC了。

4.1 黑电平是怎么来的?

理想情况下,传感器完全遮光时,输出应该是0。但现实不是这样的。即使没有光,像素也会读出一些信号。这个非零的底,就是黑电平。

原因主要有两个:

  • 暗电流:半导体热激发产生的电子,温度越高越严重。夏天和冬天,同一个传感器的暗电流能差好几倍。
  • 读出电路偏移:ADC、放大器这些模拟电路,天生就有直流偏置。不同通道的偏移还不一样。

你想想看,如果黑电平是64,而真实信号只有10,那这10个单位的信号就被淹没了。所以必须先把这64减掉,才能看到真实的信号。

核心公式:

P校正 = P原始 - BLC值

就这么简单?嗯,原理确实简单。但难在「BLC值」怎么取。

4.2 暗电流补偿:温度和时间是两大变量

暗电流不是常数。它随温度指数增长,随曝光时间线性增长。我见过一个项目,在60℃环境下,暗电流比25℃时高了将近8倍。如果只用一组BLC参数,画面直接就废了。

所以,正经的暗电流补偿,至少要考虑两个维度:

变量 影响方式 补偿策略
温度 每升高6-8℃,暗电流翻倍 查表法:预存多组温度-暗电流曲线
曝光时间 暗电流与曝光时间成正比 线性模型:BLC = BLC0 + k × Texp
增益 模拟增益放大暗电流 增益归一化:BLC需除以当前增益

我建议,在产品开发阶段,先做一组温度标定实验。把传感器放在温箱里,从-20℃到70℃,每10℃记录一次全黑帧的均值。然后拟合出一条曲线。虽然麻烦,但这是最靠谱的做法。

小技巧: 如果硬件上有温度传感器,可以实时查表补偿。如果没有,那就用曝光时间和增益做粗估计。虽然精度差一些,但比固定值强得多。

4.3 动态BLC算法:别让黑电平「飘」了

固定BLC最大的问题是什么?是它跟不上环境变化。温度变了、增益变了、甚至传感器老化都会让黑电平漂移。所以,现代ISP里都用动态BLC。

动态BLC的核心思路:从每一帧图像里,实时估算当前的黑电平。

怎么估算?传感器通常会在像素阵列边缘留几行/几列「光学黑像素(OB,Optical Black)」。这些像素被金属层遮住了,收不到光。它们的输出,理论上就是当前的黑电平。

算法流程大致如下:

  1. 采集OB像素:从传感器读出OB行/列的原始数据。
  2. 去噪预处理:去掉OB像素中的坏点和随机噪声。我习惯用中值滤波,简单有效。
  3. 计算均值/中值:对处理后的OB数据求统计值。注意,不同颜色通道要分开算。
  4. 时间域平滑:当前帧的BLC值,要和历史值做IIR滤波。防止单帧抖动导致画面闪烁。
  5. 逐像素校正:用平滑后的BLC值,对整帧图像做减法。

这里有个坑,我曾经踩过:OB像素的数量不能太少。太少的话,统计噪声太大,BLC值跳来跳去。我一般要求至少保留16行OB像素,每行至少64个像素。

注意: OB像素本身也有暗电流,而且边缘像素的暗电流和中心区域可能不一样。所以,直接用OB均值做全图校正,有时会引入新的误差。更精细的做法是:用OB数据拟合一个二维曲面,然后逐像素补偿。

4.4 代码实现:一个轻量级动态BLC模块

下面是我在一个项目里用过的简化版实现。去掉了平台相关的寄存器操作,只保留核心逻辑。

// 动态BLC校正模块
// 假设传感器提供16行OB像素,每行128个像素
#define OB_ROWS     16
#define OB_COLS     128
#define IIR_ALPHA   0.8f   // 时间平滑系数

typedef struct {
    uint16_t blc_r;   // R通道黑电平
    uint16_t blc_gr;  // Gr通道
    uint16_t gb;      // Gb通道
    uint16_t blc_b;   // B通道
    uint8_t  init;    // 初始化标志
} blc_state_t;

static blc_state_t g_blc = {0};

void blc_update(uint16_t ob_data[OB_ROWS][OB_COLS]) {
    uint32_t sum_r = 0, sum_gr = 0, sum_gb = 0, sum_b = 0;
    uint32_t cnt_r = 0, cnt_gr = 0, cnt_gb = 0, cnt_b = 0;

    // 步骤1:按Bayer模式分离OB像素
    for (int r = 0; r < OB_ROWS; r++) {
        for (int c = 0; c < OB_COLS; c++) {
            // 假设Bayer为RGGB
            if (r % 2 == 0 && c % 2 == 0) {       // R
                sum_r += ob_data[r][c]; cnt_r++;
            } else if (r % 2 == 0 && c % 2 == 1) { // Gr
                sum_gr += ob_data[r][c]; cnt_gr++;
            } else if (r % 2 == 1 && c % 2 == 0) { // Gb
                sum_gb += ob_data[r][c]; cnt_gb++;
            } else {                                 // B
                sum_b += ob_data[r][c]; cnt_b++;
            }
        }
    }

    // 步骤2:计算当前帧各通道均值
    uint16_t cur_r  = (uint16_t)(sum_r / cnt_r);
    uint16_t cur_gr = (uint16_t)(sum_gr / cnt_gr);
    uint16_t cur_gb = (uint16_t)(sum_gb / cnt_gb);
    uint16_t cur_b  = (uint16_t)(sum_b / cnt_b);

    // 步骤3:IIR时间平滑
    if (!g_blc.init) {
        g_blc.blc_r  = cur_r;
        g_blc.blc_gr = cur_gr;
        g_blc.blc_gb = cur_gb;
        g_blc.blc_b  = cur_b;
        g_blc.init   = 1;
    } else {
        g_blc.blc_r  = (uint16_t)(IIR_ALPHA * g_blc.blc_r  + (1 - IIR_ALPHA) * cur_r);
        g_blc.blc_gr = (uint16_t)(IIR_ALPHA * g_blc.blc_gr + (1 - IIR_ALPHA) * cur_gr);
        g_blc.blc_gb = (uint16_t)(IIR_ALPHA * g_blc.blc_gb + (1 - IIR_ALPHA) * cur_gb);
        g_blc.blc_b  = (uint16_t)(IIR_ALPHA * g_blc.blc_b  + (1 - IIR_ALPHA) * cur_b);
    }
}

// 对单个像素做BLC校正
uint16_t blc_correct_pixel(uint16_t raw, uint8_t color) {
    int16_t corrected;
    switch (color) {
        case 0: corrected = (int16_t)raw - (int16_t)g_blc.blc_r;  break;
        case 1: corrected = (int16_t)raw - (int16_t)g_blc.blc_gr; break;
        case 2: corrected = (int16_t)raw - (int16_t)g_blc.blc_gb; break;
        case 3: corrected = (int16_t)raw - (int16_t)g_blc.blc_b;  break;
        default: corrected = (int16_t)raw; break;
    }
    // 钳位到有效范围
    if (corrected < 0) corrected = 0;
    return (uint16_t)corrected;
}

关于IIR系数: 我一般把alpha设在0.7到0.9之间。alpha越大,平滑越强,但响应变慢。如果场景切换很快(比如从室内走到室外),alpha太大就会看到黑电平「追不上」画面变化。这时候可以加一个检测逻辑:如果当前帧BLC和历史值偏差超过阈值,就临时降低alpha。

4.5 BLC与3A参数的协同

BLC不是孤立存在的。它和AEC、AGC、AWB都有关系。

  • BLC vs AEC:如果BLC没校好,AEC统计的亮度均值就会偏大或偏小。我曾经遇到一个case,BLC偏高了10个LSB,结果AEC以为画面太亮,拼命降曝光,导致暗部细节全丢了。
  • BLC vs AGC:增益会放大BLC误差。比如BLC有5个LSB的残差,增益到16倍时,残差就变成了80个LSB。所以,高增益场景下,BLC精度必须更高。
  • BLC vs AWB:不同通道的BLC残差,会直接导致色偏。尤其是暗光下,BLC的R/B通道不平衡,AWB怎么调都调不回来。

我的建议是:在3A收敛之前,先把BLC跑稳。BLC不稳,后面的3A全是白费功夫。

4.6 知识结构图:BLC全景

下面这张图,把BLC的原理、变量、算法和协同关系串起来了。你可以把它当作调试时的检查清单。

黑电平校正(BLC)知识体系 BLC 核心目标 暗电流(热激发) 读出电路偏移 温度(指数增长) 曝光时间(线性) 模拟增益(放大) OB像素采集与分离 分通道统计 + IIR平滑 逐像素减法 + 钳位 AEC亮度统计 AGC增益放大 AWB色温估计 HDR融合对齐 关键原则:BLC必须在3A之前收敛,否则后续所有模块都会引入系统性偏差 调试顺序:温度标定 → OB配置 → 分通道统计 → IIR调参 → 3A联调

4.7 调试避坑清单

最后,分享几个我踩过的坑,希望能帮你省点时间:

  • OB像素被污染:有些传感器的OB区域会漏光。如果发现BLC值随场景亮度变化,先检查OB区域有没有被意外照亮。
  • BLC和LSC打架:镜头阴影校正(LSC)会改变像素的增益。如果先做LSC再做BLC,BLC的减法效果会被LSC放大。我建议先BLC后LSC。
  • 整数溢出的坑:BLC校正后的像素值可能变成负数。代码里一定要做钳位,否则后续模块会拿到一个巨大的正数(因为无符号整数下溢)。
  • IIR初始化的瞬态:开机前几帧,IIR滤波器还没收敛。这时候的BLC值可能不准。我一般会跳过前5帧的3A统计,等BLC稳定后再启用。

BLC看起来简单,但它是整个图像质量的基石。地基没打好,上面盖的楼再漂亮也经不起推敲。嗯,希望这一章能帮你把这个地基夯实了。