4. 黑电平校正(BLC)算法部署

黑电平校正,圈里人常叫 BLC。说白了,就是去掉 sensor 在无光条件下产生的暗电流偏移。你想想看,一个 CMOS sensor 在完全遮光时,输出并不是 0,而是某个固定的正数——这个数就是黑电平。如果不把它减掉,整幅画面会发灰,暗部细节全被埋掉。

我最早接触 BLC 是在一款 200 万像素的枪机上。当时 sensor 的黑电平有 64 个 LSB,直接减掉后画面暗部出现条纹。后来才明白,黑电平不是固定值,它随增益和温度漂移。嗯,这里要讲的就是怎么建模、怎么查表、怎么在 ARM 上跑得快。

4.1 BLC 的数学本质

先看理想模型。sensor 输出信号可以写成:

P_out = P_real + B + N

其中:

  • P_out:sensor 原始输出(RAW 域)
  • P_real:真实光信号
  • B:黑电平偏移(主要成分)
  • N:随机噪声(包括热噪声、读出噪声等)

校正就是做减法:

P_corrected = max(P_out - B, 0)

为什么要取 max?因为减过头了会出现负值,在无符号整数表示里会卷绕成很大的正数,画面出现亮点。我在项目中就踩过这个坑——减完没做 clamp,结果暗部出现一堆白点,排查了半天才发现是数据溢出。

实际工程中,B 不是常数。它随 sensor 增益线性变化,随温度指数变化。所以我们需要一个二维查表:

B = LUT[gain_index][temp_index]

4.2 查表法实现

查表法是嵌入式 ISP 最常用的手段。为什么不用公式算?因为公式里有指数和对数,ARM Cortex-A53 没有 FPU 的话算起来太慢。查表就是空间换时间。

我习惯的做法是这样的:

  1. 标定阶段:在产线上采集不同增益、不同温度下的黑电平值,生成二维表。
  2. 存储阶段:把表固化到 flash,运行时加载到内存。
  3. 查表阶段:根据当前增益和温度,查表得到 B 值。
  4. 校正阶段:逐像素减去 B,做 clamp。

下面是一个简化的查表实现:

// 黑电平查表结构体
typedef struct {
    uint16_t gain_table[GAIN_LEVELS];   // 增益索引
    int16_t  temp_table[TEMP_LEVELS];   // 温度索引
    uint16_t blc_table[GAIN_LEVELS][TEMP_LEVELS]; // 黑电平值
    uint8_t  gain_idx;                  // 当前增益索引
    uint8_t  temp_idx;                  // 当前温度索引
} blc_lut_t;

// 查表函数
uint16_t blc_lookup(blc_lut_t *lut, uint16_t gain, int16_t temp) {
    // 找到最近的增益索引
    for (int i = 0; i < GAIN_LEVELS; i++) {
        if (gain <= lut->gain_table[i]) {
            lut->gain_idx = i;
            break;
        }
    }
    // 找到最近的温度索引
    for (int i = 0; i < TEMP_LEVELS; i++) {
        if (temp <= lut->temp_table[i]) {
            lut->temp_idx = i;
            break;
        }
    }
    return lut->blc_table[lut->gain_idx][lut->temp_idx];
}
工程经验:查表时不要用二分查找。增益和温度索引通常只有 8-16 个点,线性查找的 cache 命中率更高。我试过用二分法,结果分支预测失败导致性能反而下降。

4.3 ARM Cortex-A53 上的优化

Cortex-A53 是 ARMv8 架构,支持 NEON 指令集。BLC 是像素级操作,天然适合 SIMD 并行。优化思路有三个方向:

优化方向 方法 预期加速比
NEON 向量化 一次处理 8 个像素(uint16) 4-6x
Cache 友好 按行处理,避免跨行跳转 1.5-2x
查表预取 提前加载下一行的 B 值 1.2-1.5x

NEON 实现的核心代码片段:

void blc_neon(uint16_t *src, uint16_t *dst, int width, int height, uint16_t blc_val) {
    // 将 B 值复制到 NEON 寄存器
    uint16x8_t blc_vec = vdupq_n_u16(blc_val);
    
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x += 8) {
            // 加载 8 个像素
            uint16x8_t src_vec = vld1q_u16(&src[y * width + x]);
            // 减去黑电平
            uint16x8_t dst_vec = vsubq_u16(src_vec, blc_vec);
            // 做 clamp(防止下溢)
            dst_vec = vmaxq_u16(dst_vec, vdupq_n_u16(0));
            // 存储结果
            vst1q_u16(&dst[y * width + x], dst_vec);
        }
    }
}
注意:NEON 的 vsubq_u16 是饱和减法吗?不是。它是普通减法,下溢会卷绕。所以必须手动做 vmaxq_u16 clamp。我曾经因为忽略这一步,在低照度场景下画面出现周期性条纹,查了两天才定位到。

4.4 BLC 处理流程

整个 BLC 模块在 ISP pipeline 中的位置是这样的:

Sensor RAW → BLC → DPC(坏点校正) → LSC(镜头阴影校正) → ...

BLC 必须在 DPC 之前做。为什么?因为坏点检测依赖黑电平基准,如果 BLC 没做,坏点阈值会整体偏移,导致误检或漏检。

下面这张图展示了 BLC 模块的内部流程:

黑电平校正处理流程 BLC 模块处理流程 输入 RAW 数据 获取当前增益 & 温度 查表得到 B 值 逐像素减法 + Clamp 输出校正后 RAW 关键参数 • 增益范围:1x ~ 64x • 温度范围:-20°C ~ 85°C • 表大小:16x8 = 128 项 优化要点 • NEON 一次 8 像素 • 按行访问 cache 友好 • 预取下一行 B 值

4.5 避坑指南

做 BLC 部署,有几个坑我反复踩过,写出来给大家参考:

  • 黑电平随增益非线性:有些 sensor 的黑电平在低增益区和高增益区斜率不同。我建议在标定时至少取 8 个增益点,用分段线性插值。
  • 温度滞后效应:sensor 温度变化有热惯性。查表用的温度应该是滤波后的值,不是瞬时值。我习惯用一阶低通滤波,系数 0.8。
  • 多通道差异:Bayer 格式的 R/Gr/Gb/B 四个通道,黑电平可能不同。我见过一个项目只做了单通道校正,结果画面出现颜色偏绿。
  • 边界处理:图像边缘的像素可能因为镜头阴影导致黑电平偏移。如果 BLC 后还有 LSC 模块,这个问题不大;如果没有,建议在边缘区域做补偿。

性能数据参考(Cortex-A53 @ 1.2GHz,1080p RAW):

  • 纯 C 实现:约 18ms/帧
  • NEON 优化:约 4.5ms/帧
  • 查表预取 + NEON:约 3.2ms/帧

优化后 BLC 模块只占整个 ISP pipeline 的 5% 左右,不再是瓶颈。

最后说一句,BLC 看起来简单,但它是 ISP 的基石。黑电平没校好,后面的白平衡、色彩校正全都会偏。我见过一个团队花了两周调 AWB 调不好,最后发现是 BLC 的查表索引算错了——增益和温度搞反了。嗯,细节决定成败。