3. 噪声模型与仿真:用Python模拟真实传感器噪声

做图像降噪,第一步不是调网络,而是搞清楚你面对的是什么噪声。

我见过不少团队,模型在公开数据集上刷到SOTA,一上硬件就崩。为什么?因为训练数据里的噪声跟真实传感器拍出来的根本不是一回事。说白了,你连敌人长什么样都没搞清楚,怎么打?

这一章,我们就来拆解传感器噪声的物理来源,然后用Python把它复现出来。这样你后面做合成数据、做数据增强,心里才有底。

3.1 传感器噪声的物理来源

先看一张图,帮你建立整体认知:

传感器噪声来源与分类 传感器噪声 光子噪声(散粒噪声) 读出噪声(电路噪声) 固定模式噪声(FPN) 服从泊松分布 信号相关:σ² ∝ 信号强度 服从高斯分布(近似) 信号无关:固定方差 列/行固定偏移 暗电流不均匀 核心结论 真实传感器噪声 = 泊松噪声(信号相关)+ 高斯噪声(信号无关)+ FPN(固定偏移)

这张图里,我把传感器噪声拆成了三大类。其中,光子噪声和读出噪声是时变噪声,每次拍都不一样;固定模式噪声是空间噪声,同一颗传感器上位置固定。

关键理解:光子噪声的方差跟信号强度成正比——信号越亮,噪声越大。读出噪声的方差是常数,跟信号无关。这两者的叠加,构成了传感器噪声的基本形态。

3.2 高斯噪声:最简单的起点

高斯噪声是读出噪声的主要成分。它的特点是:每个像素独立,服从正态分布,均值为0,方差固定。

数学上很简单:

I_noisy = I_clean + n,  n ~ N(0, σ²)

Python实现就几行:

import numpy as np
import cv2

def add_gaussian_noise(image, sigma=25):
    """
    添加高斯噪声
    sigma: 噪声标准差,单位是像素值(0-255)
    """
    noise = np.random.normal(0, sigma, image.shape)
    noisy = image.astype(np.float32) + noise
    noisy = np.clip(noisy, 0, 255).astype(np.uint8)
    return noisy

# 使用示例
clean = cv2.imread('clean.png', cv2.IMREAD_GRAYSCALE)
noisy = add_gaussian_noise(clean, sigma=25)

我的经验:sigma=25对应什么水平?在8位图像里,这相当于约10%的噪声强度。实际传感器读出噪声通常在sigma=2~10之间,但做数据增强时我经常把sigma拉到30~50,让模型见过更恶劣的情况。

3.3 泊松噪声:信号相关的本质

泊松噪声才是传感器噪声的核心。它来自光子到达传感器的随机性——光子到达数量服从泊松分布。

泊松分布有个重要性质:均值等于方差。也就是说,信号越强,噪声方差越大。

def add_poisson_noise(image):
    """
    添加泊松噪声
    输入图像像素值被解释为光子计数
    """
    # 将像素值映射到光子计数
    # 这里用了一个缩放因子,控制噪声强度
    scale = 1.0  # 越小噪声越强
    photons = image.astype(np.float32) / scale
    
    # 泊松采样
    noisy_photons = np.random.poisson(photons)
    
    # 映射回像素值
    noisy = (noisy_photons * scale).astype(np.uint8)
    noisy = np.clip(noisy, 0, 255)
    return noisy

你想想看,如果直接用这个函数处理一张图,暗区的噪声看起来很小,亮区的噪声反而很大。这就是信号相关噪声的直观表现。

注意:泊松噪声的强度跟图像本身的亮度绑定。如果你用一张曝光不足的暗图,泊松噪声几乎看不见;但一旦提亮,噪声就全出来了。这就是为什么夜景照片放大后全是噪点。

3.4 信号相关噪声:更真实的模型

实际传感器噪声不是单纯的高斯或泊松,而是两者的混合。学术界常用的模型是:

I_noisy = I_clean + n_p(I_clean) + n_g

其中n_p是泊松噪声(信号相关),n_g是高斯噪声(信号无关)。

更精确的建模方式是用方差稳定变换的思路:

def add_realistic_noise(image, alpha=0.01, sigma=10):
    """
    模拟真实传感器噪声
    alpha: 泊松噪声系数(信号相关)
    sigma: 高斯噪声标准差(信号无关)
    """
    img = image.astype(np.float32)
    
    # 信号相关部分:方差与信号成正比
    poisson_var = img * alpha
    poisson_noise = np.random.normal(0, np.sqrt(poisson_var))
    
    # 信号无关部分:固定方差
    gaussian_noise = np.random.normal(0, sigma, img.shape)
    
    # 叠加
    noisy = img + poisson_noise + gaussian_noise
    noisy = np.clip(noisy, 0, 255).astype(np.uint8)
    return noisy

参数调优建议:alpha和sigma的取值需要根据实际传感器标定。我在项目中常用的做法是:拍一张均匀光照下的灰卡,然后分析不同亮度区域的噪声方差,拟合出alpha和sigma。这样得到的噪声模型跟真实传感器最接近。

3.5 固定模式噪声(FPN)

