4、噪声估计方法:从暗帧到Patch的实战路径

噪声估计这事,说难不难,说简单也不简单。我刚开始做ISP的时候,总觉得噪声嘛,不就是高斯分布加个泊松模型?后来真上了项目才发现——你连噪声长什么样都摸不准,后面降噪全是瞎蒙。

这一章咱们就聊聊三种最实用的噪声估计方法。每种方法都有它的适用场景,也有它的坑。我个人习惯是三种方法配合着用,互相校验。

4.1 基于暗帧的噪声估计

暗帧,说白了就是把镜头盖盖上拍一张全黑的图。这时候传感器收到的信号,理论上只有暗电流和读出噪声。嗯,这是最直接的噪声测量方式。

我的经验:暗帧采集时,温度控制是关键。每升高6-8°C,暗电流噪声翻一倍。我在实验室里专门做过温控对比实验,温差5°C,噪声方差能差出30%以上。

具体做法分三步:

  1. 采集多帧暗帧——至少100帧,我一般拍200帧,帧数太少统计不稳定
  2. 逐像素计算均值和方差——均值代表暗电流偏置,方差代表噪声功率
  3. 拟合噪声模型参数——把方差-均值关系映射到光子转移曲线上

代码实现其实不复杂:

// 暗帧噪声估计 - 简化版
// frames: 多帧暗帧数据 [H, W, N]
// 返回: 每像素的噪声标准差图

float[] estimateDarkNoise(uint16_t[][][] frames, int H, int W, int N) {
    float[] meanMap = new float[H * W];
    float[] varMap  = new float[H * W];
    
    // 逐像素统计
    for (int y = 0; y < H; y++) {
        for (int x = 0; x < W; x++) {
            float sum = 0, sumSq = 0;
            for (int f = 0; f < N; f++) {
                float val = (float)frames[f][y][x];
                sum   += val;
                sumSq += val * val;
            }
            meanMap[y * W + x] = sum / N;
            varMap[y * W + x]  = (sumSq - sum * sum / N) / (N - 1);
        }
    }
    return sqrt(varMap);  // 返回标准差
}
注意:暗帧估计的噪声只代表传感器本底噪声。实际拍摄时,光子散粒噪声会叠加进来。我曾经犯过这个错——直接用暗帧噪声参数去降正常曝光的图,结果欠噪,暗部细节全糊了。

4.2 基于平坦区域的噪声估计

实际拍摄场景中,你不可能每次都盖镜头盖。那怎么办?找平坦区域。

平坦区域——天空、白墙、均匀色卡——这些地方的信号变化主要来自噪声,而不是场景纹理。你想想看,如果一块区域像素值变化剧烈,那到底是纹理还是噪声?分不清。所以必须找平坦区。

我常用的平坦区域检测方法:

  • 局部梯度法——计算每个patch的梯度幅值,梯度小的判为平坦区
  • 局部方差法——方差低于阈值的区域,认为是平坦区
  • 边缘密度法——用Sobel或Canny检测边缘,边缘像素占比低的区域入选
方法 优点 缺点 我推荐场景
局部梯度法 计算快,适合实时 对弱纹理敏感 手机ISP实时处理
局部方差法 统计稳定,抗噪好 需要调阈值 实验室离线分析
边缘密度法 准确度高 计算量大 后处理/专业软件

找到平坦区域后,剩下的就和暗帧类似了——统计均值和方差。不过要注意,平坦区域的均值不是暗电流偏置,而是信号+偏置。所以拟合时要把信号成分去掉。

核心公式:平坦区域的总方差 = 光子散粒噪声方差 + 读出噪声方差 + 暗电流噪声方差。其中光子散粒噪声方差 = 信号均值 × 增益系数。这个关系就是光子转移曲线的基础。

4.3 基于Patch的噪声参数拟合

前面两种方法都有局限性。暗帧需要专门采集,平坦区域在复杂场景里可能找不到。那有没有更通用的办法?

