☰
滤波与卷积
2026/10/8 6:55:29 网站建设 项目流程

滤波与卷积

目标:理解卷积核在做什么,会按噪声类型选滤波器而不是凭感觉,
并且知道BORDER_*那一堆常量到底在填什么。

写作基线:OpenCV 4.13.0。本篇的 PSNR 数值与边界填充值均为本机实测。


一、卷积在做什么

一句话:用一个小窗口扫过整张图,每个位置用窗口内像素的加权和替换中心像素。

那个「小窗口」就是卷积核(kernel),也叫掩膜。它是一个小的浮点矩阵,比如 3×3。

举一个具体的例子。假设原图某个 3×3 区域是:

100 100 100 100 100 100 100 100 100

用一个 3×3 的均值核(每个元素都是 1/9)去卷积中心像素:

结果 = 100×1/9 + 100×1/9 + ... + 100×1/9 (9 项) = 100

值没变 —— 因为周围都是同一个值。但如果这个区域里有一个亮点:

100 100 100 100 255 100 ← 中心是个亮点 100 100 100

均值核的结果是:

(100×8 + 255×1) / 9 = 117.2

亮点被拉低了,而且它的影响会扩散到周围的像素。这就是「模糊」。

反过来,如果用一个锐化核:

0 -1 0 -1 5 -1 0 -1 0

同样的区域算出来是:

结果 = 5×255 - 4×100 = 875 → 饱和到 255

而如果中心本来就是 100(平坦区域):

结果 = 5×100 - 4×100 = 100

平坦区域不变,亮点被推到更亮。这就是「锐化」—— 它放大了与周围的差异。

一个关键直觉:卷积核的元素之和决定了整体亮度。

  • 和为 1 → 亮度不变(均值核、高斯核、锐化核都是)
  • 和为 0 → 结果只保留「与周围的差异」,也就是边缘(拉普拉斯核、Sobel 核)
  • 和不为 0 也不为 1 → 整体会变亮或变暗

二、cv::filter2D:自定义核

#include<opencv2/imgproc.hpp>cv::Mat gray;cv::cvtColor(src,gray,cv::COLOR_BGR2GRAY);// 均值核:3×3,每个元素 1/9cv::Mat kMean=(cv::Mat_<float>(3,3)<<1,1,1,1,1,1,1,1,1)/9.0f;cv::Mat blurred;cv::filter2D(gray,blurred,-1,kMean);// ↑ ddepth = -1 表示输出与输入同类型

ddepth用-1的含义是「和输入一样」。但要注意:如果你用的是会产出负值的核(比如拉普拉斯),-1会让负值被截断成 0 —— 这时候要用CV_32F,或者先取绝对值:

cv::Mat edges;cv::filter2D(gray,edges,CV_32F,kLaplacian);// 保留负值cv::Mat edgesAbs;cv::convertScaleAbs(edges,edgesAbs);// 取绝对值并转回 8 位

四种核的效果

图 1:四种卷积核的效果对照(OpenCV 4.13.0 实测)。注意第一格与最后一格是一致的 ——cv::blur内部就是均值核。

核矩阵和效果
均值全 1 / 91模糊,抹掉细节
锐化[0,-1,0; -1,5,-1; 0,-1,0]1增强边缘对比,平坦区不变
浮雕[-2,-1,0; -1,1,1; 0,1,2]1方向性浮雕,灰底 + 亮暗边
拉普拉斯[0,1,0; 1,-4,1; 0,1,0]0只保留边缘,平坦区全黑

这四种核可以自己手写验证理解是否正确。写完和 OpenCV 内置函数对比 —— 比如你的均值核结果应该和cv::blur(gray, out, cv::Size(3,3))完全一致。


三、边界怎么处理

卷积核扫到图像边缘时,窗口会超出图像范围。超出的部分填什么,就是BORDER_*常量决定的。

图 2:五种边界填充模式。图中数值是cv2.copyMakeBorder的真实输出(OpenCV 4.13.0 实测):原始一行 6 个像素10 60 110 160 210 255,左右各补 3 个。

实测数据:

原始行: 10 60 110 160 210 255 BORDER_CONSTANT → 0 0 0 | 10 60 110 160 210 255 | 0 0 0 BORDER_REPLICATE → 10 10 10 | 10 60 110 160 210 255 | 255 255 255 BORDER_REFLECT → 110 60 10 | 10 60 110 160 210 255 | 255 210 160 BORDER_REFLECT_101 → 160 110 60 | 10 60 110 160 210 255 | 210 160 110 BORDER_WRAP → 160 210 255 | 10 60 110 160 210 255 | 10 60 110

