3. 经典空域降噪:均值滤波、高斯滤波、中值滤波的原理与C++实现
各位好,欢迎来到第三章。
前面我们聊了噪声模型和图像质量评估。从这章开始,咱们正式进入降噪算法的实战环节。今天要讲的三个滤波器——均值、高斯、中值——是空域降噪的基石。别看它们简单,我在实际项目中见过不少工程师连这几个基础滤波器的适用场景都搞混,结果调了半天效果反而更差。
说白了,这三个滤波器代表了两种完全不同的思路:线性平滑和非线性排序。理解它们的本质区别,比背代码重要得多。
3.1 均值滤波:最朴素的平滑
均值滤波的原理,一句话就能说清楚:用邻域内所有像素的平均值代替中心像素。
你想想看,如果某个像素被噪声污染了,它和周围像素的差异通常会比较大。取个平均值,就能把这个异常值“拉”回来。这就是降噪的基本逻辑。
g(x, y) = (1 / (m × n)) × Σ f(i, j)
其中 (i, j) 属于以 (x, y) 为中心的 m×n 邻域
代码实现非常直接。我习惯用模板参数来控制核大小,这样编译期就能优化循环展开:
// 均值滤波 - 手动实现
void meanFilter(const cv::Mat& src, cv::Mat& dst, int kernelSize) {
// kernelSize 必须是奇数,且大于等于3
CV_Assert(kernelSize % 2 == 1 && kernelSize >= 3);
dst = src.clone();
int half = kernelSize / 2;
float scale = 1.0f / (kernelSize * kernelSize);
for (int y = half; y < src.rows - half; ++y) {
for (int x = half; x < src.cols - half; ++x) {
float sum = 0.0f;
for (int ky = -half; ky <= half; ++ky) {
for (int kx = -half; kx <= half; ++kx) {
sum += src.at<uchar>(y + ky, x + kx);
}
}
dst.at<uchar>(y, x) = cv::saturate_cast<uchar>(sum * scale);
}
}
}
均值滤波最大的问题是什么?它把所有像素一视同仁。边缘处的像素和平坦区域的像素,权重完全一样。结果就是:噪声被平滑了,边缘也模糊了。核越大,模糊越严重。
我记得有一次做车载摄像头的预处理,用均值滤波降噪,结果车道线边缘全糊了,车道线检测算法直接罢工。从那以后,我对均值滤波就格外谨慎——它只适合噪声强度很低、且对边缘细节要求不高的场景。
3.2 高斯滤波:带权重的平滑
高斯滤波解决了均值滤波“一视同仁”的问题。它的核心思想是:离中心像素越近的邻域像素,权重越大。权重由高斯函数决定。
为什么这样更合理?因为自然图像中,相邻像素的相关性本来就随着距离增大而减弱。高斯滤波正好模拟了这一特性。
G(x, y) = (1 / (2πσ²)) × exp(-(x² + y²) / (2σ²))
关键参数 σ(标准差):
σ 越大,权重分布越平坦,平滑效果越强,边缘越模糊。
σ 越小,权重越集中在中心,平滑效果越弱,边缘保留越好。
生成高斯核的代码,我建议直接计算浮点核,不要用整数近似。虽然整数核速度快,但精度损失在后续的联合调试中可能会引入意想不到的偏差:
// 生成高斯核
cv::Mat getGaussianKernel(int kernelSize, float sigma) {
CV_Assert(kernelSize % 2 == 1 && kernelSize >= 3);
cv::Mat kernel(kernelSize, kernelSize, CV_32F);
int half = kernelSize / 2;
float sum = 0.0f;
// sigma 的默认值:0.3 * ((kernelSize - 1) * 0.5 - 1) + 0.8
if (sigma <= 0.0f) {
sigma = 0.3f * (half - 1) + 0.8f;
}
float twoSigma2 = 2.0f * sigma * sigma;
for (int y = -half; y <= half; ++y) {
for (int x = -half; x <= half; ++x) {
float value = exp(-(x * x + y * y) / twoSigma2);
kernel.at<float>(y + half, x + half) = value;
sum += value;
}
}
// 归一化,保证权重总和为1
kernel /= sum;
return kernel;
}
// 高斯滤波应用
void gaussianFilter(const cv::Mat& src, cv::Mat& dst,
int kernelSize, float sigma) {
cv::Mat kernel = getGaussianKernel(kernelSize, sigma);
dst = cv::Mat::zeros(src.size(), src.type());
int half = kernelSize / 2;
for (int y = half; y < src.rows - half; ++y) {
for (int x = half; x < src.cols - half; ++x) {
float sum = 0.0f;
for (int ky = -half; ky <= half; ++ky) {
for (int kx = -half; kx <= half; ++kx) {
float weight = kernel.at<float>(ky + half, kx + half);
sum += weight * src.at<uchar>(y + ky, x + kx);
}
}
dst.at<uchar>(y, x) = cv::saturate_cast<uchar>(sum);
}
}
}
高斯滤波是 ISP 流水线中最常用的线性滤波器。但它本质上还是低通滤波,对边缘的保护只是“相对更好”,并不能真正区分噪声和边缘。遇到椒盐噪声,高斯滤波的表现甚至不如均值滤波——因为椒盐噪声的异常值会通过加权的方式扩散到邻域中。
3.3 中值滤波:非线性的抗噪利器
中值滤波的思路和前面两个完全不同。它不做加权平均,而是取邻域内所有像素的中位数作为输出。
为什么要取中位数?因为中位数对异常值不敏感。假设一个 3×3 的邻域里,中心像素被椒盐噪声污染成了 255,周围都是 50 左右的正常值。排序之后,中位数大概率是 50 附近的某个值,噪声被完美剔除。
1. 将邻域内所有像素值排序
2. 取排序后的中间值作为输出
3. 对于奇数大小的核,中位数是唯一的;对于偶数大小,通常取中间两个值的平均
代码实现的关键在于快速求中位数。最直接的方法是排序,但排序的时间复杂度是 O(n log n)。对于 3×3 的核,n=9,排序还能接受。但核一大,性能就扛不住了。
我常用的优化方法是直方图法:维护一个滑动窗口内的像素直方图,每次移动窗口时,只更新进入和离开的像素,然后从直方图中累加找到中位数。这样时间复杂度降到 O(256),和核大小无关:
// 中值滤波 - 直方图优化版本(仅适用于8位图像)
void medianFilterFast(const cv::Mat& src, cv::Mat& dst, int kernelSize) {
CV_Assert(src.type() == CV_8UC1);
CV_Assert(kernelSize % 2 == 1 && kernelSize >= 3);
dst = cv::Mat::zeros(src.size(), CV_8UC1);
int half = kernelSize / 2;
int medianPos = (kernelSize * kernelSize) / 2; // 中位数的位置
for (int y = half; y < src.rows - half; ++y) {
// 初始化当前行的直方图
int hist[256] = {0};
// 填充初始窗口
for (int ky = -half; ky <= half; ++ky) {
for (int kx = -half; kx <= half; ++kx) {
hist[src.at<uchar>(y + ky, kx + half)]++;
}
}
for (int x = half; x < src.cols - half; ++x) {
// 从直方图中找中位数
int count = 0;
int median = 0;
for (int i = 0; i < 256; ++i) {
count += hist[i];
if (count > medianPos) {
median = i;
break;
}
}
dst.at<uchar>(y, x) = median;
// 窗口右移:移除左列,添加右列
if (x + half + 1 < src.cols) {
int leftCol = x - half;
int rightCol = x + half + 1;
for (int ky = -half; ky <= half; ++ky) {
// 移除左列
hist[src.at<uchar>(y + ky, leftCol)]--;
// 添加右列
hist[src.at<uchar>(y + ky, rightCol)]++;
}
}
}
}
}
中值滤波对椒盐噪声的抑制效果堪称完美。但它也有代价:它会破坏图像的精细纹理。因为中位数本质上是一种“多数表决”,如果某个纹理在邻域内不占多数,就会被当作噪声抹掉。
我曾经在一个医疗影像项目里遇到过这个问题。X 光片上的微钙化点只有几个像素大小,用中值滤波一过,全没了。后来我改用自适应中值滤波——根据局部统计特性动态调整核大小,才在降噪和细节保留之间找到平衡。
3.4 三种滤波器的对比与选型
说了这么多,到底什么时候用哪个?我整理了一张对比表,方便你快速决策:
| 滤波器 | 噪声类型 | 边缘保留 | 计算速度 | 典型应用场景 |
|---|---|---|---|---|
| 均值滤波 | 高斯噪声(弱) | 差 | 快 | 预处理、噪声极低的场景 |
| 高斯滤波 | 高斯噪声(强) | 中等 | 中等 | ISP 流水线通用降噪、尺度空间构建 |
| 中值滤波 | 椒盐噪声 | 好(边缘不模糊) | 慢(直方图优化后中等) | 去噪点、去坏点、扫描文档处理 |
选型建议:
- 如果你不确定噪声类型:先用高斯滤波,σ 从 1.0 开始试。这是最稳妥的起点。
- 如果图像有明显的黑白点:直接上中值滤波,核大小从 3×3 开始。别犹豫。
- 如果对实时性要求极高:均值滤波 + 小核(3×3)是最快的选择。但要做好边缘模糊的心理准备。
- 如果既要降噪又要保边:这三个都不够用。后面章节讲的双边滤波和非局部均值才是正解。
3.5 知识结构总览
下面这张图总结了三种滤波器的核心逻辑和关系,你可以把它当作本章的思维导图:
嗯,这张图把三种滤波器的定位说得很清楚了。你可以把它保存下来,以后做方案选型时对照着看。
最后说一句:不要迷信任何一个滤波器。我在项目中见过太多人拿着中值滤波处理高斯噪声,或者用大核高斯滤波去椒盐噪声,结果效果还不如不做。理解每种滤波器的数学本质和适用边界,才是真正的工程能力。