到今天这篇,Python数字图像处理系列已经写到第四篇了。翻了一下前面的记录,第一篇处理的是图像的读取、显示、保存这些基础操作,第二篇把几何变换和颜色空间过了一遍,第三篇在直方图、伽马变换和对比度增强上花了不少功夫。按正常的学习顺序,走到这一步,空间滤波就必须安排上了。
这一篇我会花最多篇幅在四类最常用的空间滤波器上:均值滤波、高斯滤波、中值滤波、双边滤波。不仅讲OpenCV里怎么一行调用,还会把每个滤波器的原理拆开揉碎,最后再用NumPy手动实现一遍卷积滤波流程。这篇内容适合已经掌握了Python基础语法、会用OpenCV读图,并且想系统理解"图像为什么变糊了、噪声到底是怎么没的"的读者。看完之后你至少能把滤波和噪声模型对上号,调参的时候心里有谱,而不是随手试几个数值碰运气。
1. 系列进度走到这儿,为什么第四篇轮到滤波
1.1 前三篇的知识地图
先简短回顾一下前三篇的大致内容。第一篇的核心是搞定IO:cv2.imread怎么读图、cv2.imshow怎么显示、cv2.imwrite怎么保存,以及BGR和RGB的通道顺序坑。第二篇围绕几何变换展开,包括缩放、旋转、仿射变换和透视变换,顺手把颜色空间转换也梳理了一遍,比如RGB转灰度、RGB转HSV。第三篇做的是图像增强,重点讲了直方图均衡化、伽马变换、灰度拉伸这些对比度调整手段。
这三篇其实是同一条线索的层层递进:先把图像读进来,再对图像的几何位置和表示形式做变换,然后再对像素值本身做全局调整。但这里存在一个明显的缺口——图像的噪声问题。日常拍摄的照片、扫描的文档、视频抽帧出来的画面,没有一张是绝对干净的,多多少少都有颗粒感、雪花点、坏点。如果带着这些噪声直接做后续的阈值分割、边缘检测、特征提取,结果通常惨不忍睹。第四篇的内容就是补上"如何在空间域里把图像变干净"这块拼图。
1.2 滤波到底在解决什么问题
滤波的核心目标只有一个:在尽量保留真实细节的前提下,去除或者抑制无关信息。这些"无关信息"可能是传感器带来的随机噪声,可能是传输过程中出现的异常像素值,也可能是扫描稿件上的灰尘痕迹。空间滤波的思路非常直白:用一个固定大小的窗口在图像上逐像素滑动,窗口覆盖的那个局部区域做一次数学运算,运算结果作为中心像素的新值。这个思路看起来简单,但不同运算规则衍生出的滤波器,行为差异极大。
很多初学者会把"滤波"直接等同为"模糊",这个理解不能算错,但太粗糙了。均值滤波确实是模糊,高斯滤波也是模糊,但中值滤波能去掉椒盐噪声的同时还保留锐利的边缘,双边滤波更是能把降噪和保边同时做到。所以这一篇真正的学习目标,是搞清楚每种运算规则背后的数学直觉,再针对性处理不同的噪声情形。
2. 先搞清楚噪声长什么样,才知道滤波在滤什么
2.1 三种最常见的噪声模型
图像噪声在数学模型上有各自的概率分布特征,平时用得最多的就是下面三种。
高斯噪声是最常见的加性噪声模型,每一个像素上叠加一个服从正态分布的随机值。它的成因很多,比如传感器暗电流、高ISO带来的电路噪声、光照不足时的热噪声,都会让图像看起来像蒙了一层细密的颗粒。高斯噪声的视觉效果是整体性的颗粒感,没有明显的孤立亮点或暗点。
椒盐噪声则完全不同。它的表现是图像上随机位置出现纯白或纯黑的像素点,像撒了一把盐和胡椒。来源通常是传输过程中的位翻转、传感器坏点、或者存储介质的错误。这类噪声的特点是强度极端,要么是255要么是0,跟周围像素反差极大,用排序类方法处理特别有效。
泊松噪声在低光照条件下比较明显,比如夜间监控视频、天文摄影。光子到达传感器的数量本身服从泊松分布,光子数越少,噪声相对强度越高。这种噪声的方差和信号强度相关,不是简单的加性模型,处理起来也更复杂一些。这一篇我不展开做泊松噪声的抑制,但在识别噪声类型的时候值得有个印象。
2.2 噪声污染后的图像会发生什么
以一张灰度图为例,如果叠加了均值为0、标准差为25的高斯噪声,直观感受是图像变"毛糙"了,暗部的颗粒尤其明显,有些地方看起来像磨砂玻璃。如果叠加5%密度的椒盐噪声,图像上会随机出现密密麻麻的黑白点,像老电视的雪花点一样分散。
如果在这种状态下直接做边缘检测,cv2.Canny会对噪声点产生大量虚假响应,原本干净的二值边缘图被噪点和乱线淹没。如果直接做自适应阈值分割,噪声会让局部阈值计算结果不稳定,文字区域可能出现大片黑斑。也就是说,噪声不处理,后面的所有操作都建立在不可靠的数据基础上。这就是为什么滤波必须放在图像预处理阶段优先解决。
3. 四种滤波器原理拆解:从算术平均到保边平滑
3.1 均值滤波:拿一个窗口的平均值换一个像素
均值滤波的思路最简单:把窗口内所有像素值加起来求平均,作为中心像素的新值。以3×3窗口为例,输出图像的每个像素就是原图对应位置周围9个像素的算术平均,等效于用一个3×3的全1矩阵除以9作为卷积核对图像做卷积。
import cv2 import numpy as np img = cv2.imread('lena.jpg', cv2.IMREAD_GRAYSCALE) # 均值滤波,核大小5x5 result = cv2.blur(img, (5, 5))均值滤波的优点是计算极快、原理直观,但它有两个明显短板。第一,它对所有像素一视同仁,窗口中心本身可能是噪声点,也可能是真实边缘,都同样参与到平均里,所以降噪的同时必然把边缘一起模糊掉。第二,它对椒盐噪声的抑制效果一般,因为极端值虽然被平均拉低了一些,但仍然会污染周围一片区域,图像上会出现灰白色的"晕影"。
3.2 高斯滤波:加权平均才是关键
高斯滤波和均值滤波的区别在于"权重"二字。离中心像素越近的邻域像素,相关性越高,应当获得更大的权重;离得越远的像素,影响越小。权重由二维高斯函数决定:
G(x, y) = (1 / (2 * pi * sigma^2)) * exp(-(x^2 + y^2) / (2 * sigma^2))其中(x, y)是邻域像素相对中心的偏移量,sigma控制权重衰减速度。sigma越大,邻域像素权重越均匀,滤波结果越接近均值滤波;sigma越小,中心权重越突出,图像基本不变。OpenCV里生成高斯核有现成函数:
# 获取一维高斯核 kx = cv2.getGaussianKernel(5, 1.0) # 用两个一维核相乘得到二维高斯核 kernel_2d = kx * kx.T print(kernel_2d) # 直接调用高斯滤波 result = cv2.GaussianBlur(img, (5, 5), 1.0)高斯滤波的平滑效果比均值滤波自然,因为它更尊重中心像素的信息,但本质仍然是一种低通滤波,会把高频的边缘细节去掉。边缘是图像中灰度突变的位置,在高斯滤波看来就是"高频分量",被抑制在所难免。
3.3 中值滤波:排序取中位数的暴力美学
中值滤波完全不搞加权平均那一套,做法是把窗口内所有像素值排序,直接取中间值作为结果。比如3×3窗口里有9个像素,排序后第5个值就是中位数。这个方法对付椒盐噪声特别有效,因为椒盐噪声的像素值极端(0或255),排序后它们必然会落到序列的两端,中位数几乎不受影响。
result = cv2.medianBlur(img, 5)中值滤波还有一个好处:它在去除孤立噪点的同时,不会像平均类滤波那样把边缘"糊"开,边缘锐度保持得比较好。这是因为它没有做线性加权平均,而是一个非线性排序操作。代价是计算量大,排序算法比加法乘法贵得多,而且当噪声密度过大时,窗口内可能多个极端值导致中位数也失真,需要加大窗口才能压住噪声。
3.4 双边滤波:保边去噪的进阶玩法
双边滤波可以理解为高斯滤波的改良版。高斯滤波的权重只取决于空间距离,而双边滤波在空间距离之外再加了一个权重:像素值差异。如果邻域像素和中心像素的灰度值很接近,就给予高权重;如果灰度差很大,说明很可能跨越了边缘,权重会被压低。
result = cv2.bilateralFilter(img, d=9, sigmaColor=75, sigmaSpace=75)这样就形成了一个很有意思的效果:在平坦区域,邻域像素灰度都差不多,两个权重共同作用,等效于一个平滑滤波;在边缘附近,灰度差异大的那一侧权重降得很低,边缘不会被跨区域平均,于是边缘被保留。双边滤波是这篇四种滤波里唯一能"降噪同时保边"的,但代价是参数多、计算量大,而且sigmaColor和sigmaSpace需要根据图像特点调,并不像前面几种那么"开箱即用"。
4. OpenCV里这些滤波器怎么用,参数怎么调
4.1 五个API的完整用法
OpenCV把上述四种滤波直接封装成了现成函数,先完整过一遍调用方式和参数含义。
import cv2 img = cv2.imread('lena.jpg') # 1. 均值滤波 # ksize必须是正奇数,常见3、5、7 blur_3 = cv2.blur(img, (3, 3)) blur_5 = cv2.blur(img, (5, 5)) # 2. 方框滤波,normalize=True时和blur一致 box = cv2.boxFilter(img, ddepth=-1, ksize=(5, 5), normalize=True) # 3. 高斯滤波 # sigmaX设为0时会根据ksize自动计算 gauss_5 = cv2.GaussianBlur(img, (5, 5), 0) # 4. 中值滤波 median_3 = cv2.medianBlur(img, 3) median_5 = cv2.medianBlur(img, 5) # 5. 双边滤波 # d是邻域直径,sigmaColor是灰度值域sigma,sigmaSpace是空间域sigma bilateral = cv2.bilateralFilter(img, d=9, sigmaColor=75, sigmaSpace=75)所有函数都要求核尺寸为正奇数,原因很直接:只有奇数尺寸才有真正的中心像素。如果传入偶数,OpenCV会直接抛错:ksize must be odd,这个坑新手常踩。
4.2 核大小和sigma这两个参数的决定性作用
核大小决定了滤波器的"感受野"。核越大,参与运算的像素越多,平滑力度越强,但细节丢失也越严重。以均值滤波为例,3×3核基本只是轻微磨皮,7×7就能明显看到边缘发虚,11×11以上图像基本被"糊成抽象画"。高斯滤波里还有一个sigmaX参数,很多教程都让读者直接填0,理由是"OpenCV会自动计算"。
# sigmaX=0时的自动计算规则 sigma = 0.3 * ((ksize - 1) * 0.5 - 1) + 0.8这个自动计算给出了一个还算合理的默认值,但如果你有明确需求,比如想保留更多纹理细节,就应该手动指定较小的sigma;想要更强的平滑,就手动调大sigma。中值滤波的核心参数只有核大小一个,它同时决定了排序的样本数和处理强度。椒盐噪声稀疏时3×3就够,密度大了要升到5×5甚至7×7,但核越大,真实细节的边缘也会被"中位数"改变,画面会有一定程度的失真。
双边滤波的参数最多,也是最需要花时间调的一组。d控制邻域直径,一般取9或12;sigmaSpace控制空间距离的衰减速度,决定多大范围内的像素参与平滑;sigmaColor控制灰度差异的容忍度,决定什么程度算"跨边缘"。我的经验是,先固定d=9,把sigmaSpace设为75左右保证平滑范围,然后从50开始慢慢加大sigmaColor,直到噪声消失但边缘还没有明显模糊。
4.3 调参经验:什么噪声配什么滤波器
不同噪声类型用同一种滤镜,效果天差地别。把实际经验汇总成一张表,日常查表用足够了。
| 噪声类型 | 推荐滤波器 | 理由 | 避坑提示 |
|---|---|---|---|
| 高斯噪声 | 高斯滤波或均值滤波 | 噪声是随机小幅扰动,线性平均有效 | 高斯滤波核大小和sigma要匹配,核大sigma小会不自然 |
| 椒盐噪声 | 中值滤波 | 极端值排序后落两端,中位数免疫 | 噪声密度大时加大核尺寸,别勉强用3×3 |
| 混合噪声 | 先中值后高斯 | 先清掉极端点,再处理随机扰动 | 顺序不能反,先高斯会让椒盐噪点扩散 |
| 既要降噪又要边缘 | 双边滤波 | 灰度差异权重能保护边缘 | 参数多,耐心调sigmaColor |
| 实时视频流 | 高斯滤波 | 速度够快且效果稳定 | 核别超过5×5,否则轻量模糊也费时间 |
还有一条通用建议:滤波不是越强越好,而是要"够用"。工业检测场景里,如果高斯噪声本身很轻微,3×3高斯核已经足够,强行上7×7会把细小的划痕磨没,反而丢了关键特征。
5. 手写卷积完成后,滤波原理才真正焊死在脑子里
5.1 边界填充:滤波之前少不了的伪装
卷积滤波有个容易被忽略的细节:当窗口滑到图像边界时,窗口有一部分落在图像外面。比如3×3窗口在左上角时,有四个像素根本不存在。这时候必须填充边界,常见方式有四种。
零填充是把图像外部的像素全部当作0,实现最简单,但会让边界像素偏暗,图像四周出现一圈不自然的暗边。反射填充是把边界外的像素按镜像对称取过来,效果最自然,也是实际项目里我默认选的方式。复制填充让边界外的像素等于最近的边界像素值,在某些场景里也够用。周期性填充在需要频域连续性时可能用到,空间滤波里比较少见。
5.2 从零写一个卷积滤波函数
为了彻底搞明白滤波的运算过程,我建议你至少手写一次二维卷积。下面这段代码是我学习时自己实现的核心函数,完全基于NumPy,没有用OpenCV。
import numpy as np def my_conv2d(img, kernel, mode='reflect'): h, w = img.shape kh, kw = kernel.shape ph, pw = kh // 2, kw // 2 # 边界填充 if mode == 'zero': pad_img = np.pad(img, ((ph, ph), (pw, pw)), mode='constant', constant_values=0) elif mode == 'reflect': pad_img = np.pad(img, ((ph, ph), (pw, pw)), mode='reflect') elif mode == 'edge': pad_img = np.pad(img, ((ph, ph), (pw, pw)), mode='edge') else: raise ValueError("unknown padding mode") # 输出初始化 out = np.zeros_like(img, dtype=np.float32) # 逐像素滑动窗口做加权求和 for i in range(h): for j in range(w): roi = pad_img[i:i + kh, j:j + kw] out[i, j] = np.sum(roi * kernel) return out这个函数写出来,卷积的过程就一目了然:padding把输入变大,然后窗口在填充后的图上滑动,每个位置做一次加权求和。调用它复现均值滤波的方法很简单:
kernel = np.ones((3, 3), dtype=np.float32) / 9.0 result = my_conv2d(img, kernel, mode='reflect') # 结果转换回uint8 result = np.clip(result, 0, 255).astype(np.uint8)高斯滤波也一样,把前面算出的二维高斯核传进去就能得到和cv2.GaussianBlur非常接近的结果。这里有一个关键点:如果图像是uint8类型,直接计算可能会出现负值或超过255的情况,所以中间计算要转成float32,最后用np.clip裁剪到[0, 255]再转回uint8。这步不处理的话,图像会出现黑色斑块或者整体偏亮。
5.3 手写版本和OpenCV的性能差距有多大
手写双for循环版本的运行速度会让人怀疑人生。以512×512的灰度图、3×3核为例,Python双for循环执行一次大约要几百毫秒到一两秒,换到7×7核会更慢。而OpenCV的cv2.filter2D底层是C++实现,同样的操作大部分在几毫秒内完成,相差了两个数量级不止。
这个性能差距恰恰是最好的学习素材:手动实现的目的从来不是替代OpenCV,而是确认自己理解了向量和矩阵的运算过程。跑通一遍之后,生产环境请老老实实用cv2.filter2D或者上述现成函数。如果实在想感受一下优化思路,可以把双for循环改造成用NumPy切片加矩阵乘法的方式,一次处理一个偏移位置,性能能提升不少,但这属于进阶话题了。
6. 实验效果对比与这一篇踩过的坑
6.1 椒盐噪声和高斯噪声下的滤波效果实测描述
我按经典流程做了个对比实验:先给灰度图分别添加椒盐噪声和高斯噪声,再用四种滤波器分别处理。这里不贴完整代码,只说我观察到的现象,大家跑一遍就能复现。
对5%密度的椒盐噪声,3×3均值滤波能把黑白点压成灰斑,但整张图明显模糊,细看还能看到噪点留下的浅色痕迹。3×3中值滤波几乎完美,黑点白点基本消失,边缘还能保持原有的锐度,肉眼看不出明显损失。5×5中值滤波把剩余的小瑕疵也清干净了,但注意看图像中细碎纹理部分,会有轻微钝化感。
对均值为0、标准差为25的高斯噪声,3×3均值滤波和高斯滤波都能去掉一部分颗粒,但噪声仍然是可见的。把核增大到5×5,高斯滤波的平滑效果明显优于均值滤波,画面更干净,同时边缘不模糊到无法接受。双边滤波在降噪和保边之间表现出色,头发丝、眼睫毛这类细节保留明显优于另外三种,但参数如果调得太激进,会出现"塑料感",图像表面会变得异常光滑甚至蜡化。
6.2 四个容易翻车的细节
这一篇我踩过的坑不少,挑几个有代表性的说说。
第一个是数据类型。图像从cv2.imread读出来是uint8,uint8的数值范围只有0到255。均值滤波做除法后会产生小数,如果直接赋值回uint8计算,小数直接丢掉,轮廓会丢失细节。高斯滤波可能产生负值,负值在uint8里直接溢出变成接近255的亮色,图像上出现莫名其妙的白色噪点。所以手写滤波时必须用float中间态。
第二个是核大小必须奇数。这个之前提过,但值得再强调一遍,因为很多人会在写代码时把核大小做成变量,结果传参传了个偶数进去,运行时才报错。养成习惯:核大小可以用表达式ksize = 2 * radius + 1来保证永远是奇数。
第三个是彩色图像通道处理。用OpenCV的现成函数处理彩色图没问题,cv2.GaussianBlur会自动逐通道处理。但手写的时候如果直接把彩色图当二维数组传进来,BGR三个通道会被当成一个大平面图,输出结果完全错乱。手写实现时先cv2.split拆通道,处理完再cv2.merge合并回去。
第四个是边界填充方式的选择。默认用cv2.blur时OpenCV内部用复制边界的方式,效果还行。如果手写并且忽略padding直接计算,图像四周会出现一圈明显的黑框。对于内容延伸到图像边缘的图,反射填充效果最自然;对于背景单一的图像,零填充也不致命。但无论如何,不要省略padding。
# 彩色图手写滤波时正确的处理流程 img_bgr = cv2.imread('lena.jpg') channels = cv2.split(img_bgr) filtered = [] for ch in channels: filtered_ch = my_conv2d(ch.astype(np.float32), kernel) filtered.append(np.clip(filtered_ch, 0, 255).astype(np.uint8)) result = cv2.merge(filtered)6.3 如何记住这一类方法的选型思路
学完四种滤波器之后,特别容易陷入"我到底该用哪一个"的选择困难。我自己的简化思路是:先诊断噪声的类型。图像上有不规则黑白散点,就中值滤波;整体有颗粒感和模糊蒙层,就高斯滤波或均值滤波;既要平滑噪声又要盯着边缘不放,就双边滤波。如果后面还要做边缘检测或轮廓提取,优先中值和双边,别用大核均值滤波。
如果图像本身噪声不大,我其实更推荐跳过空间滤波直接做后续处理。不少人一上来就滤波,反而把细节抹掉了。滤波永远是按需使用,不是必经流程。
这几种滤波是空间域的入门基础,下一篇我想沿着"图像锐化和边缘检测"继续往下走。到时候会用到拉普拉斯算子、Sobel算子、Canny检测,也会把这一篇讲的中值滤波和双边滤波放在边缘检测场景里重新审视。如果你也正在学数字图像处理,可以先把这篇里的代码跑一遍,尤其是自己动手写一版卷积,再用OpenCV的结果对比,那种"原来如此"的感觉比自己看十遍文档都来得扎实。