最容易混的是REFLECT和REFLECT_101:

  • REFLECT把边缘像素重复一次再镜像:… 110 60 [10] | 10 60 …
  • REFLECT_101不重复边缘:… 160 110 60 | 10 60 …

「101」这个名字的来源是:把序列abcdef看成从 0 开始编号,REFLECT的镜像轴在边缘像素上,REFLECT_101的镜像轴在边缘像素之间(也就是 0 和 1 之间)—— 所以叫 101。

BORDER_REFLECT_101是绝大多数 OpenCV 滤波函数的默认值(BORDER_DEFAULT就是它的别名)。这是有道理的:重复边缘像素会人为地强化边缘,而 101 的镜像更接近「假设图像在外面自然延伸」。

实践中的选择

场景建议
一般滤波用默认的BORDER_REFLECT_101,别改
处理紧贴图像边界的物体用BORDER_REPLICATE,避免引入虚假的镜像内容
完全不想让边界影响结果用BORDER_CONSTANT+ 大value,然后裁掉边界
周期性的纹理图BORDER_WRAP

一个容易被忽略的影响:如果你做「原图减去滤波后的图」这类差分操作,边界区域的结果是被边界模式污染的。做精确测量时,应该把边界kernel/2个像素的范围排除掉。


四、四种常用滤波器

4.1 均值滤波cv::blur

cv::blur(src,dst,cv::Size(5,5));

盒式滤波,窗口内所有像素等权。速度最快,但模糊得最"脏"—— 它会产生方块状的伪影,而且对噪声的抑制不如高斯。

4.2 高斯滤波cv::GaussianBlur

cv::GaussianBlur(src,dst,cv::Size(5,5),1.5);// ↑ ↑// 核大小 sigma

权重按高斯分布:离中心越远权重越小。这是最常用的平滑滤波器,因为它没有方向性、没有方块伪影,而且数学性质好(可分离,见第六节)。

sigma和核大小的关系:传sigma <= 0时 OpenCV 会自己算一个 sigma。

这里有一个容易踩的细节(本机实测确认):OpenCV 对较小的核直接使用内置的固定系数表,这些系数和高斯公式算出来的并不相同。

ksize = 3 → 自动核 [0.25, 0.5, 0.25] ← 二项式系数 ksize = 5 → 自动核 [0.0625, 0.25, 0.375, 0.25, 0.0625] ksize = 7 → 自动核 [0.03125, 0.109375, 0.21875, 0.28125, ...]

我拿常见公式sigma = 0.3 × ((ksize-1) × 0.5 - 1) + 0.8去对比:

ksize公式值与 OpenCV 自动结果的最大差
32.2e-02 ← 不一致
58.3e-03 ← 不一致
76.8e-03 ← 不一致
92.0e-03 ← 仍不一致
110← 一致
130← 一致

也就是说ksize ≤ 9时走内置表,ksize ≥ 11时才走公式。

实践建议:需要精确控制平滑强度时,显式传sigma,不要依赖自动计算。只有当你"让 OpenCV 自己看着办"时,sigma=0才合适。

核大小一般取ksize ≈ 6σ + 1后向上取奇数。核太小会截断高斯分布(退化成盒式滤波),太大则白白增加计算量。

4.3 中值滤波cv::medianBlur

cv::medianBlur(src,dst,5);// ksize 必须是大于 1 的奇数

取窗口内像素的中位数,而不是加权平均。

这是它和线性滤波的本质区别:中值不是"平均"出来的值。一个孤立的极端值(比如 255)在排序后会被排到两端,对中位数毫无影响。

所以中值滤波对椒盐噪声(孤立的黑白点)几乎是完美的解 —— 见下一节的实测。

限制:cv::medianBlur对多通道图像,ksize只能到 5。需要更大核时要先转单通道,或者用cv::medianBlur的分离通道版本。而且它比线性滤波慢(要做排序)。

4.4 双边滤波cv::bilateralFilter

cv::bilateralFilter(src,dst,9,75,75);// ↑ ↑ ↑// d σColor σSpace

它在高斯滤波的基础上加了一个"颜色相似度"权重:

  • σSpace:空间距离越远,权重越小(和高斯滤波一样)
  • σColor:颜色差得越多,权重越小—— 这是关键

效果是:平坦区域被平滑,但边缘两侧不会互相"污染"。所以它能同时做到「去噪」和「保边」。

实测里它的表现:对高斯噪声 PSNR 达到30.93 dB,是四种滤波里最好的(因为它不模糊边缘,PSNR 惩罚更小)。

代价:它是这四种里最慢的,而且参数(尤其σColor)需要按图像内容调。σColor太大就退化成高斯滤波,太小则几乎不平滑。


五、按噪声类型选滤波器(实测)

