盖上镜头盖拍一张长曝光,画面里可能出现几颗稳定的亮点;对着均匀白墙,又可能看到极暗的小点。它们不是场景细节,而是某些感光位置或读出链路长期偏离正常响应。
DPC(Defective Pixel Correction)要完成两件事:先判断哪个样本不可信,再用附近可信样本估计它本来应该是多少。问题在于,真实星点、反光和细线同样会表现成局部极值。检测太松会漏掉坏点,太紧则会把场景细节当成缺陷删除。
本文讨论 Bayer RAW 上的固定与在线坏点校正。暂不展开传感器制造测试、宇宙射线瞬态和视频时域坏点追踪。
热像素、死像素和闪烁像素
- 热像素在暗场中持续偏高,长曝光和高温时更明显;
- 死像素对光响应很弱,亮场里仍接近黑色;
- stuck pixel常固定在某个码值;
- 闪烁像素并非每帧都坏,响应会随机跳变。
一颗坏点,三种观察方式
同一块线性 RAW 中藏着热像素和死像素。标记层揭示真值;真实算法只看到 RAW,不能直接读取红圈答案。
坏点可能固定存在,也可能只在高温、长曝光或某种读出模式下出现。工厂坏点表与在线检测通常会共同工作。
工厂通常会生成一份静态坏点表,记录稳定缺陷的位置。在线 DPC 则根据当前帧邻域发现新缺陷或条件性异常。两者互补:静态表可靠却无法覆盖老化和温度变化,在线检测灵活却可能误伤内容。
检测的核心是局部预测残差
设当前 RAW 样本为 ,同色邻域给出的预测是 ,残差为:
最简单的规则是 时标记为坏点。预测可以用均值,但中位数对邻域里的另一个异常值更稳健:
阈值太松漏坏点,太紧伤细节
每个像素与同色邻域中位数比较。阈值决定多大差异才值得怀疑,但真实边缘也会产生很大残差。
在线检测通常还会要求像素同时偏离多个方向、跨帧持续异常,或结合静态坏点表,避免把星点和高光误杀。
固定阈值只适合教学。真实噪声随亮度、模拟增益和白平衡变化,阈值常写成亮度相关形式,或者由噪声模型给出置信区间。还可以要求中心同时偏离水平、垂直和对角方向,降低单一真实边缘造成的误报。
为什么 Bayer 域不能直接平均相邻像素
RGGB 的上下左右邻居通常属于另一种颜色。一个红色样本旁边的绿色值不同,可能只是滤光片响应不同,并非红色位置坏了。
Bayer 域只能向同色邻居借答案
相邻一个像素通常是另一种滤光片。移动目标位置,观察 RGGB 相位和可用于修复的同色样本。
裁掉一行或一列会改变 CFA 相位。如果 Pattern 没同步更新,算法会从错误颜色取邻居。
对 R、B 来说,同色样本通常沿行列相隔两个像素;两个绿色相位则交错排列。算法必须知道 CFA Pattern、Active Area 起点以及裁切后的相位。若裁掉一行却仍按旧 RGGB 解释,合法数组不会报错,修复值却来自错误颜色。
怎样避免把星点当坏点
一颗星或远处灯光可能只覆盖很少像素,看起来和热像素非常相似。可使用的附加证据包括:
- 是否出现在静态坏点表;
- 是否在多帧固定于同一传感器坐标;
- 周围其他通道是否也出现一致光学响应;
- 局部是否存在合理的 PSF 扩散,而非单像素脉冲;
- 残差是否远超当前亮度下的噪声标准差;
- 是否同时偏离多个方向的同色邻居。
没有一条规则能保证不误判,因此 DPC 往往保守处理,并把检测 mask 保留下来供调试。
修复:从可信邻居估计缺失值
检测到坏点后,最简单的方法是使用同色邻域中位数。若坏点落在明显边缘上,全方向中位数或均值仍可能跨边缘混合。
一种方向性思路是比较水平和垂直同色梯度:
选择变化更小的方向插值。若 :
修复不是随便平均四周
坏点位于一条亮暗边缘上。全方向平均会跨过边缘;方向选择尝试沿更平滑的一侧恢复。
方向算法也可能被噪声和斜边骗过。生产实现还要限制修复幅度,并保留坏点位置供后续诊断。
方向选择并不神奇:斜线、高频纹理和强噪声都可能让梯度判断出错。工程实现常限制输出落在可信邻居范围内,并在梯度接近时退回鲁棒中位数。
一个教学版 Python 实现
import numpy as np
def median_same_color(raw, row, column, pattern="RGGB"): target = pattern[(row % 2) * 2 + column % 2] values = [] for dy in range(-2, 3): for dx in range(-2, 3): if dx == 0 and dy == 0: continue y, x = row + dy, column + dx if not (0 <= y < raw.shape[0] and 0 <= x < raw.shape[1]): continue channel = pattern[(y % 2) * 2 + x % 2] if channel == target: values.append(float(raw[y, x])) return float(np.median(values))
def dpc(raw, threshold, pattern="RGGB"):11 collapsed lines
source = raw.astype(np.float32) output = source.copy() defect_mask = np.zeros(source.shape, dtype=bool)
for row in range(source.shape[0]): for column in range(source.shape[1]): estimate = median_same_color(source, row, column, pattern) if abs(source[row, column] - estimate) > threshold: output[row, column] = estimate defect_mask[row, column] = True return output, defect_mask这段代码会把真实局部极值也当成异常,只适合解释骨架。生产版至少要加入亮度相关阈值、方向一致性、边界策略、已知坏点表与噪声模型。
DPC 在管线里的位置
坏点应尽量在会扩散错误之前修复。Debayer 会让一个异常 RAW 样本参与周围多个 RGB 像素,LSC 与 WB 还会放大它。因此常见教学顺序是:
RAW → BLC → DPC → LSC / Bayer Denoise / WB → DebayerBLC 应在检测前完成,否则不同通道黑位残差可能被误认成缺陷。具体的 LSC、降噪和 WB 顺序依赖 ISP:如果 DPC 阈值使用原始噪声模型,就要明确每个增益阶段怎样改变方差。
边界、饱和与数据类型
图像边缘缺少完整邻域,可以镜像、缩小有效检测区,或依赖静态坏点表;不要让负索引绕到数组另一端。
已经饱和的高光也会偏离邻域,但饱和不是坏点。应先读取饱和标记或白电平,避免把成片高光逐颗“修复”。在 uint16 中计算差值可能下溢,检测应先转换到有符号或浮点类型。
验证清单
- 用已知坏点 mask 统计 precision 与 recall,而不只看画面;
- 测试暗场、均匀亮场、星空、细线、斜边和高光;
- 覆盖 RGGB、BGGR、GRBG、GBRG 以及裁行裁列;
- 检查第一行、最后一行和图像四角;
- 对热像素、死像素、stuck 与闪烁像素分别测试;
- 确认检测 mask 可记录、可关闭、可与静态表比较;
- 检查 DPC 后是否还会被 LSC/WB 放大成可见斑点。
DPC 不是把离群值全部删除,而是在采样结构和噪声允许的范围内,判断某个测量是否值得信任。知道 CFA、使用鲁棒邻域、保护真实结构,并在错误扩散前完成修复,才是一套可以解释的坏点校正。