有。基于Patch的噪声参数拟合,说白了就是从图像里抠很多小块,然后通过统计方法把噪声模型参数反推出来。这是我在实际项目中最常用的方法。

基本思路是这样的:

  1. 把图像切成N×N的patch(我习惯用32×32或64×64)
  2. 对每个patch,计算均值和方差
  3. 剔除那些方差异常大的patch(大概率是纹理区)
  4. 用剩下的patch做均值-方差散点图
  5. 拟合光子转移曲线:σ² = a·μ + b

这里a是增益相关项,b是读出噪声基底。拟合方法我推荐用RANSAC——因为总有一些patch混入了纹理,最小二乘法会被带偏。

// Patch噪声参数拟合 - RANSAC版本
// patches: 所有patch的(均值, 方差)对
// 返回: 拟合参数 a, b

void fitNoiseModel(float[][] patches, int numPatches, 
                   float* a, float* b) {
    int bestInliers = 0;
    float bestA = 0, bestB = 0;
    
    for (int iter = 0; iter < 500; iter++) {
        // 随机选两个点
        int i1 = rand() % numPatches;
        int i2 = rand() % numPatches;
        if (i1 == i2) continue;
        
        float a_tmp = (patches[i1][1] - patches[i2][1]) /
                      (patches[i1][0] - patches[i2][0]);
        float b_tmp = patches[i1][1] - a_tmp * patches[i1][0];
        
        // 统计内点
        int inliers = 0;
        for (int i = 0; i < numPatches; i++) {
            float pred = a_tmp * patches[i][0] + b_tmp;
            float err  = fabs(pred - patches[i][1]);
            if (err < THRESHOLD) inliers++;
        }
        
        if (inliers > bestInliers) {
            bestInliers = inliers;
            bestA = a_tmp;
            bestB = b_tmp;
        }
    }
    
    // 用所有内点重新拟合(最小二乘)
    *a = bestA;
    *b = bestB;
}
避坑指南:我曾经在一个HDR项目里直接用全图patch拟合,结果a参数偏大了一倍。后来发现是HDR合成后的图像有拼接痕迹,patch方差包含了拼接误差。从那以后,我每次做patch拟合前都会先做一步异常patch剔除——把方差最大的前10%直接扔掉。

4.4 三种方法的对比与选择

这三种方法不是互斥的,我通常这样搭配:

  • 产线标定阶段:用暗帧法建立传感器本底噪声数据库
  • 场景自适应阶段:用平坦区域法实时更新噪声参数
  • 复杂场景兜底:用Patch法做二次校验

你想想看,如果只用暗帧法,温度变了怎么办?如果只用Patch法,场景里全是纹理怎么办?所以我的建议是——至少掌握两种方法,互相备份。

下面这张图总结了三种方法的流程和关系:

噪声估计方法总览 暗帧噪声估计 采集100-200帧全黑图像 逐像素统计均值/方差 拟合暗电流+读出噪声 适用:产线标定 平坦区域噪声估计 检测天空/白墙等平坦区 局部梯度/方差筛选 统计平坦区均值-方差 适用:场景自适应 Patch噪声参数拟合 图像切32×32 Patch RANSAC拟合光子转移曲线 剔除纹理异常Patch 适用:复杂场景兜底 噪声模型参数 (a, b, σ²) 降噪参数计算 HDR合成权重 去马赛克参数 建议:产线标定用暗帧,实时处理用平坦区域,离线分析用Patch拟合 三种方法配合使用,互相校验,避免单一方法带来的偏差

最后说一句——噪声估计不是一锤子买卖。同一个传感器,不同增益、不同温度、不同曝光时间,噪声参数都不一样。我习惯在ISP pipeline里留一个噪声参数更新接口,每帧都做一次轻量级的平坦区域检测,实时修正参数。这样降噪效果才能稳得住。