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 的话算起来太慢。查表就是空间换时间。
我习惯的做法是这样的:
- 标定阶段:在产线上采集不同增益、不同温度下的黑电平值,生成二维表。
- 存储阶段:把表固化到 flash,运行时加载到内存。
- 查表阶段:根据当前增益和温度,查表得到 B 值。
- 校正阶段:逐像素减去 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];
}
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);
}
}
}
4.4 BLC 处理流程
整个 BLC 模块在 ISP pipeline 中的位置是这样的:
Sensor RAW → BLC → DPC(坏点校正) → LSC(镜头阴影校正) → ...
BLC 必须在 DPC 之前做。为什么?因为坏点检测依赖黑电平基准,如果 BLC 没做,坏点阈值会整体偏移,导致误检或漏检。
下面这张图展示了 BLC 模块的内部流程:
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 的查表索引算错了——增益和温度搞反了。嗯,细节决定成败。