3. 黑电平校正(Black Level Correction)

黑电平校正,说白了就是让传感器在「全黑」环境下输出的像素值归零。你想想看,如果相机盖上镜头盖,理论上应该读出全 0 的数据,对吧?但实际拿到的 RAW 图,每个像素都有个几十到几百的底数。这个底数不干掉,后面做白平衡、去马赛克都会偏色偏得离谱。

我刚开始接触 ISP 时,觉得黑电平校正不就是减个固定值嘛,有什么好讲的?直到有一次在弱光场景下,暗部出现了明显的色块和条纹,查了两天才发现是局部黑电平没处理好。嗯,这坑我替你们踩过了,今天咱们就把它彻底讲透。

3.1 黑电平是怎么来的?

黑电平的产生,主要有三个源头:

  • 暗电流:半导体热激发产生的电子,温度越高越严重。我记得在手机项目上,夏天户外 40°C 时,暗电流能比实验室 25°C 高出 3 倍。
  • ADC 偏移:模数转换器本身有个固定的偏置电压,保证信号不会低于 0。这个偏移量通常由芯片厂商校准好,但不同芯片之间会有差异。
  • 像素复位噪声:每次读出前复位操作残留的电荷,跟像素结构和读出时序有关。

关键结论:黑电平不是固定不变的。它会随温度、增益、曝光时间变化。如果你只用一组固定参数,暗部细节一定会出问题。

3.2 暗电流的影响有多大?

暗电流这东西,我习惯把它比作「传感器的慢性漏电」。它有几个特点:

  • 随温度指数增长:温度每升高 6-8°C,暗电流翻一倍。这是物理规律,谁也绕不开。
  • 随曝光时间线性累积:长曝光时,暗电流会积累成明显的亮度梯度。
  • 像素间不均匀:有些像素天生漏电更严重,形成固定模式噪声(FPN)。
温度 暗电流(e-/s) 1秒曝光黑电平(DN) 10秒曝光黑电平(DN)
25°C 10 2 20
40°C 40 8 80
60°C 160 32 320

你看这个表,60°C 长曝光时,黑电平已经到 320 DN 了。如果不校正,暗部细节全被淹没了。

3.3 全局黑电平校正

全局校正是最简单的方法。从传感器预留的 OB(Optical Black)像素 区域取平均值,然后从整个图像中减去这个值。

OB 像素是什么?就是传感器边缘被金属遮挡、永远照不到光的那些像素。它们输出的值,就是纯黑电平的采样。

我的习惯:OB 区域通常取 4-8 行,每行几十个像素。我会先做中值滤波剔除异常点,再取均值。直接取均值容易被坏点干扰,我吃过这个亏。

// 全局黑电平校正 - C 风格伪代码
uint16_t calc_black_level(uint16_t *ob_buffer, int ob_count) {
    // 第一步:排序取中值,剔除异常点
    sort(ob_buffer, ob_count);
    int mid_start = ob_count / 4;
    int mid_end = ob_count * 3 / 4;
    uint32_t sum = 0;
    for (int i = mid_start; i < mid_end; i++) {
        sum += ob_buffer[i];
    }
    uint16_t black_level = sum / (mid_end - mid_start);
    return black_level;
}

void global_black_correction(uint16_t *raw, int width, int height, uint16_t black_level) {
    for (int i = 0; i < width * height; i++) {
        if (raw[i] > black_level) {
            raw[i] -= black_level;
        } else {
            raw[i] = 0;  // 防止下溢
        }
    }
}

全局校正的优点是快、省资源。但缺点也很明显——它假设整个 sensor 的黑电平是一致的。实际上,sensor 边缘和中心、不同通道之间,黑电平都有差异。

3.4 局部黑电平校正

为什么需要局部校正?我举个例子。有一次做安防相机项目,画面左上角总是偏暗偏绿,右下角偏亮偏红。查了半天,发现是 sensor 的 OB 区域分布不均匀,左上角的 OB 像素离像素阵列远,走线长,压降大,导致采样值偏低。

局部校正的思路是:把图像分成多个区域,每个区域独立计算黑电平。

// 局部黑电平校正 - 分块处理
#define BLOCK_SIZE 64  // 64x64 像素一块

void local_black_correction(uint16_t *raw, int width, int height) {
    int blocks_x = width / BLOCK_SIZE;
    int blocks_y = height / BLOCK_SIZE;
    
    // 第一步:计算每个块的黑电平
    uint16_t black_map[blocks_y][blocks_x];
    for (int by = 0; by < blocks_y; by++) {
        for (int bx = 0; bx < blocks_x; bx++) {
            // 从每个块边缘的 OB 像素采样
            uint16_t block_ob = sample_ob_in_block(raw, bx, by);
            black_map[by][bx] = block_ob;
        }
    }
    
    // 第二步:双线性插值,生成逐像素黑电平图
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            float black = bilinear_interp(black_map, x, y, blocks_x, blocks_y);
            if (raw[y * width + x] > (uint16_t)black) {
                raw[y * width + x] -= (uint16_t)black;
            } else {
                raw[y * width + x] = 0;
            }
        }
    }
}

注意:分块大小要权衡。块太大,局部差异校正不干净;块太小,OB 像素样本不足,噪声反而放大。我一般用 64x64 或 128x128,具体看 sensor 分辨率和暗电流均匀性。

3.5 黑电平校正的完整流程

下面这张图,是我在实际项目中总结的黑电平校正流程。它把全局和局部方法结合起来了。

黑电平校正完整流程图 RAW 图像输入 ① 提取 OB 像素区域 ② 全局:OB 均值/中值 单值黑电平 ② 局部:分块采样 黑电平网格图 ③ 逐像素减去黑电平 校正后 RAW 输出 全局+局部可并行计算 根据场景动态切换

3.6 实际项目中的避坑指南

做黑电平校正这么多年,我踩过的坑不少。挑几个典型的说说:

  • OB 像素被污染:有一次发现黑电平突然跳变,查了三天,结果是镜头边缘的杂散光漏进了 OB 区域。后来我加了一个「OB 有效性检测」——如果 OB 均值超过阈值,就切换到备用 OB 行。
  • 下溢处理:减完黑电平变成负数怎么办?直接截断到 0 会丢失暗部细节。我习惯保留 2-3 个 bit 的余量,或者用带符号的中间格式处理。
  • 温度补偿:sensor 刚上电和稳定后的黑电平能差 10-20 DN。我建议做一段「预热校准」——开机后前 10 秒,每 0.5 秒更新一次黑电平参数。
  • 增益联动:模拟增益放大后,黑电平也会被放大。校正时一定要先除增益,再减黑电平,顺序不能错。

我曾经在一个项目上,为了省成本没用 OB 像素,而是用全黑帧校准。结果发现每次开机黑电平都不一样,因为 sensor 的暗电流有随机漂移。从那以后,我再也不敢省 OB 像素了。

3.7 不同场景的参数建议

场景 推荐方法 分块大小 更新频率
手机拍照(短曝光) 全局校正 — 每帧
安防监控(长曝光) 局部校正 128x128 每帧
车载摄像头(高温) 局部校正 + 温度补偿 64x64 每帧 + 温度触发
工业检测(高增益) 局部校正 + 增益联动 64x64 每帧

黑电平校正看起来简单,但要做好,得把 sensor 物理特性、温度特性、增益特性都吃透。嗯,这部分内容就到这里。你只要把 OB 像素用好、分块大小选对、温度补偿加上,暗部画质基本就不会出大问题。