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 黑电平校正的完整流程
下面这张图,是我在实际项目中总结的黑电平校正流程。它把全局和局部方法结合起来了。
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 像素用好、分块大小选对、温度补偿加上,暗部画质基本就不会出大问题。