4、噪声估计方法:从暗帧到Patch的实战路径
噪声估计这事,说难不难,说简单也不简单。我刚开始做ISP的时候,总觉得噪声嘛,不就是高斯分布加个泊松模型?后来真上了项目才发现——你连噪声长什么样都摸不准,后面降噪全是瞎蒙。
这一章咱们就聊聊三种最实用的噪声估计方法。每种方法都有它的适用场景,也有它的坑。我个人习惯是三种方法配合着用,互相校验。
4.1 基于暗帧的噪声估计
暗帧,说白了就是把镜头盖盖上拍一张全黑的图。这时候传感器收到的信号,理论上只有暗电流和读出噪声。嗯,这是最直接的噪声测量方式。
具体做法分三步:
- 采集多帧暗帧——至少100帧,我一般拍200帧,帧数太少统计不稳定
- 逐像素计算均值和方差——均值代表暗电流偏置,方差代表噪声功率
- 拟合噪声模型参数——把方差-均值关系映射到光子转移曲线上
代码实现其实不复杂:
// 暗帧噪声估计 - 简化版
// 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的噪声参数拟合,说白了就是从图像里抠很多小块,然后通过统计方法把噪声模型参数反推出来。这是我在实际项目中最常用的方法。
基本思路是这样的:
- 把图像切成N×N的patch(我习惯用32×32或64×64)
- 对每个patch,计算均值和方差
- 剔除那些方差异常大的patch(大概率是纹理区)
- 用剩下的patch做均值-方差散点图
- 拟合光子转移曲线:σ² = 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;
}
4.4 三种方法的对比与选择
这三种方法不是互斥的,我通常这样搭配:
- 产线标定阶段:用暗帧法建立传感器本底噪声数据库
- 场景自适应阶段:用平坦区域法实时更新噪声参数
- 复杂场景兜底:用Patch法做二次校验
你想想看,如果只用暗帧法,温度变了怎么办?如果只用Patch法,场景里全是纹理怎么办?所以我的建议是——至少掌握两种方法,互相备份。
下面这张图总结了三种方法的流程和关系:
最后说一句——噪声估计不是一锤子买卖。同一个传感器,不同增益、不同温度、不同曝光时间,噪声参数都不一样。我习惯在ISP pipeline里留一个噪声参数更新接口,每帧都做一次轻量级的平坦区域检测,实时修正参数。这样降噪效果才能稳得住。