这是本篇最实用的一节。

图 3:高斯噪声 σ=25 下的四种滤波器(OpenCV 4.13.0 实测)。

图 4:椒盐噪声 5% 下的对比(OpenCV 4.13.0 实测)。

实测 PSNR(dB,越高越好):

滤波器高斯噪声 σ=25椒盐噪声 5%
噪声图本身(滤波前)20.4118.47
均值滤波22.9024.75
高斯滤波24.6025.12
中值滤波27.0134.87⭐
双边滤波30.93⭐18.61 ⚠️

(PSNR 是「与原图的接近程度」,所以噪声图本身那一行是两个系列的基线。)

两个值得注意的结果:

1. 椒盐噪声下,中值滤波是压倒性优势。

34.87 dB 对比次优的 25.12 dB —— 差了将近 10 dB。原因在上一节讲过:中位数不被极端值影响。椒盐噪声就是一堆极端值,所以中值滤波几乎是为它量身定做的。

2. 双边滤波在椒盐噪声下是最差的**(18.61 dB)。**

这个反直觉的结果很值得想一下。双边滤波的设计目标是「保边」—— 它会保护那些与周围差异大的像素。而椒盐噪声恰恰就是"与周围差异极大"的像素。于是双边滤波忠实地把噪声当作边缘保护了下来,同时因为它的平滑项,反而把噪声周围的正常像素改动了 —— 结果比不滤波还糟(噪声图本身 15.11,滤波后 18.61,但比其他方法差得远)。

这就是「按噪声类型选滤波器」的意义:不存在万能的滤波器。

  • 高斯噪声 / 一般平滑 →双边滤波(保边)或高斯滤波
  • 椒盐噪声 / 孤立坏点 →中值滤波(几乎没有替代品)
  • 要求速度 → 均值滤波

怎么判断噪声类型

看直方图和空间分布:

特征噪声类型对策
直方图整体变胖、像素值连续分布高斯噪声双边 / 高斯
出现孤立的纯黑纯白点椒盐噪声中值
亮度成倍波动乘性噪声(散斑)先取对数转成加性
规则排列的亮点不是噪声,是固定模式干扰平场校正

工程上的做法:先对一张均匀背景图做统计。如果直方图是单峰连续分布 → 高斯;如果两端有孤立尖峰 → 椒盐。


六、可分离滤波:性能优化的一招

一个 5×5 的高斯核有 25 个系数。如果它可以写成两个一维核的外积,那么可以先横向卷一次、再纵向卷一次,计算量从25降到5 + 5 = 10。

高斯核是可分离的(这是它的重要性质)。所以:

// 方式一:直接用 GaussianBlur(OpenCV 内部已经做了分离优化)cv::GaussianBlur(src,dst,cv::Size(5,5),1.5);// 方式二:手动分离(当你有自定义的可分离核时)cv::Mat kx,ky;cv::getGaussianKernel(5,1.5,CV_32F);// 只生成一维核// ... 调整好方向后:cv::sepFilter2D(src,dst,-1,kx,ky);

什么时候需要手动分离:当你的自定义核是可分离的(比如二维 Sobel 可以拆成两个一维核)。这时sepFilter2D会比filter2D快一个数量级。

如果核不可分离(比如拉普拉斯),就只能用filter2D,没有捷径。

一个反向的优化建议

不要自己写嵌套循环做卷积。cv::filter2D内部做了向量化、多线程、以及针对小核的特殊优化。手写循环除了慢,还容易在边界处理上出错。

如果你的目的只是「验证卷积的理解」,写一次就够了;生产代码一律用 OpenCV 的函数。


七、这套方案什么时候会失败

  • 用双边滤波去椒盐噪声。实测最差(18.61 dB)。这不是参数问题,是算法设计与噪声性质冲突。
  • 用中值滤波处理高斯噪声期望获得最好效果。可以用(27.01 dB,不差),但不如双边。而且中值滤波会破坏细小的结构(细线、小点会被当作噪声抹掉)。
  • 核大小选偶数。cv::GaussianBlur的ksize.width/height必须是正奇数,否则会抛异常。cv::medianBlur同理。只有cv::blur的核可以是偶数,但那样会没有精确的中心像素,导致半个像素的偏移。
  • sigma和核大小不匹配。核太小而 σ 很大,高斯分布被截断,实际效果退化。反过来核很大而 σ 很小,边缘系数接近于 0,白白浪费计算。
  • 对差分结果不做边界排除。前面说过,边界kernel/2个像素的范围是被边界模式污染的。
  • 反复叠加多次滤波。每一步都会损失信息。如果你需要强平滑效果,用一个大核一次做完,而不是小核叠多次。
  • 在 8 位图上做多步滤波。每一步都有舍入误差,累积起来会造成可见的退化。高精度场景(比如做测量前的预处理)应该转CV_32F处理,最后再转回来。

