暗场与亮场校正:从暗电流到增益映射
传感器标定里,暗场和亮场校正是最基础、也最容易被忽视的一步。我见过不少团队,算法调得花里胡哨,结果一上硬件,图像四角发红、中间发青——说白了,就是没把传感器本身的“坏毛病”先治住。
这一节,咱们就聊聊怎么给传感器做“体检”和“矫正”。
暗电流的成因:传感器为什么会“漏电”
先问一个问题:把镜头盖死,全黑环境下,传感器输出的像素值应该是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)本质上是一个与图像同尺寸的系数矩阵。每个像素的增益系数 = 目标值 / 实际响应值。
具体流程如下:
- 采集暗场帧 D,计算平均暗场 D_avg
- 采集亮场帧 L,减去暗场得到净响应 L_clean = L - D_avg
- 计算亮场帧的全局平均值 G_avg = mean(L_clean)
- 每个像素的增益系数 gain[i,j] = G_avg / L_clean[i,j]
- 对增益系数做平滑或截断,防止噪声放大
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),用双线性插值恢复,减少存储
- 增益映射需要定期重新标定,尤其是镜头更换或温度变化较大时
暗场与亮场校正的完整流程
下面这张图把整个流程串起来了,我建议你保存下来,做项目时对照着看。
避坑指南:我踩过的几个坑
做暗场亮场校正这么多年,我总结了几条血泪教训:
- 温度漂移:暗电流随温度变化,标定时的温度和实际使用温度不同,校正效果会打折扣。我建议在传感器附近加一个温度传感器,实时补偿暗电流。
- 增益映射的边界效应:图像边缘的增益系数往往很大,容易放大噪声。我曾经把增益上限设到3.0,结果边缘全是噪点。后来限制在1.5以内,配合镜头渐晕校正,效果好很多。
- 亮场帧的均匀性:用普通背光板做亮场,本身就不均匀。我吃过这个亏,后来改用积分球,均匀性从85%提升到99%以上。
- 多帧平均的帧数:不是越多越好。16帧和64帧的差异已经很小了,但采集时间差了4倍。我一般用32帧,平衡噪声和效率。
暗场与亮场校正的核心指标:
| 指标 | 说明 | 典型值 |
|---|---|---|
| 暗电流非均匀性(DSNU) | 暗场下像素响应的标准差 | < 0.5% 满阱容量 |
| 光响应非均匀性(PRNU) | 均匀光照下像素响应的标准差 | < 1% 均值 |
| 校正后残留非均匀性 | 校正后图像的残余不均匀度 | < 0.1% |
嗯,暗场和亮场校正说完了。说白了,就是先去掉传感器的“底噪”,再拉平每个像素的“个性”。这两步做好了,后面的色彩校正、白平衡才能站得住脚。否则,你调得再好,也是沙上建塔。