黑电平校正基础:传感器OB区域的理解
做图像传感器算法,第一个绕不开的概念就是OB(Optical Black,光学黑电平)。说白了,传感器在物理上不可能做到「零光」时输出为0,总会有个底噪偏移。这个偏移量,就是黑电平。
我刚开始接触ISP流水线时,觉得黑电平校正不就是减个固定值嘛,有什么好研究的?后来在项目中吃过亏才明白——OB区域的理解和平均值计算,直接决定了整条图像链路的暗部表现。今天我们就把它彻底讲透。
OB像素的布局与读取
先看传感器是怎么把OB像素排进去的。大多数CMOS传感器会在有效像素阵列的四周,布置几行几列被金属遮光层覆盖的像素。这些像素收不到光,只输出暗电流和读出噪声。
关键认知:OB像素不是「坏像素」,而是传感器故意留出来的参考基准。它们和有效像素共享同一套模拟电路、同一根列总线,所以能真实反映当前帧的暗电平漂移。
常见的OB布局有两种:
| 布局类型 | 典型位置 | 特点 |
|---|---|---|
| 顶部/底部OB行 | 有效像素阵列的上方和下方 | 行数固定(如4行、8行),适合逐行读出 |
| 左右OB列 | 有效像素阵列的左侧和右侧 | 列数固定(如16列、32列),适合列FPN校正 |
| 四边包围式 | 上下左右均有OB区域 | 信息最全,但占用面积大,高端传感器常用 |
读取时,传感器驱动会把OB像素和有效像素一起扫出来。你拿到raw图后,需要根据传感器的datasheet找到OB区域的起始坐标和宽高。举个例子:
# 假设传感器输出分辨率为 1920x1080
# 顶部有4行OB,底部有4行OB,左右各有16列OB
# 有效像素区域从 (16, 4) 开始,宽 1888,高 1072
raw_width = 1920
raw_height = 1080
ob_top_rows = 4
ob_bottom_rows = 4
ob_left_cols = 16
ob_right_cols = 16
# 提取顶部OB行(所有列)
ob_top = raw_image[0:ob_top_rows, :]
# 提取左侧OB列(排除顶部和底部OB行后的有效行范围)
ob_left = raw_image[ob_top_rows:raw_height-ob_bottom_rows, 0:ob_left_cols]
注意:有些传感器会在OB区域里混入dummy像素(无效填充像素),读取时一定要跳过。我曾经遇到过一款传感器,顶部OB行前两列是dummy,直接取平均把黑电平算偏了,暗部校正后出现条纹。后来老老实实按datasheet的像素映射表重新切区域才解决。
OB区域平均值的计算
拿到OB像素数据后,下一步就是算平均值。听起来简单,但这里有几个坑。
基本计算方法
最直接的做法:把OB区域所有像素的灰度值加起来,除以像素总数。
import numpy as np
def compute_ob_mean(ob_region):
"""
计算OB区域的平均值
ob_region: numpy array, shape (H, W)
"""
return np.mean(ob_region)
嗯,就这么几行。但实际项目中,我从来不会直接用这个均值。
为什么要做异常值剔除?
OB区域虽然被遮光了,但依然会有缺陷像素(hot pixel、dead pixel)。如果某个OB像素的暗电流特别大,它的值会远高于正常OB像素,直接把均值拉高。你想想看,用这个偏高的均值去做黑电平校正,有效像素的暗部会被过度减除,出现负值截断。
我个人的习惯是:先做3-sigma剔除,或者用中位数代替均值。
def robust_ob_mean(ob_region, sigma_thresh=3.0):
"""
鲁棒的OB均值计算:剔除异常值后取平均
"""
mean = np.mean(ob_region)
std = np.std(ob_region)
mask = np.abs(ob_region - mean) < sigma_thresh * std
return np.mean(ob_region[mask])
def ob_median(ob_region):
"""
直接用中位数,对异常值更鲁棒
"""
return np.median(ob_region)
经验之谈:如果OB区域像素数量足够多(比如超过1000个),中位数和剔除后的均值非常接近。但中位数计算在硬件实现上更简单(只需要排序或直方图统计),所以很多ISP芯片直接用中位数。软件原型阶段我建议两种都算一下,对比看看差异。
多行/多列OB的融合策略
当传感器有多个OB行或OB列时,不同位置的OB像素值可能不一样。为什么?因为传感器芯片存在工艺梯度——靠近芯片边缘的像素和靠近中心的像素,暗电流特性有细微差异。
我遇到过最典型的情况:顶部OB行均值是64,底部OB行均值是68。如果只取顶部OB行做校正,底部有效像素区域的黑电平会被低估;只取底部OB行,顶部区域会被高估。
解决方案有两种:
- 全局平均法:把所有OB像素混在一起算一个均值。简单,但忽略了空间分布差异。
- 分段校正法:按行位置线性插值。比如顶部用OB_top均值,底部用OB_bottom均值,中间的行按比例混合。
我个人更推荐分段校正法,尤其是在传感器尺寸较大(比如1英寸以上)时。下面是一个简单的线性插值实现:
def interpolated_ob_level(raw_height, ob_top_mean, ob_bottom_mean):
"""
按行位置计算每行的OB基准值
raw_height: 有效像素的行数
ob_top_mean: 顶部OB均值
ob_bottom_mean: 底部OB均值
"""
ob_level = np.linspace(ob_top_mean, ob_bottom_mean, raw_height)
return ob_level
OB平均值的工程落地要点
把OB平均值计算放到实际ISP流水线里,还有几个细节要注意:
| 环节 | 常见做法 | 我的建议 |
|---|---|---|
| 帧间滤波 | 每帧独立计算OB均值 | 做IIR时域滤波,防止单帧异常导致黑电平跳变 |
| 温度补偿 | 忽略温度影响 | OB值随温度升高而增大,建议查表补偿 |
| 增益联动 | OB均值固定 | 模拟增益变化时,OB均值会跟着变,需要重新计算 |
| 边界处理 | 直接切OB区域 | 预留2-4像素的边界保护带,避免边缘效应 |
曾经踩过的坑:有一款传感器在低光照下,OB区域会出现「光晕渗透」——强光从有效像素区散射到相邻的OB像素。结果OB均值被污染,校正后暗部发紫。解决方案是只取远离有效像素区的OB行(比如顶部第3-4行,而不是第1-2行)。
本章知识体系
下面这张图把OB区域的理解、读取、平均值计算串起来了:
从传感器输出到OB均值落地,每一步都有细节。记住一句话:OB区域是传感器的「暗参考」,你对待它的精度,决定了暗部画质的天花板。