第4章 去噪:空间域去噪与时域融合

去噪这章,我琢磨了很久该怎么讲。说实话,刚入行那会儿我觉得去噪就是「把图弄干净」,后来踩了无数坑才明白——去噪的本质是在分辨率和噪声之间找平衡。你压噪声压得太狠,细节就糊了;你保细节保得太猛,噪声又在那杵着。嗯,咱们今天就把这事掰扯清楚。

4.1 空间域去噪:三种经典滤波器

空间域去噪,说白了就是直接在像素点上做运算。我习惯把它分成两类:线性滤波和非线性滤波。线性滤波快,但容易把边缘也抹了;非线性滤波慢点,但能保边。

4.1.1 均值滤波

均值滤波是最朴素的思路——把中心像素周围一圈像素的值加起来取平均。你想想看,噪声是随机抖动的,平均一下不就拉回来了吗?

// 3x3 均值滤波核
[1/9, 1/9, 1/9]
[1/9, 1/9, 1/9]
[1/9, 1/9, 1/9]

// 实现示例(C风格伪代码)
void boxFilter(uint8_t* src, uint8_t* dst, int w, int h, int ksize) {
    int half = ksize / 2;
    float scale = 1.0f / (ksize * ksize);
    for (int y = half; y < h - half; y++) {
        for (int x = half; x < w - half; x++) {
            float sum = 0;
            for (int ky = -half; ky <= half; ky++)
                for (int kx = -half; kx <= half; kx++)
                    sum += src[(y+ky)*w + (x+kx)];
            dst[y*w + x] = (uint8_t)(sum * scale + 0.5f);
        }
    }
}
我曾经踩过的坑:均值滤波的核大小千万别选太大。3x3还行,5x5就开始糊了,7x7基本没法看。有一次我在低光场景下用了7x7均值滤波,结果人脸跟油画似的,被PM怼了一下午。

4.1.2 高斯滤波

高斯滤波和均值滤波的区别在哪?均值滤波是「一碗水端平」,高斯滤波是「近亲远疏」——离中心越近的像素权重越大,越远权重越小。这个权重分布服从高斯函数,所以叫高斯滤波。

滤波器 权重分布 去噪效果 边缘保留 计算量
均值滤波 均匀 一般 差 低
高斯滤波 高斯分布 较好 中等 中
中值滤波 排序取中 椒盐噪声极好 好 高

我个人习惯用 σ(标准差) 来控制高斯滤波的强度。σ越大,核越平缓,去噪越强但细节损失也越大。一般 σ 取 0.8~1.5 比较合适,超过 2.0 就开始糊了。

// 3x3 高斯核(σ=1.0)
[1/16, 2/16, 1/16]
[2/16, 4/16, 2/16]
[1/16, 2/16, 1/16]

// 生成高斯核的公式
float gaussian(int x, int y, float sigma) {
    float coeff = 1.0f / (2.0f * M_PI * sigma * sigma);
    float exp_val = -(x*x + y*y) / (2.0f * sigma * sigma);
    return coeff * expf(exp_val);
}

4.1.3 中值滤波

中值滤波的思路完全不同——它不求和,而是把窗口内的像素值排序,取中间那个值。这招对付椒盐噪声特别管用。为什么?因为椒盐噪声是极端值(要么特别亮要么特别暗),排序后肯定被挤到两头去了,中间那个值大概率是正常像素。

实战技巧:中值滤波的窗口大小建议选奇数(3、5、7),这样中间位置好确定。我在做工业相机ISP时,遇到过传感器坏点导致的固定噪声,用3x3中值滤波就能搞定,比均值滤波效果好得多。

4.2 时域去噪原理

空间域去噪有个硬伤——它只看了当前这一帧。如果噪声在时间上是随机抖动的,那多帧之间取平均不是更好吗?这就是时域去噪的核心思想。

时域去噪的公式其实很简单:

// 时域递归滤波
dst(t) = α * src(t) + (1 - α) * dst(t-1)

// α 是更新率,范围 0~1
// α 越大,当前帧权重越大,去噪越弱但运动拖影越少
// α 越小,历史帧权重越大,去噪越强但运动拖影越明显