FPN是传感器制造工艺导致的固定偏移。每个像素或每列像素有一个固定的偏置,不随时间变化。

FPN有两种主要形式:

类型 来源 特点 消除方法
像素FPN 每个像素的暗电流不均匀 空间随机,固定不变 暗帧减法
列FPN 列放大器偏移 整列有固定偏移 列校准

模拟FPN的代码:

def add_fpn(image, pixel_offset_std=5, col_offset_std=3):
    """
    添加固定模式噪声
    pixel_offset_std: 像素级偏移标准差
    col_offset_std: 列级偏移标准差
    """
    h, w = image.shape[:2]
    
    # 像素级FPN
    pixel_offset = np.random.normal(0, pixel_offset_std, (h, w))
    
    # 列级FPN
    col_offset = np.random.normal(0, col_offset_std, (1, w))
    col_offset = np.tile(col_offset, (h, 1))
    
    # 叠加
    fpn = pixel_offset + col_offset
    noisy = image.astype(np.float32) + fpn
    noisy = np.clip(noisy, 0, 255).astype(np.uint8)
    return noisy

避坑指南:我曾经在项目中忽略了FPN,结果模型在合成数据上表现很好,一上真实硬件就出现竖条纹。后来才发现是列FPN在作怪。从那以后,我每次做数据合成都会加上FPN,哪怕强度调得很低。

3.6 完整噪声管线:把一切串起来

现在我们把所有噪声类型整合成一个完整的仿真管线:

def sensor_noise_pipeline(image, iso_level=1.0):
    """
    完整传感器噪声仿真管线
    iso_level: ISO感光度,越高噪声越强
    """
    img = image.astype(np.float32)
    
    # 参数随ISO变化
    alpha = 0.005 * iso_level      # 泊松系数
    sigma = 5 * iso_level          # 高斯标准差
    fpn_pixel = 2 * iso_level      # 像素FPN
    fpn_col = 1 * iso_level        # 列FPN
    
    # 1. 光子噪声(泊松近似)
    poisson_var = img * alpha
    poisson_noise = np.random.normal(0, np.sqrt(poisson_var + 1e-8))
    
    # 2. 读出噪声(高斯)
    gaussian_noise = np.random.normal(0, sigma, img.shape)
    
    # 3. 固定模式噪声
    h, w = img.shape[:2]
    pixel_offset = np.random.normal(0, fpn_pixel, (h, w))
    col_offset = np.random.normal(0, fpn_col, (1, w))
    col_offset = np.tile(col_offset, (h, 1))
    
    # 叠加所有噪声
    noisy = img + poisson_noise + gaussian_noise + pixel_offset + col_offset
    noisy = np.clip(noisy, 0, 255).astype(np.uint8)
    
    return noisy

# 使用示例
clean = cv2.imread('clean.png')
noisy_low_iso = sensor_noise_pipeline(clean, iso_level=1.0)
noisy_high_iso = sensor_noise_pipeline(clean, iso_level=4.0)

核心要点:这个管线模拟了传感器噪声的三个核心成分——信号相关的泊松噪声、信号无关的高斯噪声、空间固定的FPN。调整iso_level参数,就能模拟从低ISO到高ISO的不同噪声水平。

3.7 噪声评估:怎么知道模拟得像不像?

模拟完了,总得验证一下。我常用的评估方法有两个:

  1. 方差-均值曲线:取均匀区域,计算不同亮度下的噪声方差。真实传感器噪声的方差-均值曲线应该是线性的(泊松部分)加上一个常数偏移(高斯部分)。
  2. 噪声频谱分析:FPN在频谱上表现为特定频率的峰值,高斯噪声则是平坦的频谱。

代码实现:

def analyze_noise(noisy_image, clean_image=None):
    """
    分析噪声特性
    返回不同亮度区间的噪声方差
    """
    if clean_image is not None:
        noise = noisy_image.astype(np.float32) - clean_image.astype(np.float32)
    else:
        # 如果没有干净图像,用局部方差估计
        noise = noisy_image.astype(np.float32)
    
    # 按亮度分桶
    bins = np.linspace(0, 255, 20)
    bin_centers = (bins[:-1] + bins[1:]) / 2
    bin_variances = []
    
    for i in range(len(bins)-1):
        mask = (noisy_image >= bins[i]) & (noisy_image < bins[i+1])
        if mask.sum() > 100:
            bin_variances.append(np.var(noise[mask]))
        else:
            bin_variances.append(0)
    
    return bin_centers, bin_variances

我的习惯:每次做完噪声仿真,我都会画一张方差-均值曲线图,跟真实传感器的标定数据对比。如果曲线形状对不上,就调整alpha和sigma参数。这个迭代过程虽然枯燥,但能让你的合成数据质量提升一个档次。

3.8 本章小结

这一章我们做了三件事:

  • 理解了传感器噪声的物理来源——光子噪声、读出噪声、FPN
  • 用Python实现了每种噪声的仿真代码
  • 整合成完整的噪声管线,并给出了评估方法

有了这个噪声仿真工具,你后面做合成训练数据、做数据增强,就有了坚实的基础。记住一句话:噪声模型越真实,降噪模型越鲁棒。