黑电平校正基础:传感器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行,顶部区域会被高估。

解决方案有两种:

  1. 全局平均法:把所有OB像素混在一起算一个均值。简单,但忽略了空间分布差异。
  2. 分段校正法:按行位置线性插值。比如顶部用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区域理解与平均值计算知识体系 传感器原始输出(Raw Image) OB区域提取(按datasheet坐标切图) 顶部/底部OB行 左右OB列 四边包围式 OB平均值计算 简单均值 异常值剔除后均值 中位数 工程落地:帧间滤波 → 温度补偿 → 增益联动 → 边界保护

从传感器输出到OB均值落地,每一步都有细节。记住一句话:OB区域是传感器的「暗参考」,你对待它的精度,决定了暗部画质的天花板。