这里有个关键问题——运动检测。如果场景里有运动物体,你还傻乎乎地把前后帧平均,那运动物体就拖影了。我做过一个项目,在监控摄像头上做时域去噪,没加运动检测,结果人走过去后面拖了一条尾巴,客户直接退货。

运动自适应时域去噪的核心思路:
  • 计算当前帧和上一帧的像素差
  • 差值大的区域认为是运动区域,减小 α(少用历史帧)
  • 差值小的区域认为是静止区域,增大 α(多用历史帧)
  • 用运动掩码(motion mask)来控制 α 的空间分布

4.3 3D去噪:多帧融合

3D去噪,说白了就是把空间域和时间域结合起来。空间域处理单帧内的噪声,时间域利用帧间相关性,两者互补,效果1+1>2。

我习惯把3D去噪的流程画成下面这张图,你看一眼就明白了:

3D去噪(多帧融合)流程图 当前帧 t 上一帧 t-1 前两帧 t-2 运动估计 (光流/块匹配) 运动补偿 (对齐到当前帧) 时域融合 (加权平均) 空间去噪 (双边/小波) 去噪结果 关键点: 1. 运动估计决定帧间对齐精度,直接影响融合质量 2. 时域融合优先做,空间去噪后做,避免细节被二次模糊

4.3.1 多帧融合的工程实现

实际工程里,多帧融合一般分三步走:

  1. 运动估计:用块匹配或光流法算出帧间运动矢量
  2. 运动补偿:把历史帧按运动矢量对齐到当前帧
  3. 加权融合:对齐后的多帧按权重叠加

权重怎么定?我一般用帧差置信度来做权重。帧差小的像素,说明对齐得好,权重给大点;帧差大的像素,说明可能有遮挡或运动估计不准,权重给小点。

// 多帧融合伪代码
void multiFrameDenoise(Frame* frames[], int numFrames, Frame* result) {
    // 以中间帧为参考帧
    int refIdx = numFrames / 2;
    Frame* ref = frames[refIdx];

    for (int y = 0; y < ref->h; y++) {
        for (int x = 0; x < ref->w; x++) {
            float sum = 0, weightSum = 0;
            for (int i = 0; i < numFrames; i++) {
                // 运动补偿:找到第i帧中对应(x,y)的位置
                int mx = x + motionVectors[i][y][x].x;
                int my = y + motionVectors[i][y][x].y;

                // 计算帧差置信度
                float diff = abs(ref->data[y][x] - frames[i]->data[my][mx]);
                float weight = expf(-diff * diff / (2 * sigma * sigma));

                sum += weight * frames[i]->data[my][mx];
                weightSum += weight;
            }
            result->data[y][x] = sum / weightSum;
        }
    }
}
我曾经踩过的坑:多帧融合的帧数不是越多越好。3~5帧效果提升明显,超过7帧收益递减,而且运动估计误差会累积。有一次我贪心用了9帧融合,结果运动区域全是鬼影,还不如3帧的效果好。

4.3.2 3D去噪的典型参数

参数 推荐值 说明
融合帧数 3~5 超过7帧收益递减
时域更新率 α 0.1~0.3 静止场景用0.1,运动场景用0.3
运动检测阈值 10~20(8bit) 低于阈值认为是静止区域
空间去噪强度 弱(σ=0.5~1.0) 时域已经去了一部分,空间别太猛

4.4 实战建议:怎么选去噪方案?

我做了这么多年ISP,总结了一套选型思路,分享给你:

  • 场景一:高光充足,噪声不明显 → 只用弱高斯滤波(σ=0.8),别上时域,省算力
  • 场景二:低光环境,噪声明显但场景静止 → 3D去噪,3帧融合,时域α=0.15
  • 场景三:低光且有运动物体 → 运动自适应时域滤波 + 弱空间去噪
  • 场景四:椒盐噪声(传感器坏点) → 中值滤波,3x3窗口就够了
我的个人习惯:先在时域把能去的噪声去了,再用空间域做精细调整。时域去噪不损失分辨率,空间域多少会损失一点。所以顺序很重要——先时域后空间,效果最好。

去噪这事,说到底就是取舍。没有万能方案,只有最适合当前场景的方案。你多试几次,慢慢就能找到感觉了。