暗场与亮场校正:从暗电流到增益映射

传感器标定里,暗场和亮场校正是最基础、也最容易被忽视的一步。我见过不少团队,算法调得花里胡哨,结果一上硬件,图像四角发红、中间发青——说白了,就是没把传感器本身的“坏毛病”先治住。

这一节,咱们就聊聊怎么给传感器做“体检”和“矫正”。

暗电流的成因:传感器为什么会“漏电”

先问一个问题:把镜头盖死,全黑环境下,传感器输出的像素值应该是0吗?

答案是:不是。而且往往不是个小数字。

暗电流(Dark Current)就是传感器在无光照条件下,由于热激发产生的电子-空穴对。温度每升高6-8°C,暗电流大约翻一倍。你想想看,夏天户外35°C,传感器内部可能已经50°C了,暗电流能不高吗?

暗电流的三个主要来源:

  • 耗尽区热激发:硅材料中晶格振动导致电子跃迁
  • 界面态陷阱:Si-SiO₂界面处的缺陷释放载流子
  • 杂质中心:重金属污染或掺杂不均匀

我在一个工业检测项目里遇到过,同一批次的传感器,暗电流差异能到30%。后来排查发现,是封装过程中清洁度不一致导致的。嗯,硬件上的坑,往往比算法上的更难搞。

暗场帧采集:怎么拍一张“全黑”的照片

暗场校正的原理很简单:拍一张全黑图像,记下每个像素的“底噪”,然后从正常图像里减掉它。

但“拍一张全黑”这件事,比你想的讲究。

暗场帧采集的实操要点:

  • 物理遮光:用不透明镜头盖或遮光布,确保无任何光线进入
  • 曝光时间一致:暗场帧的曝光时间必须与正常拍摄相同,因为暗电流随时间线性累积
  • 温度一致:传感器温度差异会导致暗电流变化,最好在恒温环境下采集
  • 多帧平均:采集16-64帧暗场图像,取平均值以降低随机噪声

我曾经犯过一个低级错误:用黑色卡纸遮光,结果卡纸边缘漏光,暗场帧里有一片亮区。校正后的图像反而多了一块“暗斑”。从那以后,我改用金属镜头盖加黑色绒布双层遮光。

暗场帧的数学表达很简单:

# 暗场校正示例
import numpy as np

def dark_correction(raw_image, dark_frame):
    """
    raw_image: 原始图像 (H x W)
    dark_frame: 暗场帧 (H x W)
    返回: 校正后图像
    """
    # 确保数据类型一致,防止溢出
    corrected = raw_image.astype(np.int16) - dark_frame.astype(np.int16)
    # 截断到有效范围
    corrected = np.clip(corrected, 0, 65535).astype(np.uint16)
    return corrected

亮场非均匀性校正:为什么均匀光照下图像却不均匀

暗场校正之后,图像看起来干净多了。但还有一个问题:在均匀光照下,不同像素的响应不一致。这就是亮场非均匀性(Photo Response Non-Uniformity, PRNU)。

原因主要有两个:

  • 光学系统:镜头边缘渐晕(vignetting),中心亮、四周暗
  • 像素差异:每个像素的量子效率、增益略有不同,制造工艺的微小偏差

亮场校正的思路是:拍一张均匀光照的参考图像,计算每个像素的增益系数,然后用这个系数去校正所有图像。

亮场帧采集的注意事项:

  • 使用均匀光源(积分球或背光板),确保整个视场照度一致
  • 曝光值控制在传感器线性响应区的中段(通常为满阱容量的50%-70%)
  • 先做暗场校正,再做亮场校正——顺序不能反
  • 亮场帧也需要多帧平均,降低随机噪声

增益映射生成:给每个像素算一个“矫正系数”

增益映射(Gain Map)本质上是一个与图像同尺寸的系数矩阵。每个像素的增益系数 = 目标值 / 实际响应值。

具体流程如下:

  1. 采集暗场帧 D,计算平均暗场 D_avg
  2. 采集亮场帧 L,减去暗场得到净响应 L_clean = L - D_avg
  3. 计算亮场帧的全局平均值 G_avg = mean(L_clean)
  4. 每个像素的增益系数 gain[i,j] = G_avg / L_clean[i,j]
  5. 对增益系数做平滑或截断,防止噪声放大
def generate_gain_map(light_frame, dark_frame, target_value=None):
    """
    生成增益映射
    light_frame: 亮场帧 (H x W)
    dark_frame: 暗场帧 (H x W)
    target_value: 目标值,默认使用亮场帧均值
    """
    # 暗场校正
    clean_light = light_frame.astype(np.float32) - dark_frame.astype(np.float32)
    clean_light = np.clip(clean_light, 1, None)  # 避免除零
    
    if target_value is None:
        target_value = np.mean(clean_light)
    
    # 计算增益系数
    gain_map = target_value / clean_light
    
    # 限制增益范围,防止极端值
    gain_map = np.clip(gain_map, 0.5, 2.0)
    
    return gain_map

def apply_gain_map(image, dark_frame, gain_map):
    """应用增益映射进行校正"""
    # 先做暗场校正
    corrected = image.astype(np.float32) - dark_frame.astype(np.float32)
    # 再乘增益系数
    corrected = corrected * gain_map
    corrected = np.clip(corrected, 0, 65535).astype(np.uint16)
    return corrected

增益映射的工程技巧:

  • 增益系数建议用float32存储,精度够用且节省空间
  • 可以压缩增益映射为低分辨率(如1/16),用双线性插值恢复,减少存储
  • 增益映射需要定期重新标定,尤其是镜头更换或温度变化较大时

暗场与亮场校正的完整流程

下面这张图把整个流程串起来了,我建议你保存下来,做项目时对照着看。

暗场与亮场校正完整流程 采集暗场帧(多帧平均) 采集亮场帧(多帧平均) 计算平均暗场 D_avg 亮场减暗场 L_clean 计算增益映射 gain_map 原始图像减暗场 乘增益映射 校正后图像输出 注意:暗场校正必须在亮场校正之前执行,顺序不可颠倒

避坑指南:我踩过的几个坑

做暗场亮场校正这么多年,我总结了几条血泪教训:

  • 温度漂移:暗电流随温度变化,标定时的温度和实际使用温度不同,校正效果会打折扣。我建议在传感器附近加一个温度传感器,实时补偿暗电流。
  • 增益映射的边界效应:图像边缘的增益系数往往很大,容易放大噪声。我曾经把增益上限设到3.0,结果边缘全是噪点。后来限制在1.5以内,配合镜头渐晕校正,效果好很多。
  • 亮场帧的均匀性:用普通背光板做亮场,本身就不均匀。我吃过这个亏,后来改用积分球,均匀性从85%提升到99%以上。
  • 多帧平均的帧数:不是越多越好。16帧和64帧的差异已经很小了,但采集时间差了4倍。我一般用32帧,平衡噪声和效率。

暗场与亮场校正的核心指标:

指标 说明 典型值
暗电流非均匀性(DSNU) 暗场下像素响应的标准差 < 0.5% 满阱容量
光响应非均匀性(PRNU) 均匀光照下像素响应的标准差 < 1% 均值
校正后残留非均匀性 校正后图像的残余不均匀度 < 0.1%

嗯,暗场和亮场校正说完了。说白了,就是先去掉传感器的“底噪”,再拉平每个像素的“个性”。这两步做好了,后面的色彩校正、白平衡才能站得住脚。否则,你调得再好,也是沙上建塔。