3. 黑电平校正(BLC):从暗电流到数字校正
黑电平校正,英文叫 Black Level Correction,简称 BLC。这名字听起来挺学术,但说白了,就是解决一个很实际的问题:当传感器完全看不到光的时候,它输出的像素值应该是多少?
理想情况下,应该是 0。但现实世界没这么完美。传感器即使在全黑环境里,也会读出一些非零的数值。这个数值,就是黑电平。如果不把它校正掉,整张画面就会蒙上一层灰雾,暗部细节全被吞掉。
我刚开始做 ISP 的时候,就吃过这个亏。当时调一个 sensor,白天效果看着还行,一到夜景,暗部全是偏紫的噪点,怎么调都调不干净。后来才发现,是黑电平没校正好,导致暗部偏色被后面的白平衡放大了。嗯,从那以后,我再也不敢小看 BLC 了。
3.1 黑电平是怎么产生的?
黑电平的根源,主要来自三个方面。我习惯把它们分成“物理的”和“电路的”两类。
| 来源 | 原因 | 影响 |
|---|---|---|
| 暗电流 | 硅基半导体在热激发下产生电子-空穴对,即使无光照也有电荷积累 | 随温度升高呈指数增长,长曝光时尤其明显 |
| 像素复位噪声 | 像素复位后,浮置扩散节点电压无法完全归零 | 引入固定模式噪声(FPN) |
| ADC 偏移 | 模数转换器的参考电压或比较器存在固有偏移 | 整体加一个固定偏置 |
你想想看,这三个因素叠加在一起,全黑像素读出来可能是 64、128,甚至更高。如果不处理,暗部细节就全被这个“底噪”淹没了。
关键指标:黑电平通常用 DN(Digital Number)表示。常见 CMOS sensor 的黑电平在 64~256 DN(12-bit RAW)之间。温度每升高 10°C,暗电流大约翻一倍。
3.2 校正原理:减掉那个“底”
校正原理其实很简单:从每个像素值里减去一个参考黑电平值。
但问题来了——这个“参考值”怎么取?
我见过两种主流做法:
- OB(Optical Black)像素法:传感器边缘有一圈被金属遮挡的像素,它们永远接收不到光。用这些像素的平均值作为黑电平参考。
- 固定值法:直接使用 sensor 厂商提供的固定黑电平值,或者通过标定得到。
我个人更推荐 OB 像素法。为什么?因为它能实时跟踪温度变化。我在一个安防项目中遇到过,白天和晚上的黑电平能差 20 多个 DN,用固定值根本扛不住。
校正公式也很直接:
// 黑电平校正核心公式
P_corrected = P_raw - BLC_offset
// 如果使用 OB 像素
BLC_offset = mean(OB_pixels)
// 带增益补偿的版本(用于高增益场景)
BLC_offset = mean(OB_pixels) * gain_compensation
注意:校正后的值可能为负数。硬件实现时一定要做 clamp(钳位)处理,把负值截断到 0。否则后面的 ISP 模块会出各种奇怪的问题。
3.3 硬件实现:流水线里的减法器
在 ISP 硬件流水线里,BLC 通常是第一个处理模块。它紧跟在 RAW 数据采集之后,比去噪、去马赛克都早。
硬件实现的核心是一个减法器 + 累加器结构:
- OB 像素累加:在行消隐期(H-Blank)或帧消隐期(V-Blank),把 OB 像素的值累加起来,计算平均值。
- 逐像素减法:每个有效像素进来,减去刚才算好的 BLC_offset。
- Clamp 电路:结果小于 0 时强制输出 0。
这里有个细节——行级 BLC vs 帧级 BLC。我建议在 CMOS sensor 上用行级 BLC,因为每行的 OB 像素能更准确地反映该行的暗电流状态。帧级 BLC 虽然省资源,但遇到卷帘快门(Rolling Shutter)时,行与行之间的黑电平差异会被忽略。
下面这张图展示了 BLC 在 ISP 流水线中的位置和内部结构:
3.4 软件模拟:用 Python 验证算法
在硬件实现之前,我习惯先用软件跑一遍算法。这样能快速验证思路,也能提前发现边界情况。
下面是一个简单的 BLC 模拟代码:
import numpy as np
import cv2
def blc_correction(raw_image, ob_pixels=None, fixed_offset=None):
"""
黑电平校正软件模拟
参数:
raw_image: 原始 RAW 图像 (numpy array)
ob_pixels: OB 像素区域 (numpy array),用于动态计算偏移
fixed_offset: 固定黑电平偏移值
返回:
校正后的图像
"""
# 确保输入是 uint16 或 float
img = raw_image.astype(np.float32)
if ob_pixels is not None:
# 使用 OB 像素计算动态偏移
blc_offset = np.mean(ob_pixels)
print(f"[BLC] 动态偏移: {blc_offset:.2f} DN")
elif fixed_offset is not None:
# 使用固定偏移
blc_offset = fixed_offset
print(f"[BLC] 固定偏移: {blc_offset:.2f} DN")
else:
raise ValueError("必须提供 ob_pixels 或 fixed_offset")
# 执行校正
corrected = img - blc_offset
# Clamp 到有效范围
corrected = np.clip(corrected, 0, None)
return corrected.astype(np.uint16)
# 模拟使用
if __name__ == "__main__":
# 模拟一个 12-bit RAW 图像(全黑场景)
h, w = 1080, 1920
dark_current = 32 # 模拟暗电流贡献
adc_offset = 48 # 模拟 ADC 偏移
noise = np.random.normal(0, 5, (h, w)) # 随机噪声
raw_black = dark_current + adc_offset + noise
raw_black = np.clip(raw_black, 0, 4095).astype(np.uint16)
# 模拟 OB 像素(传感器边缘被遮挡的区域)
ob_region = raw_black[0:10, 0:10] # 假设左上角 10x10 是 OB 像素
# 执行校正
corrected = blc_correction(raw_black, ob_pixels=ob_region)
print(f"校正前均值: {np.mean(raw_black):.2f} DN")
print(f"校正后均值: {np.mean(corrected):.2f} DN")
我的经验:软件模拟时,一定要加入噪声模型。纯理想情况下的 BLC 很好做,但一旦加上 sensor 的随机噪声和固定模式噪声,你就会发现简单的减法不够用。这时候可以考虑加一个 IIR 滤波器对 OB 像素做平滑,防止单帧噪声导致偏移抖动。
3.5 避坑指南:我踩过的几个坑
做了这么多年 ISP,BLC 相关的坑我踩过不少。挑几个典型的说说:
- 坑一:OB 像素被污染。 有一次 sensor 的 OB 区域封装漏光,导致黑电平算出来偏高,整张图偏暗。排查了三天才发现。后来我要求所有 OB 像素必须做 坏点检测,剔除异常值再求平均。
- 坑二:增益后的黑电平漂移。 模拟增益(Analog Gain)放大信号的同时,也会放大黑电平。如果不做增益补偿,高 ISO 下暗部会偏色。我一般会在 BLC 后面加一个 增益归一化 步骤。
- 坑三:BLC 和去噪的顺序。 有些 ISP 把去噪放在 BLC 前面。我个人不建议这么做——黑电平没校掉,噪声的均值不是零,去噪算法会算错统计量。
重要提醒:BLC 校正后的数据,一定要检查是否有 负值被截断 导致的直方图缺口。这个缺口会在后面的色调映射(Tone Mapping)中被放大,形成肉眼可见的条带。我曾经在一个医疗影像项目里吃过这个亏,图像在暗部出现了明显的等高线效应。
3.6 小结:BLC 的核心要点
黑电平校正看起来简单,但它是整个 ISP 流水线的基石。基础没打好,后面的模块再怎么调也救不回来。
记住三个关键点:
- 来源要清楚:暗电流、复位噪声、ADC 偏移,三者叠加决定了黑电平的大小。
- 方法要选对:OB 像素法适合温度变化大的场景,固定值法适合资源受限的嵌入式设备。
- 实现要严谨:Clamp 处理、增益补偿、OB 像素坏点剔除,一个都不能少。