八、Python 对照

8.1 函数名基本一一对应

importcv2 blur=cv2.blur(gray,(5,5))gauss=cv2.GaussianBlur(gray,(5,5),1.5)median=cv2.medianBlur(gray,5)# 注意:直接传整数,不是元组bilat=cv2.bilateralFilter(gray,9,75,75)custom=cv2.filter2D(gray,-1,kernel)# kernel 是 numpy 数组

medianBlur的参数形式和其他不同—— C++ 和 Python 都是直接传ksize整数,不是Size。这一点两边一致,但和上面几个函数不一致,容易记混。

8.2 卷积核的构造:Python 更简洁

importnumpyasnp# C++ 是 (cv::Mat_<float>(3,3) << 1,1,1, 1,1,1, 1,1,1) / 9.0f;# Python 直接写成二维数组:kernel=np.ones((3,3),np.float32)/9.0sharpen=np.array([[0,-1,0],[-1,5,-1],[0,-1,0]],np.float32)laplace=np.array([[0,1,0],[1,-4,1],[0,1,0]],np.float32)

dtype 必须是np.float32。用默认的float64会让filter2D报错(它要求CV_32F)。

8.3ddepth和负值

和 C++ 一样,用会产出负值的核(拉普拉斯)时:

lap=cv2.filter2D(gray,cv2.CV_32F,laplace)# 保留负值lap_abs=cv2.convertScaleAbs(lap)# 取绝对值转回 8 位

Python 侧还要注意 numpy 的溢出(见 01-04):filter2D内部是 C++ 实现,会正确饱和;但如果你自己用 numpy 做卷积,uint8的运算会回绕。

8.4 噪声生成:一个 C++ 和 Python 差异较大的地方

C++ 用cv::RNG+cv::randn/cv::randu:

cv::Matnoise(src.size(),CV_32F);cv::randn(noise,0,25);// 均值 0,标准差 25// 或者用带种子的 RNG 保证可复现cv::RNGrng(12345);rng.fill(noise,cv::RNG::NORMAL,0,25);

Python 侧没有cv2.RNG这个类(本系列配图脚本开发时实际踩到过:AttributeError: module 'cv2' has no attribute 'RNG')。用 numpy:

rng=np.random.default_rng(seed=12345)# 固定种子,保证可复现noise=rng.normal(0,25,img.shape).astype(np.float32)noisy=np.clip(img.astype(np.float32)+noise,0,255).astype(np.uint8)

np.clip这一步不能省。加完噪声后会有超出[0,255]的值,直接.astype(np.uint8)会回绕(01-04 讲过),产生一堆假的纯白/纯黑点 —— 而这恰好会被误认为"椒盐噪声"。

8.5 PSNR 两边都有

score=cv2.PSNR(filtered,original)# 单位 dB

C++ 侧同样是cv::PSNR()。比较滤波器效果时用它比"看起来哪个好"客观得多。


九、自测

  1. 一个卷积核的所有元素之和是 0,它的作用是什么?把一个「和为 0」的核用在纯色区域上,结果是什么?
  2. BORDER_REFLECT和BORDER_REFLECT_101有什么区别?哪个是 OpenCV 滤波函数的默认值?
  3. 椒盐噪声应该用什么滤波器?为什么双边滤波在这里反而最差?
  4. 为什么高斯核可以用sepFilter2D加速,而拉普拉斯核不行?
答案
  1. 作用相当于高通滤波,只保留与周围的差异 —— 也就是边缘。它是线性的,所以在纯色区域(所有像素相同)上结果恒为 0(因为c × Σk = c × 0 = 0),也就是全黑。
  2. REFLECT会把边缘像素重复一次再镜像(…110 60 [10] | 10 60…);REFLECT_101不重复边缘(…160 110 60 | 10 60…)。REFLECT_101是默认值(BORDER_DEFAULT是它的别名)。
  3. 用中值滤波。因为它取的是窗口内的中位数,而椒盐噪声是少量极端值,在排序后位于两端,对中位数没有影响。双边滤波最差是因为它的设计目标就是「保护与周围差异大的像素」,而椒盐噪声正是这样的像素—— 它把噪声当边缘保护了下来。
  4. 因为高斯核在数学上可以分解为两个一维核的外积(G(x,y) = G(x)·G(y)),所以能拆成横纵两次一维卷积,计算量从k²降到2k。而拉普拉斯核[0,1,0; 1,-4,1; 0,1,0]不能这样分解—— 它不是可分离核。判据是:核矩阵的秩为 1 才可分离。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询